Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add StreamLibrary gRPC stream and notify on scan

Define StreamLibrary messages and RPC in the proto and regenerate
client/server stubs (rpc, cli, gpui, rocksky). Implement server
streaming in crates/rpc to forward graphql SimpleBroker<ScanCompleted>
events. Publish ScanCompleted from the HTTP scan handler so UI clients
(subscribed by GPUI) receive real-time library-scan updates; update GPUI
state and spawn a library stream consumer.

+1417 -846
+78
cli/src/api/rockbox.v1alpha1.rs
··· 506 506 } 507 507 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 508 508 pub struct ScanLibraryResponse {} 509 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 510 + pub struct StreamLibraryRequest {} 511 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 512 + pub struct StreamLibraryResponse {} 509 513 #[derive(Clone, PartialEq, ::prost::Message)] 510 514 pub struct SearchRequest { 511 515 #[prost(string, tag = "1")] ··· 842 846 )); 843 847 self.inner.unary(req, path, codec).await 844 848 } 849 + pub async fn stream_library( 850 + &mut self, 851 + request: impl tonic::IntoRequest<super::StreamLibraryRequest>, 852 + ) -> std::result::Result< 853 + tonic::Response<tonic::codec::Streaming<super::StreamLibraryResponse>>, 854 + tonic::Status, 855 + > { 856 + self.inner.ready().await.map_err(|e| { 857 + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) 858 + })?; 859 + let codec = tonic::codec::ProstCodec::default(); 860 + let path = http::uri::PathAndQuery::from_static( 861 + "/rockbox.v1alpha1.LibraryService/StreamLibrary", 862 + ); 863 + let mut req = request.into_request(); 864 + req.extensions_mut().insert(GrpcMethod::new( 865 + "rockbox.v1alpha1.LibraryService", 866 + "StreamLibrary", 867 + )); 868 + self.inner.server_streaming(req, path, codec).await 869 + } 845 870 pub async fn search( 846 871 &mut self, 847 872 request: impl tonic::IntoRequest<super::SearchRequest>, ··· 924 949 &self, 925 950 request: tonic::Request<super::ScanLibraryRequest>, 926 951 ) -> std::result::Result<tonic::Response<super::ScanLibraryResponse>, tonic::Status>; 952 + /// Server streaming response type for the StreamLibrary method. 953 + type StreamLibraryStream: tonic::codegen::tokio_stream::Stream< 954 + Item = std::result::Result<super::StreamLibraryResponse, tonic::Status>, 955 + > + std::marker::Send 956 + + 'static; 957 + async fn stream_library( 958 + &self, 959 + request: tonic::Request<super::StreamLibraryRequest>, 960 + ) -> std::result::Result<tonic::Response<Self::StreamLibraryStream>, tonic::Status>; 927 961 async fn search( 928 962 &self, 929 963 request: tonic::Request<super::SearchRequest>, ··· 1517 1551 max_encoding_message_size, 1518 1552 ); 1519 1553 let res = grpc.unary(method, req).await; 1554 + Ok(res) 1555 + }; 1556 + Box::pin(fut) 1557 + } 1558 + "/rockbox.v1alpha1.LibraryService/StreamLibrary" => { 1559 + #[allow(non_camel_case_types)] 1560 + struct StreamLibrarySvc<T: LibraryService>(pub Arc<T>); 1561 + impl<T: LibraryService> 1562 + tonic::server::ServerStreamingService<super::StreamLibraryRequest> 1563 + for StreamLibrarySvc<T> 1564 + { 1565 + type Response = super::StreamLibraryResponse; 1566 + type ResponseStream = T::StreamLibraryStream; 1567 + type Future = 1568 + BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>; 1569 + fn call( 1570 + &mut self, 1571 + request: tonic::Request<super::StreamLibraryRequest>, 1572 + ) -> Self::Future { 1573 + let inner = Arc::clone(&self.0); 1574 + let fut = async move { 1575 + <T as LibraryService>::stream_library(&inner, request).await 1576 + }; 1577 + Box::pin(fut) 1578 + } 1579 + } 1580 + let accept_compression_encodings = self.accept_compression_encodings; 1581 + let send_compression_encodings = self.send_compression_encodings; 1582 + let max_decoding_message_size = self.max_decoding_message_size; 1583 + let max_encoding_message_size = self.max_encoding_message_size; 1584 + let inner = self.inner.clone(); 1585 + let fut = async move { 1586 + let method = StreamLibrarySvc(inner); 1587 + let codec = tonic::codec::ProstCodec::default(); 1588 + let mut grpc = tonic::server::Grpc::new(codec) 1589 + .apply_compression_config( 1590 + accept_compression_encodings, 1591 + send_compression_encodings, 1592 + ) 1593 + .apply_max_message_size_config( 1594 + max_decoding_message_size, 1595 + max_encoding_message_size, 1596 + ); 1597 + let res = grpc.server_streaming(method, req).await; 1520 1598 Ok(res) 1521 1599 }; 1522 1600 Box::pin(fut)
cli/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+3
crates/graphql/src/types.rs
··· 4 4 pub struct StatusCode { 5 5 pub code: i32, 6 6 } 7 + 8 + #[derive(Clone, Debug)] 9 + pub struct ScanCompleted;
+78
crates/rocksky/src/api/rockbox.v1alpha1.rs
··· 506 506 } 507 507 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 508 508 pub struct ScanLibraryResponse {} 509 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 510 + pub struct StreamLibraryRequest {} 511 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 512 + pub struct StreamLibraryResponse {} 509 513 #[derive(Clone, PartialEq, ::prost::Message)] 510 514 pub struct SearchRequest { 511 515 #[prost(string, tag = "1")] ··· 842 846 )); 843 847 self.inner.unary(req, path, codec).await 844 848 } 849 + pub async fn stream_library( 850 + &mut self, 851 + request: impl tonic::IntoRequest<super::StreamLibraryRequest>, 852 + ) -> std::result::Result< 853 + tonic::Response<tonic::codec::Streaming<super::StreamLibraryResponse>>, 854 + tonic::Status, 855 + > { 856 + self.inner.ready().await.map_err(|e| { 857 + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) 858 + })?; 859 + let codec = tonic::codec::ProstCodec::default(); 860 + let path = http::uri::PathAndQuery::from_static( 861 + "/rockbox.v1alpha1.LibraryService/StreamLibrary", 862 + ); 863 + let mut req = request.into_request(); 864 + req.extensions_mut().insert(GrpcMethod::new( 865 + "rockbox.v1alpha1.LibraryService", 866 + "StreamLibrary", 867 + )); 868 + self.inner.server_streaming(req, path, codec).await 869 + } 845 870 pub async fn search( 846 871 &mut self, 847 872 request: impl tonic::IntoRequest<super::SearchRequest>, ··· 924 949 &self, 925 950 request: tonic::Request<super::ScanLibraryRequest>, 926 951 ) -> std::result::Result<tonic::Response<super::ScanLibraryResponse>, tonic::Status>; 952 + /// Server streaming response type for the StreamLibrary method. 953 + type StreamLibraryStream: tonic::codegen::tokio_stream::Stream< 954 + Item = std::result::Result<super::StreamLibraryResponse, tonic::Status>, 955 + > + std::marker::Send 956 + + 'static; 957 + async fn stream_library( 958 + &self, 959 + request: tonic::Request<super::StreamLibraryRequest>, 960 + ) -> std::result::Result<tonic::Response<Self::StreamLibraryStream>, tonic::Status>; 927 961 async fn search( 928 962 &self, 929 963 request: tonic::Request<super::SearchRequest>, ··· 1517 1551 max_encoding_message_size, 1518 1552 ); 1519 1553 let res = grpc.unary(method, req).await; 1554 + Ok(res) 1555 + }; 1556 + Box::pin(fut) 1557 + } 1558 + "/rockbox.v1alpha1.LibraryService/StreamLibrary" => { 1559 + #[allow(non_camel_case_types)] 1560 + struct StreamLibrarySvc<T: LibraryService>(pub Arc<T>); 1561 + impl<T: LibraryService> 1562 + tonic::server::ServerStreamingService<super::StreamLibraryRequest> 1563 + for StreamLibrarySvc<T> 1564 + { 1565 + type Response = super::StreamLibraryResponse; 1566 + type ResponseStream = T::StreamLibraryStream; 1567 + type Future = 1568 + BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>; 1569 + fn call( 1570 + &mut self, 1571 + request: tonic::Request<super::StreamLibraryRequest>, 1572 + ) -> Self::Future { 1573 + let inner = Arc::clone(&self.0); 1574 + let fut = async move { 1575 + <T as LibraryService>::stream_library(&inner, request).await 1576 + }; 1577 + Box::pin(fut) 1578 + } 1579 + } 1580 + let accept_compression_encodings = self.accept_compression_encodings; 1581 + let send_compression_encodings = self.send_compression_encodings; 1582 + let max_decoding_message_size = self.max_decoding_message_size; 1583 + let max_encoding_message_size = self.max_encoding_message_size; 1584 + let inner = self.inner.clone(); 1585 + let fut = async move { 1586 + let method = StreamLibrarySvc(inner); 1587 + let codec = tonic::codec::ProstCodec::default(); 1588 + let mut grpc = tonic::server::Grpc::new(codec) 1589 + .apply_compression_config( 1590 + accept_compression_encodings, 1591 + send_compression_encodings, 1592 + ) 1593 + .apply_max_message_size_config( 1594 + max_decoding_message_size, 1595 + max_encoding_message_size, 1596 + ); 1597 + let res = grpc.server_streaming(method, req).await; 1520 1598 Ok(res) 1521 1599 }; 1522 1600 Box::pin(fut)
crates/rocksky/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+5
crates/rpc/proto/rockbox/v1alpha1/library.proto
··· 137 137 138 138 message ScanLibraryResponse {} 139 139 140 + message StreamLibraryRequest {} 141 + 142 + message StreamLibraryResponse {} 143 + 140 144 message SearchRequest { 141 145 string term = 1; 142 146 } ··· 161 165 rpc GetLikedTracks(GetLikedTracksRequest) returns (GetLikedTracksResponse); 162 166 rpc GetLikedAlbums(GetLikedAlbumsRequest) returns (GetLikedAlbumsResponse); 163 167 rpc ScanLibrary(ScanLibraryRequest) returns (ScanLibraryResponse); 168 + rpc StreamLibrary(StreamLibraryRequest) returns (stream StreamLibraryResponse); 164 169 rpc Search(SearchRequest) returns (SearchResponse); 165 170 }
+78
crates/rpc/src/api/rockbox.v1alpha1.rs
··· 1033 1033 } 1034 1034 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 1035 1035 pub struct ScanLibraryResponse {} 1036 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 1037 + pub struct StreamLibraryRequest {} 1038 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 1039 + pub struct StreamLibraryResponse {} 1036 1040 #[derive(Clone, PartialEq, ::prost::Message)] 1037 1041 pub struct SearchRequest { 1038 1042 #[prost(string, tag = "1")] ··· 1369 1373 )); 1370 1374 self.inner.unary(req, path, codec).await 1371 1375 } 1376 + pub async fn stream_library( 1377 + &mut self, 1378 + request: impl tonic::IntoRequest<super::StreamLibraryRequest>, 1379 + ) -> std::result::Result< 1380 + tonic::Response<tonic::codec::Streaming<super::StreamLibraryResponse>>, 1381 + tonic::Status, 1382 + > { 1383 + self.inner.ready().await.map_err(|e| { 1384 + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) 1385 + })?; 1386 + let codec = tonic::codec::ProstCodec::default(); 1387 + let path = http::uri::PathAndQuery::from_static( 1388 + "/rockbox.v1alpha1.LibraryService/StreamLibrary", 1389 + ); 1390 + let mut req = request.into_request(); 1391 + req.extensions_mut().insert(GrpcMethod::new( 1392 + "rockbox.v1alpha1.LibraryService", 1393 + "StreamLibrary", 1394 + )); 1395 + self.inner.server_streaming(req, path, codec).await 1396 + } 1372 1397 pub async fn search( 1373 1398 &mut self, 1374 1399 request: impl tonic::IntoRequest<super::SearchRequest>, ··· 1451 1476 &self, 1452 1477 request: tonic::Request<super::ScanLibraryRequest>, 1453 1478 ) -> std::result::Result<tonic::Response<super::ScanLibraryResponse>, tonic::Status>; 1479 + /// Server streaming response type for the StreamLibrary method. 1480 + type StreamLibraryStream: tonic::codegen::tokio_stream::Stream< 1481 + Item = std::result::Result<super::StreamLibraryResponse, tonic::Status>, 1482 + > + std::marker::Send 1483 + + 'static; 1484 + async fn stream_library( 1485 + &self, 1486 + request: tonic::Request<super::StreamLibraryRequest>, 1487 + ) -> std::result::Result<tonic::Response<Self::StreamLibraryStream>, tonic::Status>; 1454 1488 async fn search( 1455 1489 &self, 1456 1490 request: tonic::Request<super::SearchRequest>, ··· 2044 2078 max_encoding_message_size, 2045 2079 ); 2046 2080 let res = grpc.unary(method, req).await; 2081 + Ok(res) 2082 + }; 2083 + Box::pin(fut) 2084 + } 2085 + "/rockbox.v1alpha1.LibraryService/StreamLibrary" => { 2086 + #[allow(non_camel_case_types)] 2087 + struct StreamLibrarySvc<T: LibraryService>(pub Arc<T>); 2088 + impl<T: LibraryService> 2089 + tonic::server::ServerStreamingService<super::StreamLibraryRequest> 2090 + for StreamLibrarySvc<T> 2091 + { 2092 + type Response = super::StreamLibraryResponse; 2093 + type ResponseStream = T::StreamLibraryStream; 2094 + type Future = 2095 + BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>; 2096 + fn call( 2097 + &mut self, 2098 + request: tonic::Request<super::StreamLibraryRequest>, 2099 + ) -> Self::Future { 2100 + let inner = Arc::clone(&self.0); 2101 + let fut = async move { 2102 + <T as LibraryService>::stream_library(&inner, request).await 2103 + }; 2104 + Box::pin(fut) 2105 + } 2106 + } 2107 + let accept_compression_encodings = self.accept_compression_encodings; 2108 + let send_compression_encodings = self.send_compression_encodings; 2109 + let max_decoding_message_size = self.max_decoding_message_size; 2110 + let max_encoding_message_size = self.max_encoding_message_size; 2111 + let inner = self.inner.clone(); 2112 + let fut = async move { 2113 + let method = StreamLibrarySvc(inner); 2114 + let codec = tonic::codec::ProstCodec::default(); 2115 + let mut grpc = tonic::server::Grpc::new(codec) 2116 + .apply_compression_config( 2117 + accept_compression_encodings, 2118 + send_compression_encodings, 2119 + ) 2120 + .apply_max_message_size_config( 2121 + max_decoding_message_size, 2122 + max_encoding_message_size, 2123 + ); 2124 + let res = grpc.server_streaming(method, req).await; 2047 2125 Ok(res) 2048 2126 }; 2049 2127 Box::pin(fut)
crates/rpc/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+27 -1
crates/rpc/src/library.rs
··· 1 + use std::pin::Pin; 2 + 3 + use rockbox_graphql::{simplebroker::SimpleBroker, types::ScanCompleted}; 1 4 use rockbox_library::{entity::favourites::Favourites, repo}; 2 5 use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 3 6 use sqlx::Sqlite; 7 + use tokio_stream::{Stream, StreamExt}; 4 8 5 9 use crate::{ 6 10 api::rockbox::v1alpha1::{ ··· 10 14 GetLikedTracksRequest, GetLikedTracksResponse, GetTrackRequest, GetTrackResponse, 11 15 GetTracksRequest, GetTracksResponse, LikeAlbumRequest, LikeAlbumResponse, LikeTrackRequest, 12 16 LikeTrackResponse, ScanLibraryRequest, ScanLibraryResponse, SearchRequest, SearchResponse, 13 - UnlikeAlbumRequest, UnlikeAlbumResponse, UnlikeTrackRequest, UnlikeTrackResponse, 17 + StreamLibraryRequest, StreamLibraryResponse, UnlikeAlbumRequest, UnlikeAlbumResponse, 18 + UnlikeTrackRequest, UnlikeTrackResponse, 14 19 }, 15 20 rockbox_url, 16 21 }; ··· 289 294 albums, 290 295 artists, 291 296 })) 297 + } 298 + 299 + type StreamLibraryStream = Pin< 300 + Box< 301 + dyn Stream<Item = Result<StreamLibraryResponse, tonic::Status>> + Send + Sync + 'static, 302 + >, 303 + >; 304 + 305 + async fn stream_library( 306 + &self, 307 + _request: tonic::Request<StreamLibraryRequest>, 308 + ) -> Result<tonic::Response<Self::StreamLibraryStream>, tonic::Status> { 309 + let mut stream = SimpleBroker::<ScanCompleted>::subscribe(); 310 + let output = async_stream::try_stream! { 311 + while let Some(_) = stream.next().await { 312 + yield StreamLibraryResponse {}; 313 + } 314 + }; 315 + Ok(tonic::Response::new( 316 + Box::pin(output) as Self::StreamLibraryStream 317 + )) 292 318 } 293 319 }
+4
crates/server/src/handlers/system.rs
··· 2 2 3 3 use crate::http::{Context, Request, Response}; 4 4 use anyhow::Error; 5 + use rockbox_graphql::{simplebroker::SimpleBroker, types::ScanCompleted}; 5 6 use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files, repo}; 6 7 use rockbox_sys as rb; 7 8 use rockbox_typesense::client::*; ··· 43 44 }; 44 45 45 46 if path != music_library { 47 + SimpleBroker::publish(ScanCompleted); 46 48 res.text("0"); 47 49 return Ok(()); 48 50 } ··· 50 52 update_metadata(ctx.pool.clone()).await?; 51 53 52 54 if !rebuild_index { 55 + SimpleBroker::publish(ScanCompleted); 53 56 res.text("0"); 54 57 return Ok(()); 55 58 } ··· 66 69 insert_artists(artists.into_iter().map(Artist::from).collect()).await?; 67 70 insert_albums(albums.into_iter().map(Album::from).collect()).await?; 68 71 72 + SimpleBroker::publish(ScanCompleted); 69 73 res.text("0"); 70 74 Ok(()) 71 75 }
+89
gpui/src/api/rockbox.v1alpha1.rs
··· 529 529 } 530 530 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 531 531 pub struct ScanLibraryResponse {} 532 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 533 + pub struct StreamLibraryRequest {} 534 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 535 + pub struct StreamLibraryResponse {} 532 536 #[derive(Clone, PartialEq, ::prost::Message)] 533 537 pub struct SearchRequest { 534 538 #[prost(string, tag = "1")] ··· 958 962 ); 959 963 self.inner.unary(req, path, codec).await 960 964 } 965 + pub async fn stream_library( 966 + &mut self, 967 + request: impl tonic::IntoRequest<super::StreamLibraryRequest>, 968 + ) -> std::result::Result< 969 + tonic::Response<tonic::codec::Streaming<super::StreamLibraryResponse>>, 970 + tonic::Status, 971 + > { 972 + self.inner 973 + .ready() 974 + .await 975 + .map_err(|e| { 976 + tonic::Status::unknown( 977 + format!("Service was not ready: {}", e.into()), 978 + ) 979 + })?; 980 + let codec = tonic::codec::ProstCodec::default(); 981 + let path = http::uri::PathAndQuery::from_static( 982 + "/rockbox.v1alpha1.LibraryService/StreamLibrary", 983 + ); 984 + let mut req = request.into_request(); 985 + req.extensions_mut() 986 + .insert( 987 + GrpcMethod::new("rockbox.v1alpha1.LibraryService", "StreamLibrary"), 988 + ); 989 + self.inner.server_streaming(req, path, codec).await 990 + } 961 991 pub async fn search( 962 992 &mut self, 963 993 request: impl tonic::IntoRequest<super::SearchRequest>, ··· 1083 1113 request: tonic::Request<super::ScanLibraryRequest>, 1084 1114 ) -> std::result::Result< 1085 1115 tonic::Response<super::ScanLibraryResponse>, 1116 + tonic::Status, 1117 + >; 1118 + /// Server streaming response type for the StreamLibrary method. 1119 + type StreamLibraryStream: tonic::codegen::tokio_stream::Stream< 1120 + Item = std::result::Result<super::StreamLibraryResponse, tonic::Status>, 1121 + > 1122 + + std::marker::Send 1123 + + 'static; 1124 + async fn stream_library( 1125 + &self, 1126 + request: tonic::Request<super::StreamLibraryRequest>, 1127 + ) -> std::result::Result< 1128 + tonic::Response<Self::StreamLibraryStream>, 1086 1129 tonic::Status, 1087 1130 >; 1088 1131 async fn search( ··· 1749 1792 max_encoding_message_size, 1750 1793 ); 1751 1794 let res = grpc.unary(method, req).await; 1795 + Ok(res) 1796 + }; 1797 + Box::pin(fut) 1798 + } 1799 + "/rockbox.v1alpha1.LibraryService/StreamLibrary" => { 1800 + #[allow(non_camel_case_types)] 1801 + struct StreamLibrarySvc<T: LibraryService>(pub Arc<T>); 1802 + impl< 1803 + T: LibraryService, 1804 + > tonic::server::ServerStreamingService<super::StreamLibraryRequest> 1805 + for StreamLibrarySvc<T> { 1806 + type Response = super::StreamLibraryResponse; 1807 + type ResponseStream = T::StreamLibraryStream; 1808 + type Future = BoxFuture< 1809 + tonic::Response<Self::ResponseStream>, 1810 + tonic::Status, 1811 + >; 1812 + fn call( 1813 + &mut self, 1814 + request: tonic::Request<super::StreamLibraryRequest>, 1815 + ) -> Self::Future { 1816 + let inner = Arc::clone(&self.0); 1817 + let fut = async move { 1818 + <T as LibraryService>::stream_library(&inner, request).await 1819 + }; 1820 + Box::pin(fut) 1821 + } 1822 + } 1823 + let accept_compression_encodings = self.accept_compression_encodings; 1824 + let send_compression_encodings = self.send_compression_encodings; 1825 + let max_decoding_message_size = self.max_decoding_message_size; 1826 + let max_encoding_message_size = self.max_encoding_message_size; 1827 + let inner = self.inner.clone(); 1828 + let fut = async move { 1829 + let method = StreamLibrarySvc(inner); 1830 + let codec = tonic::codec::ProstCodec::default(); 1831 + let mut grpc = tonic::server::Grpc::new(codec) 1832 + .apply_compression_config( 1833 + accept_compression_encodings, 1834 + send_compression_encodings, 1835 + ) 1836 + .apply_max_message_size_config( 1837 + max_decoding_message_size, 1838 + max_encoding_message_size, 1839 + ); 1840 + let res = grpc.server_streaming(method, req).await; 1752 1841 Ok(res) 1753 1842 }; 1754 1843 Box::pin(fut)
gpui/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+39 -2
gpui/src/client.rs
··· 10 10 PlayDirectoryRequest, PlayTrackRequest, FastForwardRewindRequest, PlaylistResumeRequest, 11 11 PreviousRequest, RemoveTracksRequest, ResumeRequest, ResumeTrackRequest, 12 12 SaveSettingsRequest, SearchRequest, ShufflePlaylistRequest, StartRequest, 13 - StreamCurrentTrackRequest, StreamPlaylistRequest, StreamStatusRequest, TreeGetEntriesRequest, 14 - UnlikeTrackRequest, 13 + StreamCurrentTrackRequest, StreamLibraryRequest, StreamPlaylistRequest, StreamStatusRequest, 14 + TreeGetEntriesRequest, UnlikeTrackRequest, 15 15 }; 16 16 use crate::state::{SearchAlbum, SearchArtist, SearchResults}; 17 17 ··· 52 52 genre: t.genre, 53 53 duration: t.length as u64 / 1000, 54 54 track_number: t.track_number, 55 + disc_number: t.disc_number, 55 56 year: t.year, 56 57 album_art: t.album_art.filter(|s| !s.is_empty()), 57 58 } ··· 238 239 genre: t.genre, 239 240 duration: t.length / 1000, 240 241 track_number: t.tracknum as u32, 242 + disc_number: 0, 241 243 year: t.year as u32, 242 244 album_art: t.album_art.filter(|s| !s.is_empty()), 243 245 }) ··· 378 380 } 379 381 } 380 382 383 + pub async fn run_library_stream(tx: Sender<StateUpdate>) { 384 + loop { 385 + if let Err(e) = library_stream_inner(&tx).await { 386 + log::warn!("library stream: {e}"); 387 + } 388 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 389 + } 390 + } 391 + 392 + async fn library_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 393 + let mut c = LibraryServiceClient::connect(URL).await?; 394 + let resp = c.stream_library(StreamLibraryRequest {}).await?; 395 + let mut stream = resp.into_inner(); 396 + loop { 397 + match stream.message().await { 398 + Ok(Some(_)) => { 399 + if let Ok(mut tracks) = fetch_tracks().await { 400 + tracks.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); 401 + let _ = tx.send(StateUpdate::Tracks(tracks)).await; 402 + } 403 + if let Ok(ids) = fetch_liked_tracks().await { 404 + let _ = tx.send(StateUpdate::LikedTracks(ids)).await; 405 + } 406 + } 407 + Ok(None) => break, 408 + Err(e) => { 409 + log::warn!("library stream message: {e}"); 410 + break; 411 + } 412 + } 413 + } 414 + Ok(()) 415 + } 416 + 381 417 pub async fn run_liked_tracks_sync(tx: Sender<StateUpdate>) { 382 418 match fetch_liked_tracks().await { 383 419 Ok(ids) => { ··· 523 559 genre: t.genre, 524 560 duration: t.length / 1000, 525 561 track_number: t.tracknum as u32, 562 + disc_number: 0, 526 563 year: t.year as u32, 527 564 album_art: t.album_art.filter(|s| !s.is_empty()), 528 565 })
+1
gpui/src/controller.rs
··· 29 29 30 30 // Spawn tokio background tasks — these are Send because Sender<StateUpdate> is Send 31 31 rt.spawn(crate::client::run_library_sync(tx.clone())); 32 + rt.spawn(crate::client::run_library_stream(tx.clone())); 32 33 rt.spawn(crate::client::run_liked_tracks_sync(tx.clone())); 33 34 rt.spawn(crate::client::run_artist_images_sync(tx.clone())); 34 35 rt.spawn(crate::client::run_settings_sync(tx.clone()));
+1
gpui/src/state.rs
··· 11 11 pub genre: String, 12 12 pub duration: u64, 13 13 pub track_number: u32, 14 + pub disc_number: u32, 14 15 pub year: u32, 15 16 pub album_art: Option<String>, 16 17 }
+1014 -843
gpui/src/ui/components/pages/library.rs
··· 7 7 use crate::ui::animations::equalizer_bars; 8 8 use crate::ui::components::icons::{Icon, Icons}; 9 9 use crate::ui::components::miniplayer::MiniPlayer; 10 - use crate::ui::components::search_input::SearchInput; 11 10 use crate::ui::components::pages::files::{menu_item, FilesView}; 11 + use crate::ui::components::search_input::SearchInput; 12 12 use crate::ui::components::{ 13 - AlbumContextMenu, AlbumContextMenuState, BackSection, FileContextMenuState, 14 - HoveredAlbumIdx, LibraryContextMenu, LibraryContextMenuState, LibrarySection, LikedOrder, 15 - LikedSongs, SelectedAlbum, SelectedArtist, 13 + AlbumContextMenu, AlbumContextMenuState, BackSection, FileContextMenuState, HoveredAlbumIdx, 14 + LibraryContextMenu, LibraryContextMenuState, LibrarySection, LikedOrder, LikedSongs, 15 + SelectedAlbum, SelectedArtist, 16 16 }; 17 17 use crate::ui::theme::Theme; 18 18 use gpui::prelude::FluentBuilder; ··· 175 175 176 176 let mut album_map: std::collections::BTreeMap< 177 177 String, 178 - (String, u32, usize, Option<String>, String, Vec<(u32, String)>), 178 + ( 179 + String, 180 + u32, 181 + usize, 182 + Option<String>, 183 + String, 184 + Vec<(u32, String)>, 185 + ), 179 186 > = Default::default(); 180 187 for track in &state.tracks { 181 188 let display_artist = if track.album_artist.is_empty() { ··· 194 201 e.2 += 1; 195 202 e.5.push((track.track_number, track.path.clone())); 196 203 } 197 - let albums: Vec<(String, String, u32, usize, Option<String>, String, Vec<String>)> = 198 - album_map 199 - .into_iter() 200 - .map(|(name, (artist, year, count, art, album_id, mut numbered_paths))| { 204 + let albums: Vec<( 205 + String, 206 + String, 207 + u32, 208 + usize, 209 + Option<String>, 210 + String, 211 + Vec<String>, 212 + )> = album_map 213 + .into_iter() 214 + .map( 215 + |(name, (artist, year, count, art, album_id, mut numbered_paths))| { 201 216 numbered_paths.sort_by_key(|(num, _)| *num); 202 217 let paths: Vec<String> = 203 218 numbered_paths.into_iter().map(|(_, p)| p).collect(); 204 219 (name, artist, year, count, art, album_id, paths) 205 - }) 206 - .collect(); 220 + }, 221 + ) 222 + .collect(); 207 223 208 224 let mut artist_map: std::collections::BTreeMap<String, usize> = Default::default(); 209 225 for track in &state.tracks { ··· 218 234 }) 219 235 .collect(); 220 236 221 - // Album detail: tracks filtered by selected album — (global_idx, path, title, num, dur, id, album_art) 222 - let mut album_tracks: Vec<(usize, String, String, String, u64, String, Option<String>)> = state 237 + // Album detail: tracks filtered by selected album — (global_idx, path, title, num, dur, id, album_art, disc_number) 238 + let mut album_tracks: Vec<( 239 + usize, 240 + String, 241 + String, 242 + String, 243 + u64, 244 + String, 245 + Option<String>, 246 + u32, 247 + )> = state 223 248 .tracks 224 249 .iter() 225 250 .enumerate() ··· 233 258 t.duration, 234 259 t.id.clone(), 235 260 t.album_art.clone(), 261 + t.disc_number, 236 262 ) 237 263 }) 238 264 .collect(); 239 - album_tracks.sort_by_key(|(_, _, _, num, _, _, _)| num.parse::<u32>().unwrap_or(0)); 265 + album_tracks.sort_by_key(|(_, _, _, num, _, _, _, disc)| { 266 + (*disc, num.parse::<u32>().unwrap_or(0)) 267 + }); 240 268 241 269 let album_first_track = state.tracks.iter().find(|t| t.album == selected_album); 242 270 let album_artist = album_first_track ··· 261 289 .unwrap_or_default(); 262 290 263 291 // Artist detail: tracks filtered by selected artist — (global_idx, path, title, album, dur, id, album_art) 264 - let artist_tracks: Vec<(usize, String, String, String, u64, String, Option<String>)> = state 265 - .tracks 266 - .iter() 267 - .enumerate() 268 - .filter(|(_, t)| t.artist == selected_artist) 269 - .map(|(idx, t)| { 270 - ( 271 - idx, 272 - t.path.clone(), 273 - t.title.clone(), 274 - t.album.clone(), 275 - t.duration, 276 - t.id.clone(), 277 - t.album_art.clone(), 278 - ) 279 - }) 280 - .collect(); 292 + let artist_tracks: Vec<(usize, String, String, String, u64, String, Option<String>)> = 293 + state 294 + .tracks 295 + .iter() 296 + .enumerate() 297 + .filter(|(_, t)| t.artist == selected_artist) 298 + .map(|(idx, t)| { 299 + ( 300 + idx, 301 + t.path.clone(), 302 + t.title.clone(), 303 + t.album.clone(), 304 + t.duration, 305 + t.id.clone(), 306 + t.album_art.clone(), 307 + ) 308 + }) 309 + .collect(); 281 310 282 311 // (album_name, count, album_art) 283 312 let mut artist_album_map: std::collections::BTreeMap<String, (usize, Option<String>)> = ··· 303 332 .enumerate() 304 333 .map(|(i, id)| (id.as_str(), i)) 305 334 .collect(); 306 - let mut liked_tracks: Vec<(usize, String, String, String, String, u64, String, Option<String>)> = state 335 + let mut liked_tracks: Vec<( 336 + usize, 337 + String, 338 + String, 339 + String, 340 + String, 341 + u64, 342 + String, 343 + Option<String>, 344 + )> = state 307 345 .tracks 308 346 .iter() 309 347 .enumerate() ··· 322 360 }) 323 361 .collect(); 324 362 liked_tracks.sort_by_key(|(_, _, _, _, _, _, id, _)| { 325 - liked_order_map.get(id.as_str()).copied().unwrap_or(usize::MAX) 363 + liked_order_map 364 + .get(id.as_str()) 365 + .copied() 366 + .unwrap_or(usize::MAX) 326 367 }); 327 368 328 369 let current_path = state.current_track().map(|t| t.path.clone()); ··· 369 410 let _detail_scroll_handle = self.detail_scroll_handle.clone(); 370 411 371 412 // Sidebar nav item — Albums/Artists stay active while in their detail view 372 - let make_nav_item = move |icon: Icons, icon_size: u8, label: &'static str, target: LibrarySection| { 373 - let is_active = section == target 374 - || (section == LibrarySection::AlbumDetail && target == LibrarySection::Albums) 375 - || (section == LibrarySection::ArtistDetail && target == LibrarySection::Artists) 376 - || (section == LibrarySection::AlbumDetail 377 - && back_section == LibrarySection::ArtistDetail 378 - && target == LibrarySection::Artists); 379 - div() 380 - .id(label) 381 - .w_full() 382 - .px_4() 383 - .py_2p5() 384 - .cursor_pointer() 385 - .text_sm() 386 - .font_weight(if is_active { 387 - FontWeight(600.0) 388 - } else { 389 - FontWeight(400.0) 390 - }) 391 - .text_color(if is_active { 392 - gpui::rgb(0xFFFFFF) 393 - } else { 394 - theme.library_header_text 395 - }) 396 - .when(is_active, |this| { 397 - this.border_l_2().border_color(theme.switcher_active) 398 - }) 399 - .hover(|this| this.text_color(theme.library_text)) 400 - .on_click(move |_, _, cx: &mut App| { 401 - *cx.global_mut::<LibrarySection>() = target; 402 - }) 403 - .child( 404 - div() 405 - .flex() 406 - .items_center() 407 - .gap_x_2() 408 - .child(icon_sized(icon, icon_size)) 409 - .child(label), 410 - ) 411 - }; 413 + let make_nav_item = 414 + move |icon: Icons, icon_size: u8, label: &'static str, target: LibrarySection| { 415 + let is_active = section == target 416 + || (section == LibrarySection::AlbumDetail && target == LibrarySection::Albums) 417 + || (section == LibrarySection::ArtistDetail 418 + && target == LibrarySection::Artists) 419 + || (section == LibrarySection::AlbumDetail 420 + && back_section == LibrarySection::ArtistDetail 421 + && target == LibrarySection::Artists); 422 + div() 423 + .id(label) 424 + .w_full() 425 + .px_4() 426 + .py_2p5() 427 + .cursor_pointer() 428 + .text_sm() 429 + .font_weight(if is_active { 430 + FontWeight(600.0) 431 + } else { 432 + FontWeight(400.0) 433 + }) 434 + .text_color(if is_active { 435 + gpui::rgb(0xFFFFFF) 436 + } else { 437 + theme.library_header_text 438 + }) 439 + .when(is_active, |this| { 440 + this.border_l_2().border_color(theme.switcher_active) 441 + }) 442 + .hover(|this| this.text_color(theme.library_text)) 443 + .on_click(move |_, _, cx: &mut App| { 444 + *cx.global_mut::<LibrarySection>() = target; 445 + }) 446 + .child( 447 + div() 448 + .flex() 449 + .items_center() 450 + .gap_x_2() 451 + .child(icon_sized(icon, icon_size)) 452 + .child(label), 453 + ) 454 + }; 412 455 413 456 // ── track row helper (shared between Songs, AlbumDetail, ArtistDetail, Likes) ────── 414 457 let track_row = move |row_id: (&'static str, usize), ··· 681 724 .text_color(theme.library_text) 682 725 .child("Artists"), 683 726 ) 684 - .child( 685 - div() 686 - .flex() 687 - .items_start() 688 - .gap_x_4() 689 - .children(r.artists.iter().take(8).enumerate().map( 690 - |(idx, artist)| { 691 - let name_clone = artist.name.clone(); 692 - let img_url = artist 693 - .image 694 - .as_deref() 695 - .filter(|s| !s.is_empty()) 696 - .map(|s| { 697 - if s.starts_with("http") { 698 - s.to_string() 699 - } else { 700 - format!("{COVERS_BASE}{s}") 701 - } 702 - }); 703 - div() 704 - .id(("sa", idx)) 705 - .w(px(112.0)) 706 - .flex_shrink_0() 707 - .flex() 708 - .flex_col() 709 - .items_center() 710 - .gap_y_2() 711 - .cursor_pointer() 712 - .on_click(move |_, _, cx: &mut App| { 713 - *cx.global_mut::<SelectedArtist>() = 714 - SelectedArtist(name_clone.clone()); 715 - *cx.global_mut::<LibrarySection>() = 716 - LibrarySection::ArtistDetail; 717 - }) 718 - .child({ 719 - let mut c = div() 720 - .w(px(88.0)) 721 - .rounded_full() 722 - .overflow_hidden() 723 - .flex_shrink_0(); 724 - c.style().aspect_ratio = Some(1.0_f32); 725 - if let Some(url) = img_url { 726 - c.child( 727 - img(url) 728 - .w_full() 729 - .h_full() 730 - .rounded_full() 731 - .object_fit(ObjectFit::Cover), 727 + .child(div().flex().items_start().gap_x_4().children( 728 + r.artists.iter().take(8).enumerate().map( 729 + |(idx, artist)| { 730 + let name_clone = artist.name.clone(); 731 + let img_url = artist 732 + .image 733 + .as_deref() 734 + .filter(|s| !s.is_empty()) 735 + .map(|s| { 736 + if s.starts_with("http") { 737 + s.to_string() 738 + } else { 739 + format!("{COVERS_BASE}{s}") 740 + } 741 + }); 742 + div() 743 + .id(("sa", idx)) 744 + .w(px(112.0)) 745 + .flex_shrink_0() 746 + .flex() 747 + .flex_col() 748 + .items_center() 749 + .gap_y_2() 750 + .cursor_pointer() 751 + .on_click(move |_, _, cx: &mut App| { 752 + *cx.global_mut::<SelectedArtist>( 753 + ) = SelectedArtist( 754 + name_clone.clone(), 755 + ); 756 + *cx.global_mut::<LibrarySection>( 757 + ) = LibrarySection::ArtistDetail; 758 + }) 759 + .child({ 760 + let mut c = div() 761 + .w(px(88.0)) 762 + .rounded_full() 763 + .overflow_hidden() 764 + .flex_shrink_0(); 765 + c.style().aspect_ratio = 766 + Some(1.0_f32); 767 + if let Some(url) = img_url { 768 + c.child( 769 + img(url) 770 + .w_full() 771 + .h_full() 772 + .rounded_full() 773 + .object_fit( 774 + ObjectFit::Cover, 775 + ), 776 + ) 777 + } else { 778 + c.bg(theme.library_art_bg) 779 + .flex() 780 + .items_center() 781 + .justify_center() 782 + .text_color( 783 + theme.player_icons_text, 732 784 ) 733 - } else { 734 - c.bg(theme.library_art_bg) 735 - .flex() 736 - .items_center() 737 - .justify_center() 738 - .text_color(theme.player_icons_text) 739 - .child(Icon::new(Icons::Artist).size_8()) 740 - } 741 - }) 742 - .child( 743 - div() 744 - .w_full() 745 - .text_xs() 746 - .font_weight(FontWeight(500.0)) 747 - .text_color(theme.library_text) 748 - .text_center() 749 - .truncate() 750 - .child(artist.name.clone()), 751 - ) 752 - }, 753 - )), 754 - ), 785 + .child( 786 + Icon::new( 787 + Icons::Artist, 788 + ) 789 + .size_8(), 790 + ) 791 + } 792 + }) 793 + .child( 794 + div() 795 + .w_full() 796 + .text_xs() 797 + .font_weight(FontWeight(500.0)) 798 + .text_color(theme.library_text) 799 + .text_center() 800 + .truncate() 801 + .child(artist.name.clone()), 802 + ) 803 + }, 804 + ), 805 + )), 755 806 ) 756 807 }) 757 808 // ── Albums ──────────────────────────────────────── ··· 768 819 .text_color(theme.library_text) 769 820 .child("Albums"), 770 821 ) 771 - .child( 772 - div() 773 - .flex() 774 - .items_start() 775 - .gap_x_4() 776 - .children(r.albums.iter().take(8).enumerate().map( 777 - |(idx, album)| { 778 - let title_clone = album.title.clone(); 779 - let art_url = album 780 - .album_art 781 - .as_deref() 782 - .filter(|s| !s.is_empty()) 783 - .map(|id| { 784 - format!("{COVERS_BASE}{id}") 785 - }); 786 - div() 787 - .id(("sab", idx)) 788 - .w(px(130.0)) 789 - .flex_shrink_0() 790 - .flex() 791 - .flex_col() 792 - .gap_y_1() 793 - .cursor_pointer() 794 - .on_click(move |_, _, cx: &mut App| { 795 - *cx.global_mut::<SelectedAlbum>() = 796 - SelectedAlbum(title_clone.clone()); 797 - *cx.global_mut::<BackSection>() = 798 - BackSection(LibrarySection::Albums); 799 - *cx.global_mut::<LibrarySection>() = 800 - LibrarySection::AlbumDetail; 801 - }) 802 - .child({ 803 - let mut c = div() 804 - .w_full() 805 - .rounded_lg() 806 - .overflow_hidden(); 807 - c.style().aspect_ratio = Some(1.0_f32); 808 - if let Some(url) = art_url { 809 - c.child( 810 - img(url) 811 - .w_full() 812 - .h_full() 813 - .object_fit(ObjectFit::Cover), 822 + .child(div().flex().items_start().gap_x_4().children( 823 + r.albums.iter().take(8).enumerate().map( 824 + |(idx, album)| { 825 + let title_clone = album.title.clone(); 826 + let art_url = album 827 + .album_art 828 + .as_deref() 829 + .filter(|s| !s.is_empty()) 830 + .map(|id| format!("{COVERS_BASE}{id}")); 831 + div() 832 + .id(("sab", idx)) 833 + .w(px(130.0)) 834 + .flex_shrink_0() 835 + .flex() 836 + .flex_col() 837 + .gap_y_1() 838 + .cursor_pointer() 839 + .on_click(move |_, _, cx: &mut App| { 840 + *cx.global_mut::<SelectedAlbum>() = 841 + SelectedAlbum( 842 + title_clone.clone(), 843 + ); 844 + *cx.global_mut::<BackSection>() = 845 + BackSection( 846 + LibrarySection::Albums, 847 + ); 848 + *cx.global_mut::<LibrarySection>( 849 + ) = LibrarySection::AlbumDetail; 850 + }) 851 + .child({ 852 + let mut c = div() 853 + .w_full() 854 + .rounded_lg() 855 + .overflow_hidden(); 856 + c.style().aspect_ratio = 857 + Some(1.0_f32); 858 + if let Some(url) = art_url { 859 + c.child( 860 + img(url) 861 + .w_full() 862 + .h_full() 863 + .object_fit( 864 + ObjectFit::Cover, 865 + ), 866 + ) 867 + } else { 868 + c.bg(theme.library_art_bg) 869 + .flex() 870 + .items_center() 871 + .justify_center() 872 + .text_color( 873 + theme.player_icons_text, 814 874 ) 815 - } else { 816 - c.bg(theme.library_art_bg) 817 - .flex() 818 - .items_center() 819 - .justify_center() 820 - .text_color(theme.player_icons_text) 821 - .child(Icon::new(Icons::Music).size_8()) 822 - } 823 - }) 824 - .child( 825 - div() 826 - .text_xs() 827 - .font_weight(FontWeight(500.0)) 828 - .text_color(theme.library_text) 829 - .truncate() 830 - .child(album.title.clone()), 831 - ) 832 - .child( 833 - div() 834 - .text_xs() 835 - .text_color(theme.library_header_text) 836 - .truncate() 837 - .child(album.artist.clone()), 838 - ) 839 - }, 840 - )), 841 - ), 875 + .child( 876 + Icon::new(Icons::Music) 877 + .size_8(), 878 + ) 879 + } 880 + }) 881 + .child( 882 + div() 883 + .text_xs() 884 + .font_weight(FontWeight(500.0)) 885 + .text_color(theme.library_text) 886 + .truncate() 887 + .child(album.title.clone()), 888 + ) 889 + .child( 890 + div() 891 + .text_xs() 892 + .text_color( 893 + theme.library_header_text, 894 + ) 895 + .truncate() 896 + .child(album.artist.clone()), 897 + ) 898 + }, 899 + ), 900 + )), 842 901 ) 843 902 }) 844 903 // ── Songs ───────────────────────────────────────── 845 904 .when(!r.tracks.is_empty(), |this| { 846 - let rows = r.tracks.iter().take(20).enumerate().map(|(i, track)| { 847 - let is_current = 848 - current_path.as_deref() == Some(track.path.as_str()); 849 - let is_liked = liked_songs.contains(&track.id); 850 - track_row( 851 - ("search_track", i), 852 - track.path.clone(), 853 - (i + 1).to_string(), 854 - track.title.clone(), 855 - Some(track.artist.clone()), 856 - Some(track.album.clone()), 857 - track.duration, 858 - is_current, 859 - is_liked, 860 - track.artist.clone(), 861 - track.album.clone(), 862 - track.id.clone(), 863 - track.album_art.clone(), 864 - ) 865 - }); 905 + let rows = 906 + r.tracks.iter().take(20).enumerate().map(|(i, track)| { 907 + let is_current = current_path.as_deref() 908 + == Some(track.path.as_str()); 909 + let is_liked = liked_songs.contains(&track.id); 910 + track_row( 911 + ("search_track", i), 912 + track.path.clone(), 913 + (i + 1).to_string(), 914 + track.title.clone(), 915 + Some(track.artist.clone()), 916 + Some(track.album.clone()), 917 + track.duration, 918 + is_current, 919 + is_liked, 920 + track.artist.clone(), 921 + track.album.clone(), 922 + track.id.clone(), 923 + track.album_art.clone(), 924 + ) 925 + }); 866 926 this.child( 867 927 div() 868 928 .flex() ··· 883 943 } 884 944 } 885 945 } else { 886 - let content_inner = match section { 887 - // ── Songs ───────────────────────────────────────────────────────────── 888 - LibrarySection::Songs => div() 889 - .flex_1() 890 - .min_h_0() 891 - .flex() 892 - .flex_col() 893 - .child( 894 - div() 895 - .w_full() 896 - .flex_shrink_0() 897 - .flex() 898 - .items_center() 899 - .gap_x_4() 900 - .px_6() 901 - .py_4() 902 - .border_b_1() 903 - .border_color(theme.library_table_border) 904 - .child( 905 - div() 906 - .w(px(28.0)) 907 - .flex_shrink_0() 908 - .text_xs() 909 - .font_weight(FontWeight::MEDIUM) 910 - .text_color(theme.library_header_text) 911 - .child("#"), 912 - ) 913 - .child( 914 - div() 915 - .flex_1() 916 - .min_w_0() 917 - .text_xs() 918 - .font_weight(FontWeight::MEDIUM) 919 - .text_color(theme.library_header_text) 920 - .child("TITLE"), 921 - ) 922 - .child( 923 - div() 924 - .w_40() 925 - .flex_shrink_0() 926 - .text_xs() 927 - .font_weight(FontWeight::MEDIUM) 928 - .text_color(theme.library_header_text) 929 - .child("ARTIST"), 930 - ) 931 - .child( 932 - div() 933 - .w_40() 934 - .flex_shrink_0() 935 - .text_xs() 936 - .font_weight(FontWeight::MEDIUM) 937 - .text_color(theme.library_header_text) 938 - .child("ALBUM"), 939 - ) 940 - .child( 941 - div() 942 - .w(px(56.0)) 943 - .flex_shrink_0() 944 - .text_xs() 945 - .font_weight(FontWeight::MEDIUM) 946 - .text_color(theme.library_header_text) 947 - .child("TIME"), 948 - ) 949 - .child(div().w(px(28.0)).flex_shrink_0()) 950 - .child(div().w(px(28.0)).flex_shrink_0()), 951 - ) 952 - .child( 953 - uniform_list("library_tracks", n_songs, move |range, _window, cx| { 954 - let state = cx.global::<Controller>().state.read(cx); 955 - let current_idx = state.current_library_idx(); 956 - let liked = cx.global::<LikedSongs>().0.clone(); 957 - range 958 - .map(|idx| { 959 - let track = &state.tracks[idx]; 960 - let is_current = current_idx == Some(idx); 961 - let is_liked = liked.contains(&track.id); 962 - track_row( 963 - ("track_row", idx), 964 - track.path.clone(), 965 - (idx + 1).to_string(), 966 - track.title.clone(), 967 - Some(track.artist.clone()), 968 - Some(track.album.clone()), 969 - track.duration, 970 - is_current, 971 - is_liked, 972 - track.artist.clone(), 973 - track.album.clone(), 974 - track.id.clone(), 975 - track.album_art.clone(), 976 - ) 977 - }) 978 - .collect() 979 - }) 946 + let content_inner = match section { 947 + // ── Songs ───────────────────────────────────────────────────────────── 948 + LibrarySection::Songs => div() 980 949 .flex_1() 981 - .w_full() 982 - .track_scroll(scroll_handle), 983 - ) 984 - .into_any_element(), 985 - 986 - // ── Albums grid ─────────────────────────────────────────────────────── 987 - LibrarySection::Albums => div() 988 - .id("albums_scroll") 989 - .flex_1() 990 - .min_h_0() 991 - .overflow_y_scroll() 992 - .child( 993 - div() 950 + .min_h_0() 951 + .flex() 952 + .flex_col() 953 + .child( 954 + div() 955 + .w_full() 956 + .flex_shrink_0() 957 + .flex() 958 + .items_center() 959 + .gap_x_4() 960 + .px_6() 961 + .py_4() 962 + .border_b_1() 963 + .border_color(theme.library_table_border) 964 + .child( 965 + div() 966 + .w(px(28.0)) 967 + .flex_shrink_0() 968 + .text_xs() 969 + .font_weight(FontWeight::MEDIUM) 970 + .text_color(theme.library_header_text) 971 + .child("#"), 972 + ) 973 + .child( 974 + div() 975 + .flex_1() 976 + .min_w_0() 977 + .text_xs() 978 + .font_weight(FontWeight::MEDIUM) 979 + .text_color(theme.library_header_text) 980 + .child("TITLE"), 981 + ) 982 + .child( 983 + div() 984 + .w_40() 985 + .flex_shrink_0() 986 + .text_xs() 987 + .font_weight(FontWeight::MEDIUM) 988 + .text_color(theme.library_header_text) 989 + .child("ARTIST"), 990 + ) 991 + .child( 992 + div() 993 + .w_40() 994 + .flex_shrink_0() 995 + .text_xs() 996 + .font_weight(FontWeight::MEDIUM) 997 + .text_color(theme.library_header_text) 998 + .child("ALBUM"), 999 + ) 1000 + .child( 1001 + div() 1002 + .w(px(56.0)) 1003 + .flex_shrink_0() 1004 + .text_xs() 1005 + .font_weight(FontWeight::MEDIUM) 1006 + .text_color(theme.library_header_text) 1007 + .child("TIME"), 1008 + ) 1009 + .child(div().w(px(28.0)).flex_shrink_0()) 1010 + .child(div().w(px(28.0)).flex_shrink_0()), 1011 + ) 1012 + .child( 1013 + uniform_list("library_tracks", n_songs, move |range, _window, cx| { 1014 + let state = cx.global::<Controller>().state.read(cx); 1015 + let current_idx = state.current_library_idx(); 1016 + let liked = cx.global::<LikedSongs>().0.clone(); 1017 + range 1018 + .map(|idx| { 1019 + let track = &state.tracks[idx]; 1020 + let is_current = current_idx == Some(idx); 1021 + let is_liked = liked.contains(&track.id); 1022 + track_row( 1023 + ("track_row", idx), 1024 + track.path.clone(), 1025 + (idx + 1).to_string(), 1026 + track.title.clone(), 1027 + Some(track.artist.clone()), 1028 + Some(track.album.clone()), 1029 + track.duration, 1030 + is_current, 1031 + is_liked, 1032 + track.artist.clone(), 1033 + track.album.clone(), 1034 + track.id.clone(), 1035 + track.album_art.clone(), 1036 + ) 1037 + }) 1038 + .collect() 1039 + }) 1040 + .flex_1() 994 1041 .w_full() 995 - .p_6() 996 - .grid() 997 - .grid_cols(album_cols) 998 - .gap_6() 999 - .children(albums.into_iter().enumerate().map( 1000 - |(idx, (name, artist, year, _count, album_art, album_id, track_paths))| { 1001 - let name_clone = name.clone(); 1002 - let name_clone_opts = name.clone(); 1003 - let art_url = album_art 1004 - .filter(|s| !s.is_empty()) 1005 - .map(|id| format!("{COVERS_BASE}{id}")); 1006 - let art_url_opts = art_url.clone(); 1007 - let album_id_play = album_id.clone(); 1008 - let album_id_opts = album_id.clone(); 1009 - let artist_for_opts = artist.clone(); 1010 - let paths_for_opts = track_paths.clone(); 1011 - let is_hovered = hovered_album_idx == Some(idx); 1012 - let mut art_container = div() 1013 - .id(("album_art_hover", idx)) 1014 - .w_full() 1015 - .rounded_lg() 1016 - .overflow_hidden() 1017 - .relative() 1018 - .on_hover(move |hovered, _window, cx: &mut App| { 1019 - cx.set_global(HoveredAlbumIdx( 1020 - if *hovered { Some(idx) } else { None }, 1021 - )); 1022 - }); 1023 - art_container.style().aspect_ratio = Some(1.0_f32); 1024 - let art_content: AnyElement = if let Some(url) = art_url { 1025 - img(url) 1026 - .w_full() 1027 - .h_full() 1028 - .object_fit(ObjectFit::Cover) 1029 - .into_any_element() 1030 - } else { 1031 - div() 1042 + .track_scroll(scroll_handle), 1043 + ) 1044 + .into_any_element(), 1045 + 1046 + // ── Albums grid ─────────────────────────────────────────────────────── 1047 + LibrarySection::Albums => div() 1048 + .id("albums_scroll") 1049 + .flex_1() 1050 + .min_h_0() 1051 + .overflow_y_scroll() 1052 + .child( 1053 + div() 1054 + .w_full() 1055 + .p_6() 1056 + .grid() 1057 + .grid_cols(album_cols) 1058 + .gap_6() 1059 + .children(albums.into_iter().enumerate().map( 1060 + |( 1061 + idx, 1062 + (name, artist, year, _count, album_art, album_id, track_paths), 1063 + )| { 1064 + let name_clone = name.clone(); 1065 + let name_clone_opts = name.clone(); 1066 + let art_url = album_art 1067 + .filter(|s| !s.is_empty()) 1068 + .map(|id| format!("{COVERS_BASE}{id}")); 1069 + let art_url_opts = art_url.clone(); 1070 + let album_id_play = album_id.clone(); 1071 + let album_id_opts = album_id.clone(); 1072 + let artist_for_opts = artist.clone(); 1073 + let paths_for_opts = track_paths.clone(); 1074 + let is_hovered = hovered_album_idx == Some(idx); 1075 + let mut art_container = div() 1076 + .id(("album_art_hover", idx)) 1032 1077 .w_full() 1033 - .h_full() 1034 - .bg(theme.library_art_bg) 1035 - .flex() 1036 - .items_center() 1037 - .justify_center() 1038 - .text_color(theme.player_icons_text) 1039 - .child(Icon::new(Icons::Music).size_8()) 1040 - .into_any_element() 1041 - }; 1042 - let art_overlay = div() 1043 - .absolute() 1044 - .bottom_0() 1045 - .left_0() 1046 - .right_0() 1047 - .pb_3() 1048 - .flex() 1049 - .items_center() 1050 - .opacity(if is_hovered { 1.0 } else { 0.0 }) 1051 - .child( 1052 - // Left half — Play button centered within it 1078 + .rounded_lg() 1079 + .overflow_hidden() 1080 + .relative() 1081 + .on_hover(move |hovered, _window, cx: &mut App| { 1082 + cx.set_global(HoveredAlbumIdx(if *hovered { 1083 + Some(idx) 1084 + } else { 1085 + None 1086 + })); 1087 + }); 1088 + art_container.style().aspect_ratio = Some(1.0_f32); 1089 + let art_content: AnyElement = if let Some(url) = art_url { 1090 + img(url) 1091 + .w_full() 1092 + .h_full() 1093 + .object_fit(ObjectFit::Cover) 1094 + .into_any_element() 1095 + } else { 1053 1096 div() 1054 - .flex_1() 1097 + .w_full() 1098 + .h_full() 1099 + .bg(theme.library_art_bg) 1055 1100 .flex() 1101 + .items_center() 1056 1102 .justify_center() 1057 - .child( 1103 + .text_color(theme.player_icons_text) 1104 + .child(Icon::new(Icons::Music).size_8()) 1105 + .into_any_element() 1106 + }; 1107 + let art_overlay = div() 1108 + .absolute() 1109 + .bottom_0() 1110 + .left_0() 1111 + .right_0() 1112 + .pb_3() 1113 + .flex() 1114 + .items_center() 1115 + .opacity(if is_hovered { 1.0 } else { 0.0 }) 1116 + .child( 1117 + // Left half — Play button centered within it 1118 + div().flex_1().flex().justify_center().child( 1058 1119 div() 1059 1120 .id(("album_play_btn", idx)) 1060 1121 .w(px(36.0)) ··· 1081 1142 }) 1082 1143 .on_click(move |_, _, cx: &mut App| { 1083 1144 cx.stop_propagation(); 1084 - cx.global::<Controller>() 1085 - .play_album(album_id_play.clone(), false); 1145 + cx.global::<Controller>().play_album( 1146 + album_id_play.clone(), 1147 + false, 1148 + ); 1086 1149 }) 1087 1150 .child(Icon::new(Icons::Play).size_4()), 1088 1151 ), 1089 - ) 1090 - .child( 1091 - // Right half — Options button centered within it 1092 - div() 1093 - .flex_1() 1094 - .flex() 1095 - .justify_center() 1096 - .child( 1152 + ) 1153 + .child( 1154 + // Right half — Options button centered within it 1155 + div().flex_1().flex().justify_center().child( 1097 1156 div() 1098 1157 .id(("album_opts_btn", idx)) 1099 1158 .w(px(36.0)) ··· 1120 1179 }) 1121 1180 .on_click(move |event, _, cx: &mut App| { 1122 1181 cx.stop_propagation(); 1123 - cx.global_mut::<AlbumContextMenuState>().0 = 1124 - Some(AlbumContextMenu { 1125 - pos: event.position(), 1126 - album_id: album_id_opts.clone(), 1127 - album_name: name_clone_opts.clone(), 1128 - album_art: art_url_opts.clone(), 1129 - artist_name: artist_for_opts.clone(), 1130 - track_paths: paths_for_opts.clone(), 1131 - }); 1182 + cx.global_mut::<AlbumContextMenuState>() 1183 + .0 = Some(AlbumContextMenu { 1184 + pos: event.position(), 1185 + album_id: album_id_opts.clone(), 1186 + album_name: name_clone_opts.clone(), 1187 + album_art: art_url_opts.clone(), 1188 + artist_name: artist_for_opts.clone(), 1189 + track_paths: paths_for_opts.clone(), 1190 + }); 1132 1191 }) 1133 1192 .child(Icon::new(Icons::Options).size_4()), 1134 1193 ), 1135 - ); 1136 - let art_tile_overlay = art_container 1137 - .child(art_content) 1138 - .child(art_overlay) 1139 - .into_any_element(); 1140 - div() 1141 - .id(("album_card", idx)) 1142 - .flex() 1143 - .flex_col() 1144 - .gap_y_2() 1145 - .cursor_pointer() 1146 - .on_click(move |_, _, cx: &mut App| { 1147 - *cx.global_mut::<SelectedAlbum>() = 1148 - SelectedAlbum(name_clone.clone()); 1149 - *cx.global_mut::<BackSection>() = 1150 - BackSection(LibrarySection::Albums); 1151 - *cx.global_mut::<LibrarySection>() = 1152 - LibrarySection::AlbumDetail; 1153 - }) 1154 - .child(art_tile_overlay) 1155 - .child( 1156 - div() 1157 - .flex() 1158 - .flex_col() 1159 - .gap_y_0p5() 1160 - .child( 1161 - div() 1162 - .text_sm() 1163 - .font_weight(FontWeight(500.0)) 1164 - .text_color(theme.library_text) 1165 - .truncate() 1166 - .child(name), 1167 - ) 1168 - .child( 1169 - div() 1170 - .text_xs() 1171 - .text_color(theme.library_header_text) 1172 - .truncate() 1173 - .child(if year > 0 { 1174 - format!("{artist} · {year}") 1175 - } else { 1176 - artist 1177 - }), 1178 - ), 1179 - ) 1180 - }, 1181 - )), 1182 - ) 1183 - .into_any_element(), 1194 + ); 1195 + let art_tile_overlay = art_container 1196 + .child(art_content) 1197 + .child(art_overlay) 1198 + .into_any_element(); 1199 + div() 1200 + .id(("album_card", idx)) 1201 + .flex() 1202 + .flex_col() 1203 + .gap_y_2() 1204 + .cursor_pointer() 1205 + .on_click(move |_, _, cx: &mut App| { 1206 + *cx.global_mut::<SelectedAlbum>() = 1207 + SelectedAlbum(name_clone.clone()); 1208 + *cx.global_mut::<BackSection>() = 1209 + BackSection(LibrarySection::Albums); 1210 + *cx.global_mut::<LibrarySection>() = 1211 + LibrarySection::AlbumDetail; 1212 + }) 1213 + .child(art_tile_overlay) 1214 + .child( 1215 + div() 1216 + .flex() 1217 + .flex_col() 1218 + .gap_y_0p5() 1219 + .child( 1220 + div() 1221 + .text_sm() 1222 + .font_weight(FontWeight(500.0)) 1223 + .text_color(theme.library_text) 1224 + .truncate() 1225 + .child(name), 1226 + ) 1227 + .child( 1228 + div() 1229 + .text_xs() 1230 + .text_color(theme.library_header_text) 1231 + .truncate() 1232 + .child(if year > 0 { 1233 + format!("{artist} · {year}") 1234 + } else { 1235 + artist 1236 + }), 1237 + ), 1238 + ) 1239 + }, 1240 + )), 1241 + ) 1242 + .into_any_element(), 1184 1243 1185 - // ── Artists grid ────────────────────────────────────────────────────── 1186 - LibrarySection::Artists => div() 1187 - .id("artists_scroll") 1188 - .flex_1() 1189 - .min_h_0() 1190 - .overflow_y_scroll() 1191 - .child( 1192 - div() 1193 - .w_full() 1194 - .p_6() 1195 - .grid() 1196 - .grid_cols(artist_cols) 1197 - .gap_6() 1198 - .children(artists.into_iter().enumerate().map( 1199 - |(idx, (name, count, image))| { 1200 - let name_clone = name.clone(); 1201 - div() 1202 - .id(("artist_card", idx)) 1203 - .flex() 1204 - .flex_col() 1205 - .items_center() 1206 - .gap_y_2() 1207 - .cursor_pointer() 1208 - .hover(|this| this.opacity(0.8)) 1209 - .on_click(move |_, _, cx: &mut App| { 1210 - *cx.global_mut::<SelectedArtist>() = 1211 - SelectedArtist(name_clone.clone()); 1212 - *cx.global_mut::<LibrarySection>() = 1213 - LibrarySection::ArtistDetail; 1214 - }) 1215 - .child({ 1216 - let img_url = image.filter(|s| !s.is_empty()).map(|s| { 1217 - if s.starts_with("http") { 1218 - s 1244 + // ── Artists grid ────────────────────────────────────────────────────── 1245 + LibrarySection::Artists => div() 1246 + .id("artists_scroll") 1247 + .flex_1() 1248 + .min_h_0() 1249 + .overflow_y_scroll() 1250 + .child( 1251 + div() 1252 + .w_full() 1253 + .p_6() 1254 + .grid() 1255 + .grid_cols(artist_cols) 1256 + .gap_6() 1257 + .children(artists.into_iter().enumerate().map( 1258 + |(idx, (name, count, image))| { 1259 + let name_clone = name.clone(); 1260 + div() 1261 + .id(("artist_card", idx)) 1262 + .flex() 1263 + .flex_col() 1264 + .items_center() 1265 + .gap_y_2() 1266 + .cursor_pointer() 1267 + .hover(|this| this.opacity(0.8)) 1268 + .on_click(move |_, _, cx: &mut App| { 1269 + *cx.global_mut::<SelectedArtist>() = 1270 + SelectedArtist(name_clone.clone()); 1271 + *cx.global_mut::<LibrarySection>() = 1272 + LibrarySection::ArtistDetail; 1273 + }) 1274 + .child({ 1275 + let img_url = 1276 + image.filter(|s| !s.is_empty()).map(|s| { 1277 + if s.starts_with("http") { 1278 + s 1279 + } else { 1280 + format!("{COVERS_BASE}{s}") 1281 + } 1282 + }); 1283 + let mut container = div() 1284 + .w_full() 1285 + .rounded_full() 1286 + .overflow_hidden() 1287 + .flex_shrink_0(); 1288 + container.style().aspect_ratio = Some(1.0_f32); 1289 + if let Some(url) = img_url { 1290 + container.child( 1291 + img(url) 1292 + .w_full() 1293 + .h_full() 1294 + .rounded_full() 1295 + .object_fit(ObjectFit::Cover), 1296 + ) 1219 1297 } else { 1220 - format!("{COVERS_BASE}{s}") 1298 + container 1299 + .bg(theme.library_art_bg) 1300 + .flex() 1301 + .items_center() 1302 + .justify_center() 1303 + .text_color(theme.player_icons_text) 1304 + .child(Icon::new(Icons::Artist).size_8()) 1221 1305 } 1222 - }); 1223 - let mut container = div() 1224 - .w_full() 1225 - .rounded_full() 1226 - .overflow_hidden() 1227 - .flex_shrink_0(); 1228 - container.style().aspect_ratio = Some(1.0_f32); 1229 - if let Some(url) = img_url { 1230 - container.child( 1231 - img(url) 1232 - .w_full() 1233 - .h_full() 1234 - .rounded_full() 1235 - .object_fit(ObjectFit::Cover), 1236 - ) 1237 - } else { 1238 - container 1239 - .bg(theme.library_art_bg) 1306 + }) 1307 + .child( 1308 + div() 1309 + .w_full() 1240 1310 .flex() 1311 + .flex_col() 1241 1312 .items_center() 1242 - .justify_center() 1243 - .text_color(theme.player_icons_text) 1244 - .child(Icon::new(Icons::Artist).size_8()) 1245 - } 1246 - }) 1247 - .child( 1248 - div() 1249 - .w_full() 1250 - .flex() 1251 - .flex_col() 1252 - .items_center() 1253 - .gap_y_0p5() 1254 - .child( 1255 - div() 1256 - .w_full() 1257 - .text_sm() 1258 - .font_weight(FontWeight(500.0)) 1259 - .text_color(theme.library_text) 1260 - .text_center() 1261 - .truncate() 1262 - .child(name), 1263 - ) 1264 - .child( 1265 - div() 1266 - .text_xs() 1267 - .text_color(theme.library_header_text) 1268 - .child(format!("{count} tracks")), 1269 - ), 1270 - ) 1271 - }, 1272 - )), 1273 - ) 1274 - .into_any_element(), 1313 + .gap_y_0p5() 1314 + .child( 1315 + div() 1316 + .w_full() 1317 + .text_sm() 1318 + .font_weight(FontWeight(500.0)) 1319 + .text_color(theme.library_text) 1320 + .text_center() 1321 + .truncate() 1322 + .child(name), 1323 + ) 1324 + .child( 1325 + div() 1326 + .text_xs() 1327 + .text_color(theme.library_header_text) 1328 + .child(format!("{count} tracks")), 1329 + ), 1330 + ) 1331 + }, 1332 + )), 1333 + ) 1334 + .into_any_element(), 1275 1335 1276 - // ── Album Detail ────────────────────────────────────────────────────── 1277 - LibrarySection::AlbumDetail => { 1278 - let back_label = if back_section == LibrarySection::ArtistDetail { 1279 - format!("← {}", selected_artist) 1280 - } else { 1281 - "← Albums".to_string() 1282 - }; 1283 - let n_tracks_label = format!( 1284 - "{} track{}", 1285 - n_album_tracks, 1286 - if n_album_tracks == 1 { "" } else { "s" } 1287 - ); 1288 - let album_name_display = selected_album.clone(); 1289 - let album_artist_display = album_artist.clone(); 1336 + // ── Album Detail ────────────────────────────────────────────────────── 1337 + LibrarySection::AlbumDetail => { 1338 + let back_label = if back_section == LibrarySection::ArtistDetail { 1339 + format!("← {}", selected_artist) 1340 + } else { 1341 + "← Albums".to_string() 1342 + }; 1343 + let n_tracks_label = format!( 1344 + "{} track{}", 1345 + n_album_tracks, 1346 + if n_album_tracks == 1 { "" } else { "s" } 1347 + ); 1348 + let album_name_display = selected_album.clone(); 1349 + let album_artist_display = album_artist.clone(); 1290 1350 1291 - div() 1351 + div() 1292 1352 .id("album_detail_scroll") 1293 1353 .flex_1() 1294 1354 .min_h_0() ··· 1447 1507 .child(div().w(px(28.0)).flex_shrink_0()) 1448 1508 .child(div().w(px(28.0)).flex_shrink_0()), 1449 1509 ) 1450 - // Track rows 1451 - .children(album_tracks.into_iter().enumerate().map( 1452 - |(i, (global_idx, path, title, num, duration, track_id, art))| { 1510 + // Track rows (with disc headers for multi-disc albums) 1511 + .child({ 1512 + let has_multiple_discs = album_tracks 1513 + .iter() 1514 + .any(|(_, _, _, _, _, _, _, disc)| *disc > 1); 1515 + let mut rows: Vec<AnyElement> = Vec::new(); 1516 + let mut current_disc = 0u32; 1517 + for (i, (global_idx, path, title, num, duration, track_id, art, disc)) in 1518 + album_tracks.into_iter().enumerate() 1519 + { 1520 + if has_multiple_discs && disc != current_disc { 1521 + current_disc = disc; 1522 + rows.push( 1523 + div() 1524 + .w_full() 1525 + .flex() 1526 + .items_center() 1527 + .gap_x_3() 1528 + .px_6() 1529 + .pt_4() 1530 + .pb_2() 1531 + .child( 1532 + div() 1533 + .text_xs() 1534 + .font_weight(FontWeight::MEDIUM) 1535 + .text_color(theme.library_header_text) 1536 + .child(format!("DISC {}", disc)), 1537 + ) 1538 + .into_any_element(), 1539 + ); 1540 + } 1453 1541 let is_current = current_idx == Some(global_idx); 1454 1542 let is_liked = liked_songs.contains(&track_id); 1455 1543 let row_album = selected_album.clone(); 1456 1544 let row_artist = album_artist.clone(); 1457 - track_row( 1458 - ("album_detail_row", i), 1459 - path, 1460 - num, 1461 - title, 1462 - None, 1463 - None, 1464 - duration, 1465 - is_current, 1466 - is_liked, 1467 - row_artist, 1468 - row_album, 1469 - track_id, 1470 - art, 1471 - ) 1472 - }, 1473 - )), 1545 + rows.push( 1546 + track_row( 1547 + ("album_detail_row", i), 1548 + path, 1549 + num, 1550 + title, 1551 + None, 1552 + None, 1553 + duration, 1554 + is_current, 1555 + is_liked, 1556 + row_artist, 1557 + row_album, 1558 + track_id, 1559 + art, 1560 + ) 1561 + .into_any_element(), 1562 + ); 1563 + } 1564 + div().children(rows) 1565 + }), 1474 1566 ) 1475 1567 .into_any_element() 1476 - } 1568 + } 1477 1569 1478 - // ── Artist Detail ───────────────────────────────────────────────────── 1479 - LibrarySection::ArtistDetail => { 1480 - let n_tracks_label = format!( 1481 - "{} track{}", 1482 - n_artist_tracks, 1483 - if n_artist_tracks == 1 { "" } else { "s" } 1484 - ); 1485 - let artist_name_display = selected_artist.clone(); 1486 - let sa_clone = selected_artist.clone(); 1570 + // ── Artist Detail ───────────────────────────────────────────────────── 1571 + LibrarySection::ArtistDetail => { 1572 + let n_tracks_label = format!( 1573 + "{} track{}", 1574 + n_artist_tracks, 1575 + if n_artist_tracks == 1 { "" } else { "s" } 1576 + ); 1577 + let artist_name_display = selected_artist.clone(); 1578 + let sa_clone = selected_artist.clone(); 1487 1579 1488 - div() 1580 + div() 1489 1581 .id("artist_detail_scroll") 1490 1582 .flex_1() 1491 1583 .min_h_0() ··· 1768 1860 )), 1769 1861 ) 1770 1862 .into_any_element() 1771 - } 1863 + } 1772 1864 1773 - // ── Likes ───────────────────────────────────────────────────────────── 1774 - LibrarySection::Likes => { 1775 - let n_liked = liked_tracks.len(); 1776 - let liked_paths: Vec<String> = 1777 - liked_tracks.iter().map(|(_, p, ..)| p.clone()).collect(); 1778 - let liked_paths_shuffle = liked_paths.clone(); 1779 - div() 1780 - .id("likes_scroll") 1781 - .flex_1() 1782 - .min_h_0() 1783 - .overflow_y_scroll() 1784 - .child( 1785 - div() 1786 - .w_full() 1787 - .flex() 1788 - .flex_col() 1789 - // Header 1790 - .child( 1791 - div() 1792 - .px_6() 1793 - .pt_5() 1794 - .pb_6() 1795 - .flex() 1796 - .flex_col() 1797 - .gap_y_4() 1798 - .child( 1799 - div() 1800 - .flex() 1801 - .items_center() 1802 - .gap_x_3() 1803 - .child( 1804 - Icon::new(Icons::Heart) 1805 - .size_8() 1806 - .text_color(gpui::rgb(0xFFFFFF)), 1807 - ) 1808 - .child( 1809 - div() 1810 - .flex() 1811 - .flex_col() 1812 - .gap_y_1() 1813 - .child( 1814 - div() 1815 - .text_2xl() 1816 - .font_weight(FontWeight(700.0)) 1817 - .text_color(theme.library_text) 1818 - .child("Liked Songs"), 1819 - ) 1820 - .child( 1821 - div() 1822 - .text_sm() 1823 - .text_color(theme.library_header_text) 1824 - .child(format!( 1825 - "{} track{}", 1826 - n_liked, 1827 - if n_liked == 1 { "" } else { "s" } 1828 - )), 1829 - ), 1830 - ), 1831 - ) 1832 - .child( 1833 - div() 1834 - .flex() 1835 - .items_center() 1836 - .gap_x_3() 1837 - .child( 1838 - div() 1839 - .id("likes_play_btn") 1840 - .flex() 1841 - .items_center() 1842 - .gap_x_2() 1843 - .px_4() 1844 - .py_2() 1845 - .rounded_md() 1846 - .cursor_pointer() 1847 - .bg(theme.player_play_pause_bg) 1848 - .text_color(theme.player_play_pause_text) 1849 - .hover(|this| { 1850 - this.bg(theme.player_play_pause_hover) 1851 - }) 1852 - .on_click(move |_, _, cx: &mut App| { 1853 - cx.global::<Controller>() 1854 - .play_liked_tracks( 1855 - liked_paths.clone(), 1856 - false, 1857 - ); 1858 - }) 1859 - .child(Icon::new(Icons::Play).size_4()) 1860 - .child( 1861 - div() 1862 - .text_sm() 1863 - .font_weight(FontWeight(600.0)) 1864 - .child("Play"), 1865 - ), 1866 - ) 1867 - .child( 1868 - div() 1869 - .id("likes_shuffle_btn") 1870 - .flex() 1871 - .items_center() 1872 - .gap_x_2() 1873 - .px_4() 1874 - .py_2() 1875 - .rounded_md() 1876 - .cursor_pointer() 1877 - .bg(theme.player_icons_bg_active) 1878 - .text_color(theme.library_text) 1879 - .hover(|this| { 1880 - this.bg(theme.player_icons_bg_hover) 1881 - }) 1882 - .on_click(move |_, _, cx: &mut App| { 1883 - cx.global::<Controller>() 1884 - .play_liked_tracks( 1885 - liked_paths_shuffle.clone(), 1886 - true, 1887 - ); 1888 - }) 1889 - .child(Icon::new(Icons::Shuffle).size_4()) 1890 - .child( 1891 - div() 1892 - .text_sm() 1893 - .font_weight(FontWeight(500.0)) 1894 - .child("Shuffle"), 1895 - ), 1896 - ), 1897 - ), 1898 - ) 1899 - // Track list header 1900 - .child( 1901 - div() 1902 - .w_full() 1903 - .flex() 1904 - .items_center() 1905 - .gap_x_4() 1906 - .px_6() 1907 - .py_3() 1908 - .border_b_1() 1909 - .border_color(theme.library_table_border) 1910 - .child( 1911 - div() 1912 - .w(px(28.0)) 1913 - .flex_shrink_0() 1914 - .text_xs() 1915 - .font_weight(FontWeight::MEDIUM) 1916 - .text_color(theme.library_header_text) 1917 - .child("#"), 1918 - ) 1919 - .child( 1920 - div() 1921 - .flex_1() 1922 - .min_w_0() 1923 - .text_xs() 1924 - .font_weight(FontWeight::MEDIUM) 1925 - .text_color(theme.library_header_text) 1926 - .child("TITLE"), 1927 - ) 1928 - .child( 1929 - div() 1930 - .w_40() 1931 - .flex_shrink_0() 1932 - .text_xs() 1933 - .font_weight(FontWeight::MEDIUM) 1934 - .text_color(theme.library_header_text) 1935 - .child("ARTIST"), 1936 - ) 1937 - .child( 1938 - div() 1939 - .w_40() 1940 - .flex_shrink_0() 1941 - .text_xs() 1942 - .font_weight(FontWeight::MEDIUM) 1943 - .text_color(theme.library_header_text) 1944 - .child("ALBUM"), 1945 - ) 1946 - .child( 1947 - div() 1948 - .w(px(56.0)) 1949 - .flex_shrink_0() 1950 - .text_xs() 1951 - .font_weight(FontWeight::MEDIUM) 1952 - .text_color(theme.library_header_text) 1953 - .child("TIME"), 1954 - ) 1955 - .child(div().w(px(28.0)).flex_shrink_0()) 1956 - .child(div().w(px(28.0)).flex_shrink_0()), 1957 - ) 1958 - // Liked track rows 1959 - .children(liked_tracks.into_iter().enumerate().map( 1960 - |(i, (global_idx, path, title, artist, album, duration, track_id, art))| { 1961 - let is_current = current_idx == Some(global_idx); 1962 - let row_artist = artist.clone(); 1963 - let row_album = album.clone(); 1964 - track_row( 1965 - ("liked_row", i), 1966 - path, 1967 - (i + 1).to_string(), 1968 - title, 1969 - Some(artist), 1970 - Some(album), 1971 - duration, 1972 - is_current, 1973 - true, 1974 - row_artist, 1975 - row_album, 1976 - track_id, 1977 - art, 1978 - ) 1979 - }, 1980 - )), 1981 - ) 1982 - .into_any_element() 1983 - } 1865 + // ── Likes ───────────────────────────────────────────────────────────── 1866 + LibrarySection::Likes => { 1867 + let n_liked = liked_tracks.len(); 1868 + let liked_paths: Vec<String> = 1869 + liked_tracks.iter().map(|(_, p, ..)| p.clone()).collect(); 1870 + let liked_paths_shuffle = liked_paths.clone(); 1871 + div() 1872 + .id("likes_scroll") 1873 + .flex_1() 1874 + .min_h_0() 1875 + .overflow_y_scroll() 1876 + .child( 1877 + div() 1878 + .w_full() 1879 + .flex() 1880 + .flex_col() 1881 + // Header 1882 + .child( 1883 + div() 1884 + .px_6() 1885 + .pt_5() 1886 + .pb_6() 1887 + .flex() 1888 + .flex_col() 1889 + .gap_y_4() 1890 + .child( 1891 + div() 1892 + .flex() 1893 + .items_center() 1894 + .gap_x_3() 1895 + .child( 1896 + Icon::new(Icons::Heart) 1897 + .size_8() 1898 + .text_color(gpui::rgb(0xFFFFFF)), 1899 + ) 1900 + .child( 1901 + div() 1902 + .flex() 1903 + .flex_col() 1904 + .gap_y_1() 1905 + .child( 1906 + div() 1907 + .text_2xl() 1908 + .font_weight(FontWeight(700.0)) 1909 + .text_color(theme.library_text) 1910 + .child("Liked Songs"), 1911 + ) 1912 + .child( 1913 + div() 1914 + .text_sm() 1915 + .text_color( 1916 + theme.library_header_text, 1917 + ) 1918 + .child(format!( 1919 + "{} track{}", 1920 + n_liked, 1921 + if n_liked == 1 { 1922 + "" 1923 + } else { 1924 + "s" 1925 + } 1926 + )), 1927 + ), 1928 + ), 1929 + ) 1930 + .child( 1931 + div() 1932 + .flex() 1933 + .items_center() 1934 + .gap_x_3() 1935 + .child( 1936 + div() 1937 + .id("likes_play_btn") 1938 + .flex() 1939 + .items_center() 1940 + .gap_x_2() 1941 + .px_4() 1942 + .py_2() 1943 + .rounded_md() 1944 + .cursor_pointer() 1945 + .bg(theme.player_play_pause_bg) 1946 + .text_color(theme.player_play_pause_text) 1947 + .hover(|this| { 1948 + this.bg(theme.player_play_pause_hover) 1949 + }) 1950 + .on_click(move |_, _, cx: &mut App| { 1951 + cx.global::<Controller>() 1952 + .play_liked_tracks( 1953 + liked_paths.clone(), 1954 + false, 1955 + ); 1956 + }) 1957 + .child(Icon::new(Icons::Play).size_4()) 1958 + .child( 1959 + div() 1960 + .text_sm() 1961 + .font_weight(FontWeight(600.0)) 1962 + .child("Play"), 1963 + ), 1964 + ) 1965 + .child( 1966 + div() 1967 + .id("likes_shuffle_btn") 1968 + .flex() 1969 + .items_center() 1970 + .gap_x_2() 1971 + .px_4() 1972 + .py_2() 1973 + .rounded_md() 1974 + .cursor_pointer() 1975 + .bg(theme.player_icons_bg_active) 1976 + .text_color(theme.library_text) 1977 + .hover(|this| { 1978 + this.bg(theme.player_icons_bg_hover) 1979 + }) 1980 + .on_click(move |_, _, cx: &mut App| { 1981 + cx.global::<Controller>() 1982 + .play_liked_tracks( 1983 + liked_paths_shuffle.clone(), 1984 + true, 1985 + ); 1986 + }) 1987 + .child(Icon::new(Icons::Shuffle).size_4()) 1988 + .child( 1989 + div() 1990 + .text_sm() 1991 + .font_weight(FontWeight(500.0)) 1992 + .child("Shuffle"), 1993 + ), 1994 + ), 1995 + ), 1996 + ) 1997 + // Track list header 1998 + .child( 1999 + div() 2000 + .w_full() 2001 + .flex() 2002 + .items_center() 2003 + .gap_x_4() 2004 + .px_6() 2005 + .py_3() 2006 + .border_b_1() 2007 + .border_color(theme.library_table_border) 2008 + .child( 2009 + div() 2010 + .w(px(28.0)) 2011 + .flex_shrink_0() 2012 + .text_xs() 2013 + .font_weight(FontWeight::MEDIUM) 2014 + .text_color(theme.library_header_text) 2015 + .child("#"), 2016 + ) 2017 + .child( 2018 + div() 2019 + .flex_1() 2020 + .min_w_0() 2021 + .text_xs() 2022 + .font_weight(FontWeight::MEDIUM) 2023 + .text_color(theme.library_header_text) 2024 + .child("TITLE"), 2025 + ) 2026 + .child( 2027 + div() 2028 + .w_40() 2029 + .flex_shrink_0() 2030 + .text_xs() 2031 + .font_weight(FontWeight::MEDIUM) 2032 + .text_color(theme.library_header_text) 2033 + .child("ARTIST"), 2034 + ) 2035 + .child( 2036 + div() 2037 + .w_40() 2038 + .flex_shrink_0() 2039 + .text_xs() 2040 + .font_weight(FontWeight::MEDIUM) 2041 + .text_color(theme.library_header_text) 2042 + .child("ALBUM"), 2043 + ) 2044 + .child( 2045 + div() 2046 + .w(px(56.0)) 2047 + .flex_shrink_0() 2048 + .text_xs() 2049 + .font_weight(FontWeight::MEDIUM) 2050 + .text_color(theme.library_header_text) 2051 + .child("TIME"), 2052 + ) 2053 + .child(div().w(px(28.0)).flex_shrink_0()) 2054 + .child(div().w(px(28.0)).flex_shrink_0()), 2055 + ) 2056 + // Liked track rows 2057 + .children(liked_tracks.into_iter().enumerate().map( 2058 + |( 2059 + i, 2060 + ( 2061 + global_idx, 2062 + path, 2063 + title, 2064 + artist, 2065 + album, 2066 + duration, 2067 + track_id, 2068 + art, 2069 + ), 2070 + )| { 2071 + let is_current = current_idx == Some(global_idx); 2072 + let row_artist = artist.clone(); 2073 + let row_album = album.clone(); 2074 + track_row( 2075 + ("liked_row", i), 2076 + path, 2077 + (i + 1).to_string(), 2078 + title, 2079 + Some(artist), 2080 + Some(album), 2081 + duration, 2082 + is_current, 2083 + true, 2084 + row_artist, 2085 + row_album, 2086 + track_id, 2087 + art, 2088 + ) 2089 + }, 2090 + )), 2091 + ) 2092 + .into_any_element() 2093 + } 1984 2094 1985 - // ── Files ───────────────────────────────────────────────────────────── 1986 - LibrarySection::Files => self.files_view.clone().into_any_element(), 1987 - }; 1988 - content_inner 2095 + // ── Files ───────────────────────────────────────────────────────────── 2096 + LibrarySection::Files => self.files_view.clone().into_any_element(), 2097 + }; 2098 + content_inner 1989 2099 }; // end if/else search 1990 2100 1991 2101 div() ··· 2014 2124 .pt_4() 2015 2125 .child(self.search_input.clone()) 2016 2126 .gap_y_1() 2017 - .child(make_nav_item(Icons::Music, 5, "Songs", LibrarySection::Songs)) 2018 - .child(make_nav_item(Icons::Disc, 5, "Albums", LibrarySection::Albums)) 2019 - .child(make_nav_item(Icons::Artist, 5, "Artists", LibrarySection::Artists)) 2020 - .child(make_nav_item(Icons::HeartOutline, 5, "Likes", LibrarySection::Likes)) 2021 - .child(make_nav_item(Icons::HardDrive, 4, "Files", LibrarySection::Files)), 2127 + .child(make_nav_item( 2128 + Icons::Music, 2129 + 5, 2130 + "Songs", 2131 + LibrarySection::Songs, 2132 + )) 2133 + .child(make_nav_item( 2134 + Icons::Disc, 2135 + 5, 2136 + "Albums", 2137 + LibrarySection::Albums, 2138 + )) 2139 + .child(make_nav_item( 2140 + Icons::Artist, 2141 + 5, 2142 + "Artists", 2143 + LibrarySection::Artists, 2144 + )) 2145 + .child(make_nav_item( 2146 + Icons::HeartOutline, 2147 + 5, 2148 + "Likes", 2149 + LibrarySection::Likes, 2150 + )) 2151 + .child(make_nav_item( 2152 + Icons::HardDrive, 2153 + 4, 2154 + "Files", 2155 + LibrarySection::Files, 2156 + )), 2022 2157 ) 2023 2158 .child(content), 2024 2159 ) ··· 2038 2173 let menu_h = px(198.0); 2039 2174 let margin = px(8.0); 2040 2175 let max_x = viewport.width - menu_w - margin; 2041 - let menu_x = if menu.pos.x > max_x { max_x } else { menu.pos.x }; 2176 + let menu_x = if menu.pos.x > max_x { 2177 + max_x 2178 + } else { 2179 + menu.pos.x 2180 + }; 2042 2181 let menu_x = if menu_x < margin { margin } else { menu_x }; 2043 2182 // Flip above cursor when the menu would overflow the bottom edge. 2044 2183 let overflows_bottom = (menu.pos.y + menu_h + margin) > viewport.height; 2045 - let menu_y = if overflows_bottom { menu.pos.y - menu_h } else { menu.pos.y }; 2184 + let menu_y = if overflows_bottom { 2185 + menu.pos.y - menu_h 2186 + } else { 2187 + menu.pos.y 2188 + }; 2046 2189 let menu_y = if menu_y < margin { margin } else { menu_y }; 2047 2190 this.child( 2048 2191 div() ··· 2087 2230 .rounded_md() 2088 2231 .flex_shrink_0() 2089 2232 .overflow_hidden() 2090 - .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 2233 + .child( 2234 + img(url).w_full().h_full().object_fit(ObjectFit::Cover), 2235 + ) 2091 2236 .into_any_element() 2092 2237 } else { 2093 2238 div() ··· 2207 2352 let paths_add_shuffled = menu.track_paths.clone(); 2208 2353 let paths_last_shuffled = menu.track_paths.clone(); 2209 2354 let artist_nav = menu.artist_name.clone(); 2210 - let alb_header_art_url = menu 2211 - .album_art 2212 - .as_deref() 2213 - .filter(|s| !s.is_empty()) 2214 - .map(|s| { 2215 - if s.starts_with("http") { 2216 - s.to_string() 2217 - } else { 2218 - format!("{COVERS_BASE}{s}") 2219 - } 2220 - }); 2355 + let alb_header_art_url = 2356 + menu.album_art 2357 + .as_deref() 2358 + .filter(|s| !s.is_empty()) 2359 + .map(|s| { 2360 + if s.starts_with("http") { 2361 + s.to_string() 2362 + } else { 2363 + format!("{COVERS_BASE}{s}") 2364 + } 2365 + }); 2221 2366 // header ~64px + 7 items × ~33px + borders 2222 2367 let menu_w = px(250.0); 2223 2368 let menu_h = px(296.0); 2224 2369 let margin = px(8.0); 2225 2370 let max_x = viewport.width - menu_w - margin; 2226 - let menu_x = if menu.pos.x > max_x { max_x } else { menu.pos.x }; 2371 + let menu_x = if menu.pos.x > max_x { 2372 + max_x 2373 + } else { 2374 + menu.pos.x 2375 + }; 2227 2376 let menu_x = if menu_x < margin { margin } else { menu_x }; 2228 2377 // Flip above cursor when the menu would overflow the bottom edge. 2229 2378 let overflows_bottom = (menu.pos.y + menu_h + margin) > viewport.height; 2230 - let menu_y = if overflows_bottom { menu.pos.y - menu_h } else { menu.pos.y }; 2379 + let menu_y = if overflows_bottom { 2380 + menu.pos.y - menu_h 2381 + } else { 2382 + menu.pos.y 2383 + }; 2231 2384 let menu_y = if menu_y < margin { margin } else { menu_y }; 2232 2385 this.child( 2233 2386 div() ··· 2272 2425 .rounded_md() 2273 2426 .flex_shrink_0() 2274 2427 .overflow_hidden() 2275 - .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 2428 + .child( 2429 + img(url).w_full().h_full().object_fit(ObjectFit::Cover), 2430 + ) 2276 2431 .into_any_element() 2277 2432 } else { 2278 2433 div() ··· 2322 2477 .text_color(theme.library_text) 2323 2478 .hover(|this| this.bg(theme.library_track_bg_hover)) 2324 2479 .on_click(move |_, _, cx: &mut App| { 2325 - cx.global::<Controller>().play_album(album_id_play.clone(), false); 2480 + cx.global::<Controller>() 2481 + .play_album(album_id_play.clone(), false); 2326 2482 cx.global_mut::<AlbumContextMenuState>().0 = None; 2327 2483 }) 2328 2484 .child("Play"), ··· 2451 2607 let menu_h = if is_dir { px(230.0) } else { px(140.0) }; 2452 2608 let margin = px(8.0); 2453 2609 let max_x = viewport.width - menu_w - margin; 2454 - let menu_x = if menu.pos.x > max_x { max_x } else { menu.pos.x }; 2610 + let menu_x = if menu.pos.x > max_x { 2611 + max_x 2612 + } else { 2613 + menu.pos.x 2614 + }; 2455 2615 let menu_x = if menu_x < margin { margin } else { menu_x }; 2456 2616 let overflows_bottom = (menu.pos.y + menu_h + margin) > viewport.height; 2457 - let menu_y = if overflows_bottom { menu.pos.y - menu_h } else { menu.pos.y }; 2617 + let menu_y = if overflows_bottom { 2618 + menu.pos.y - menu_h 2619 + } else { 2620 + menu.pos.y 2621 + }; 2458 2622 let menu_y = if menu_y < margin { margin } else { menu_y }; 2459 2623 2460 2624 this.child( ··· 2494 2658 .gap_x_2() 2495 2659 .child( 2496 2660 div().text_color(theme.library_header_text).child( 2497 - Icon::new(if is_dir { Icons::Directory } else { Icons::Music }) 2498 - .size_4(), 2661 + Icon::new(if is_dir { 2662 + Icons::Directory 2663 + } else { 2664 + Icons::Music 2665 + }) 2666 + .size_4(), 2499 2667 ), 2500 2668 ) 2501 2669 .child( ··· 2544 2712 move |_, _, cx: &mut App| { 2545 2713 let rt = cx.global::<Controller>().rt(); 2546 2714 if is_dir { 2547 - rt.spawn(insert_directory(path_shuffled.clone(), INSERT_SHUFFLED)); 2715 + rt.spawn(insert_directory( 2716 + path_shuffled.clone(), 2717 + INSERT_SHUFFLED, 2718 + )); 2548 2719 } else { 2549 2720 rt.spawn(insert_tracks( 2550 2721 vec![path_shuffled.clone()],