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 GPUI HTTP client and background gRPC tasks

Introduce ReqwestHttpClient to bridge tokio/reqwest and GPUI's executor
Spawn a tokio runtime in Controller and start background gRPC
stream/tasks (client.rs) to sync tracks, queue, status and images
Change Track model: id is now String; add album_id, artist_id and
optional album_art, plus ArtistImages map
Add UI art components to fetch covers over HTTP and wire the
ReqwestHttpClient into Application
Bump gpui deps (futures, http, reqwest) and add generated API module

+1116 -490
+2
gpui/Cargo.lock
··· 8 8 dependencies = [ 9 9 "anyhow", 10 10 "env_logger", 11 + "futures", 11 12 "gpui", 13 + "http", 12 14 "log", 13 15 "prost", 14 16 "reqwest",
+2
gpui/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 anyhow = "1.0" 12 + futures = "0.3" 12 13 gpui = "0.2.2" 13 14 rust-embed = "8.0" 14 15 serde = { version = "1.0", features = ["derive"] } ··· 23 24 tonic = "0.12.3" 24 25 tonic-reflection = "0.12.3" 25 26 tonic-web = "0.12.3" 27 + http = "1.4.0" 26 28 27 29 [build-dependencies] 28 30 tonic-build = "0.12.3"
+3
gpui/assets/icons/artist.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> 2 + <path d="M20 4.22a5.67 5.67 0 0 0-9.68 4.57l-8 9.79 3.3 3.3 9.79-8c.18 0 .36.05.55.05a5.7 5.7 0 0 0 4-9.73ZM5.74 19.86l-1.38-1.38 6.44-7.89a5.48 5.48 0 0 0 2.83 2.84Zm13.21-8.65a4.2 4.2 0 1 1 0-5.94 4.17 4.17 0 0 1 0 5.95Z" fill="initial"></path> 3 + </svg>
+3
gpui/assets/icons/directory.svg
··· 1 + <svg viewBox="0 0 16 16" height="20" width="20" aria-hidden="true" focusable="false" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="StyledIconBase-sc-ea9ulj-0 hQYLxw"> 2 + <path d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5v-9zM2.5 3a.5.5 0 0 0-.5.5V6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3H2.5zM14 7H2v5.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V7z"></path> 3 + </svg>
+5
gpui/assets/icons/disc.svg
··· 1 + <svg viewBox="0 0 24 24" height="20" width="20" aria-hidden="true" focusable="false" fill="currentColor" 2 + xmlns="http://www.w3.org/2000/svg" color="initial" class="StyledIconBase-sc-ea9ulj-0 hQYLxw" style="margin-right: 6px;"> 3 + <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"></path> 4 + <path d="M12 8a4 4 0 1 0 4 4 4 4 0 0 0-4-4zm0 6a2 2 0 1 1 2-2 2 2 0 0 1-2 2z"></path> 5 + </svg>
+7
gpui/assets/icons/harddrive.svg
··· 1 + <svg viewBox="0 0 24 24" height="19" width="19" aria-hidden="true" focusable="false" fill="none" 2 + xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" color="initial" class="StyledIconBase-sc-ea9ulj-0 hQYLxw" style="margin-right: 6px;"> 3 + <line x1="22" x2="2" y1="12" y2="12"></line> 4 + <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path> 5 + <line x1="6" x2="6.01" y1="16" y2="16"></line> 6 + <line x1="10" x2="10.01" y1="16" y2="16"></line> 7 + </svg>
+7
gpui/assets/icons/heart-outline.svg
··· 1 + <svg width="24" 2 + xmlns="http://www.w3.org/2000/svg" height="24" fill="none" style="-webkit-print-color-adjust: exact; padding-top: 1px;"> 3 + <g class="ionicon" style="fill: rgb(0, 0, 0);"> 4 + <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M13.534 4C11.167 4 10 6.364 10 6.364S8.833 4 6.466 4C4.543 4 3.02 5.63 3 7.575c-.04 4.038 3.162 6.91 6.672 9.323a.578.578 0 0 0 .656 0c3.51-2.413 6.712-5.285 6.672-9.323C16.98 5.63 15.457 4 13.534 4Z" style="fill: none;"></path> 5 + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M13.534 4C11.167 4 10 6.364 10 6.364S8.833 4 6.466 4C4.543 4 3.02 5.63 3 7.575c-.04 4.038 3.162 6.91 6.672 9.323a.578.578 0 0 0 .656 0c3.51-2.413 6.712-5.285 6.672-9.323C16.98 5.63 15.457 4 13.534 4Z" class="stroke-shape" style="fill: none; stroke-width: 2; stroke: rgb(0, 0, 0); stroke-opacity: 1;"></path> 6 + </g> 7 + </svg>
+6
gpui/assets/icons/heart.svg
··· 1 + <svg width="24" 2 + xmlns="http://www.w3.org/2000/svg" height="24" fill="none" style="-webkit-print-color-adjust: exact; padding-top: 1px;"> 3 + <g style="fill: rgb(254, 9, 163);"> 4 + <path d="M10 17c-.247 0-.488-.071-.692-.203-3.023-1.945-4.332-3.279-5.053-4.113C2.716 10.907 1.98 9.082 2 7.106 2.025 4.842 3.941 3 6.273 3c1.695 0 2.869.905 3.553 1.659a.236.236 0 0 0 .348 0C10.858 3.905 12.032 3 13.727 3 16.059 3 17.975 4.842 18 7.107c.02 1.976-.717 3.801-2.255 5.578-.721.834-2.03 2.167-5.053 4.112A1.275 1.275 0 0 1 10 17Z" class="ionicon" style="fill: rgb(254, 9, 163); fill-opacity: 1;"></path> 5 + </g> 6 + </svg>
+6
gpui/assets/icons/options.svg
··· 1 + <svg viewBox="0 0 512 512" height="24" width="24" aria-hidden="true" focusable="false" fill="currentColor" 2 + xmlns="http://www.w3.org/2000/svg" class="StyledIconBase-sc-ea9ulj-0 hQYLxw"> 3 + <circle cx="256" cy="256" r="48"></circle> 4 + <circle cx="416" cy="256" r="48"></circle> 5 + <circle cx="96" cy="256" r="48"></circle> 6 + </svg>
+6
gpui/src/api/mod.rs
··· 1 + pub mod v1alpha1 { 2 + include!(concat!( 3 + env!("CARGO_MANIFEST_DIR"), 4 + "/src/api/rockbox.v1alpha1.rs" 5 + )); 6 + }
+9 -1
gpui/src/app.rs
··· 11 11 pub fn run() { 12 12 let assets = Assets {}; 13 13 14 + let http_rt = tokio::runtime::Builder::new_multi_thread() 15 + .enable_all() 16 + .build() 17 + .expect("tokio runtime for http"); 18 + let http_client = crate::http_client::ReqwestHttpClient::new(http_rt.handle().clone()); 19 + std::mem::forget(http_rt); 20 + 14 21 Application::new() 22 + .with_http_client(http_client) 15 23 .with_assets(assets.clone()) 16 24 .run(move |cx| { 17 25 let bounds = Bounds::centered(None, size(px(1280.0), px(760.0)), cx); ··· 35 43 }, 36 44 |_window, cx| { 37 45 let state = cx.new(|_| AppState::new()); 38 - let controller = Controller::new(state); 46 + let controller = Controller::new(state, cx); 39 47 cx.set_global(controller); 40 48 cx.new(Rockbox::new) 41 49 },
+259
gpui/src/client.rs
··· 1 + use crate::api::v1alpha1::{ 2 + library_service_client::LibraryServiceClient, playback_service_client::PlaybackServiceClient, 3 + playlist_service_client::PlaylistServiceClient, GetArtistsRequest, GetTracksRequest, 4 + NextRequest, PauseRequest, PlayAlbumRequest, PlayAllTracksRequest, PlayArtistTracksRequest, 5 + PlayTrackRequest, PreviousRequest, ResumeRequest, StartRequest, StreamCurrentTrackRequest, 6 + StreamPlaylistRequest, StreamStatusRequest, 7 + }; 8 + use crate::state::{ArtistImages, PlaybackStatus, StateUpdate, Track}; 9 + use anyhow::Result; 10 + use tokio::sync::mpsc::Sender; 11 + 12 + const URL: &str = "http://127.0.0.1:6061"; 13 + 14 + // ── Library ─────────────────────────────────────────────────────────────────── 15 + 16 + pub async fn fetch_tracks() -> Result<Vec<Track>> { 17 + let mut c = LibraryServiceClient::connect(URL).await?; 18 + let resp = c.get_tracks(GetTracksRequest {}).await?; 19 + Ok(resp 20 + .into_inner() 21 + .tracks 22 + .into_iter() 23 + .map(track_from_proto) 24 + .collect()) 25 + } 26 + 27 + fn track_from_proto(t: crate::api::v1alpha1::Track) -> Track { 28 + Track { 29 + id: t.id, 30 + path: t.path, 31 + title: t.title, 32 + artist: t.artist, 33 + album: t.album, 34 + album_id: t.album_id.unwrap_or_default(), 35 + artist_id: t.artist_id.unwrap_or_default(), 36 + genre: t.genre, 37 + duration: t.length as u64 / 1000, 38 + track_number: t.track_number, 39 + album_art: t.album_art.filter(|s| !s.is_empty()), 40 + } 41 + } 42 + 43 + // ── Playback control ────────────────────────────────────────────────────────── 44 + 45 + pub async fn resume() -> Result<()> { 46 + let mut c = PlaybackServiceClient::connect(URL).await?; 47 + c.resume(ResumeRequest {}).await?; 48 + Ok(()) 49 + } 50 + 51 + pub async fn pause() -> Result<()> { 52 + let mut c = PlaybackServiceClient::connect(URL).await?; 53 + c.pause(PauseRequest {}).await?; 54 + Ok(()) 55 + } 56 + 57 + pub async fn next() -> Result<()> { 58 + let mut c = PlaybackServiceClient::connect(URL).await?; 59 + c.next(NextRequest {}).await?; 60 + Ok(()) 61 + } 62 + 63 + pub async fn prev() -> Result<()> { 64 + let mut c = PlaybackServiceClient::connect(URL).await?; 65 + c.previous(PreviousRequest {}).await?; 66 + Ok(()) 67 + } 68 + 69 + pub async fn play_track(path: String) -> Result<()> { 70 + let mut c = PlaybackServiceClient::connect(URL).await?; 71 + c.play_track(PlayTrackRequest { path }).await?; 72 + Ok(()) 73 + } 74 + 75 + pub async fn play_album(album_id: String) -> Result<()> { 76 + let mut c = PlaybackServiceClient::connect(URL).await?; 77 + c.play_album(PlayAlbumRequest { 78 + album_id, 79 + shuffle: Some(false), 80 + position: Some(0), 81 + }) 82 + .await?; 83 + Ok(()) 84 + } 85 + 86 + pub async fn play_artist_tracks(artist_id: String) -> Result<()> { 87 + let mut c = PlaybackServiceClient::connect(URL).await?; 88 + c.play_artist_tracks(PlayArtistTracksRequest { 89 + artist_id, 90 + shuffle: Some(false), 91 + position: Some(0), 92 + }) 93 + .await?; 94 + Ok(()) 95 + } 96 + 97 + pub async fn play_all_tracks() -> Result<()> { 98 + let mut c = PlaybackServiceClient::connect(URL).await?; 99 + c.play_all_tracks(PlayAllTracksRequest { 100 + shuffle: Some(false), 101 + position: Some(0), 102 + }) 103 + .await?; 104 + Ok(()) 105 + } 106 + 107 + // ── Queue / Playlist ────────────────────────────────────────────────────────── 108 + 109 + pub async fn jump_to_queue_position(pos: i32) -> Result<()> { 110 + let mut c = PlaylistServiceClient::connect(URL).await?; 111 + c.start(StartRequest { 112 + start_index: Some(pos), 113 + elapsed: Some(0), 114 + offset: Some(0), 115 + }) 116 + .await?; 117 + Ok(()) 118 + } 119 + 120 + // ── Streaming loops (tokio tasks — communicate via Sender<StateUpdate>) ─────── 121 + 122 + pub async fn run_library_sync(tx: Sender<StateUpdate>) { 123 + match fetch_tracks().await { 124 + Ok(mut tracks) => { 125 + tracks.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); 126 + let _ = tx.send(StateUpdate::Tracks(tracks)).await; 127 + } 128 + Err(e) => log::warn!("library sync: {e}"), 129 + } 130 + } 131 + 132 + pub async fn run_artist_images_sync(tx: Sender<StateUpdate>) { 133 + match LibraryServiceClient::connect(URL).await { 134 + Ok(mut c) => match c.get_artists(GetArtistsRequest {}).await { 135 + Ok(resp) => { 136 + let images: ArtistImages = resp 137 + .into_inner() 138 + .artists 139 + .into_iter() 140 + .filter_map(|a| a.image.filter(|s| !s.is_empty()).map(|img| (a.name, img))) 141 + .collect(); 142 + let _ = tx.send(StateUpdate::ArtistImages(images)).await; 143 + } 144 + Err(e) => log::warn!("artist images sync: {e}"), 145 + }, 146 + Err(e) => log::warn!("artist images connect: {e}"), 147 + } 148 + } 149 + 150 + pub async fn run_status_stream(tx: Sender<StateUpdate>) { 151 + loop { 152 + if let Err(e) = status_stream_inner(&tx).await { 153 + log::warn!("status stream: {e}"); 154 + } 155 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 156 + } 157 + } 158 + 159 + async fn status_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 160 + let mut c = PlaybackServiceClient::connect(URL).await?; 161 + let resp = c.stream_status(StreamStatusRequest {}).await?; 162 + let mut stream = resp.into_inner(); 163 + loop { 164 + match stream.message().await { 165 + Ok(Some(msg)) => { 166 + let new_status = match msg.status { 167 + 1 => PlaybackStatus::Playing, 168 + 2 => PlaybackStatus::Paused, 169 + _ => PlaybackStatus::Stopped, 170 + }; 171 + let _ = tx.send(StateUpdate::Status(new_status)).await; 172 + } 173 + Ok(None) => break, 174 + Err(e) => { 175 + log::warn!("status stream message: {e}"); 176 + break; 177 + } 178 + } 179 + } 180 + Ok(()) 181 + } 182 + 183 + pub async fn run_current_track_stream(tx: Sender<StateUpdate>) { 184 + loop { 185 + if let Err(e) = current_track_stream_inner(&tx).await { 186 + log::warn!("current track stream: {e}"); 187 + } 188 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 189 + } 190 + } 191 + 192 + async fn current_track_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 193 + let mut c = PlaybackServiceClient::connect(URL).await?; 194 + let resp = c.stream_current_track(StreamCurrentTrackRequest {}).await?; 195 + let mut stream = resp.into_inner(); 196 + loop { 197 + match stream.message().await { 198 + Ok(Some(msg)) => { 199 + let _ = tx.send(StateUpdate::Position(msg.elapsed / 1000)).await; 200 + } 201 + Ok(None) => break, 202 + Err(e) => { 203 + log::warn!("current track stream message: {e}"); 204 + break; 205 + } 206 + } 207 + } 208 + Ok(()) 209 + } 210 + 211 + pub async fn run_playlist_stream(tx: Sender<StateUpdate>) { 212 + loop { 213 + if let Err(e) = playlist_stream_inner(&tx).await { 214 + log::warn!("playlist stream: {e}"); 215 + } 216 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 217 + } 218 + } 219 + 220 + async fn playlist_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 221 + let mut c = PlaybackServiceClient::connect(URL).await?; 222 + let resp = c.stream_playlist(StreamPlaylistRequest {}).await?; 223 + let mut stream = resp.into_inner(); 224 + loop { 225 + match stream.message().await { 226 + Ok(Some(msg)) => { 227 + let current_idx = if msg.index >= 0 { 228 + Some(msg.index as usize) 229 + } else { 230 + None 231 + }; 232 + let queue: Vec<Track> = msg 233 + .tracks 234 + .into_iter() 235 + .map(|t| Track { 236 + id: t.id, 237 + path: t.path, 238 + title: t.title, 239 + artist: t.artist, 240 + album: t.album, 241 + album_id: t.album_id, 242 + artist_id: t.artist_id, 243 + genre: t.genre, 244 + duration: t.length / 1000, 245 + track_number: t.tracknum as u32, 246 + album_art: t.album_art.filter(|s| !s.is_empty()), 247 + }) 248 + .collect(); 249 + let _ = tx.send(StateUpdate::Playlist { queue, current_idx }).await; 250 + } 251 + Ok(None) => break, 252 + Err(e) => { 253 + log::warn!("playlist stream message: {e}"); 254 + break; 255 + } 256 + } 257 + } 258 + Ok(()) 259 + }
+93 -4
gpui/src/controller.rs
··· 1 - use crate::state::AppState; 2 - use gpui::{Entity, Global}; 1 + use crate::state::{AppState, StateUpdate}; 2 + use gpui::{App, Entity, Global}; 3 + use std::time::Duration; 4 + use tokio::sync::mpsc; 3 5 4 6 pub struct Controller { 5 7 pub state: Entity<AppState>, 8 + rt: tokio::runtime::Runtime, 6 9 } 7 10 8 11 impl Controller { 9 - pub fn new(state: Entity<AppState>) -> Self { 10 - Controller { state } 12 + pub fn new(state: Entity<AppState>, cx: &mut App) -> Self { 13 + let rt = tokio::runtime::Builder::new_multi_thread() 14 + .enable_all() 15 + .build() 16 + .expect("tokio runtime"); 17 + 18 + let (tx, rx) = mpsc::channel::<StateUpdate>(256); 19 + 20 + // Spawn tokio background tasks — these are Send because Sender<StateUpdate> is Send 21 + rt.spawn(crate::client::run_library_sync(tx.clone())); 22 + rt.spawn(crate::client::run_artist_images_sync(tx.clone())); 23 + rt.spawn(crate::client::run_status_stream(tx.clone())); 24 + rt.spawn(crate::client::run_current_track_stream(tx.clone())); 25 + rt.spawn(crate::client::run_playlist_stream(tx.clone())); 26 + 27 + // GPUI foreground poll task — not required to be Send 28 + let state_for_poll = state.clone(); 29 + cx.spawn(async move |cx| { 30 + let mut rx = rx; 31 + loop { 32 + let mut did_update = false; 33 + while let Ok(update) = rx.try_recv() { 34 + let _ = cx.update(|app| { 35 + state_for_poll.update(app, |s, _| match update { 36 + StateUpdate::Status(status) => s.status = status, 37 + StateUpdate::Position(pos) => s.position = pos, 38 + StateUpdate::Playlist { queue, current_idx } => { 39 + s.queue = queue; 40 + s.current_idx = current_idx; 41 + } 42 + StateUpdate::Tracks(tracks) => s.tracks = tracks, 43 + StateUpdate::ArtistImages(images) => s.artist_images = images, 44 + }); 45 + }); 46 + did_update = true; 47 + } 48 + if did_update { 49 + let _ = cx.update(|app| { 50 + state_for_poll.update(app, |_, cx| cx.notify()); 51 + }); 52 + } 53 + cx.background_executor() 54 + .timer(Duration::from_millis(100)) 55 + .await; 56 + } 57 + }) 58 + .detach(); 59 + 60 + Controller { state, rt } 61 + } 62 + 63 + /// Cloneable handle to the tokio runtime — use for fire-and-forget spawns. 64 + pub fn rt(&self) -> tokio::runtime::Handle { 65 + self.rt.handle().clone() 66 + } 67 + 68 + // ── Playback actions ────────────────────────────────────────────────────── 69 + 70 + pub fn next(&self) { 71 + self.rt().spawn(crate::client::next()); 72 + } 73 + 74 + pub fn prev(&self) { 75 + self.rt().spawn(crate::client::prev()); 76 + } 77 + 78 + pub fn play_track_at_idx(&self, idx: usize, cx: &App) { 79 + let path = self.state.read(cx).tracks.get(idx).map(|t| t.path.clone()); 80 + if let Some(path) = path { 81 + self.rt().spawn(crate::client::play_track(path)); 82 + } 83 + } 84 + 85 + pub fn play_album(&self, album_id: String) { 86 + self.rt().spawn(crate::client::play_album(album_id)); 87 + } 88 + 89 + pub fn play_artist_tracks(&self, artist_id: String) { 90 + self.rt() 91 + .spawn(crate::client::play_artist_tracks(artist_id)); 92 + } 93 + 94 + pub fn play_all_tracks(&self) { 95 + self.rt().spawn(crate::client::play_all_tracks()); 96 + } 97 + 98 + pub fn jump_to_queue_position(&self, pos: i32) { 99 + self.rt().spawn(crate::client::jump_to_queue_position(pos)); 11 100 } 12 101 } 13 102
+80
gpui/src/http_client.rs
··· 1 + use futures::future::BoxFuture; 2 + use gpui::http_client::{AsyncBody, HttpClient, Request, Response, Url}; 3 + use http::HeaderValue; 4 + use std::sync::Arc; 5 + use tokio::runtime::Handle; 6 + 7 + pub struct ReqwestHttpClient { 8 + client: reqwest::Client, 9 + handle: Handle, 10 + } 11 + 12 + impl ReqwestHttpClient { 13 + pub fn new(handle: Handle) -> Arc<Self> { 14 + Arc::new(Self { 15 + client: reqwest::Client::new(), 16 + handle, 17 + }) 18 + } 19 + } 20 + 21 + impl HttpClient for ReqwestHttpClient { 22 + fn type_name(&self) -> &'static str { 23 + "ReqwestHttpClient" 24 + } 25 + 26 + fn user_agent(&self) -> Option<&HeaderValue> { 27 + None 28 + } 29 + 30 + fn proxy(&self) -> Option<&Url> { 31 + None 32 + } 33 + 34 + fn send( 35 + &self, 36 + req: Request<AsyncBody>, 37 + ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> { 38 + // Bridge tokio (reqwest) and GPUI's smol executor via a tokio oneshot channel. 39 + // tokio::sync::oneshot::Receiver implements Future using standard Waker, so it can 40 + // be awaited from any executor context — including GPUI's smol background executor. 41 + let (tx, rx) = tokio::sync::oneshot::channel::<anyhow::Result<Response<AsyncBody>>>(); 42 + let client = self.client.clone(); 43 + self.handle.spawn(async move { 44 + let _ = tx.send(do_fetch(client, req).await); 45 + }); 46 + Box::pin(async move { 47 + rx.await 48 + .map_err(|_| anyhow::anyhow!("http client channel closed"))? 49 + }) 50 + } 51 + } 52 + 53 + async fn do_fetch( 54 + client: reqwest::Client, 55 + req: Request<AsyncBody>, 56 + ) -> anyhow::Result<Response<AsyncBody>> { 57 + let (parts, _body) = req.into_parts(); 58 + let url = parts.uri.to_string(); 59 + let method = reqwest::Method::from_bytes(parts.method.as_str().as_bytes()) 60 + .map_err(|e| anyhow::anyhow!("invalid method: {e}"))?; 61 + 62 + let mut rb = client.request(method, url); 63 + for (name, value) in &parts.headers { 64 + rb = rb.header(name, value); 65 + } 66 + 67 + let resp = rb.send().await.map_err(|e| anyhow::anyhow!("{e}"))?; 68 + let status = resp.status(); 69 + let mut builder = Response::builder().status(status); 70 + { 71 + let hm = builder.headers_mut().unwrap(); 72 + for (name, value) in resp.headers() { 73 + hm.insert(name.clone(), value.clone()); 74 + } 75 + } 76 + let bytes = resp.bytes().await.map_err(|e| anyhow::anyhow!("{e}"))?; 77 + Ok(builder 78 + .body(AsyncBody::from_bytes(bytes)) 79 + .map_err(|e| anyhow::anyhow!("response build: {e}"))?) 80 + }
+4 -1
gpui/src/main.rs
··· 1 + pub mod api; 1 2 pub mod app; 3 + pub mod client; 2 4 pub mod controller; 5 + pub mod http_client; 3 6 pub mod state; 4 7 pub mod ui; 5 8 ··· 8 11 9 12 fn main() { 10 13 env_logger::Builder::new() 11 - .filter_level(log::LevelFilter::Error) 14 + .filter_level(log::LevelFilter::Warn) 12 15 .init(); 13 16 app::run(); 14 17 }
+30 -152
gpui/src/state.rs
··· 1 - #[derive(Clone, Debug)] 1 + #[derive(Clone, Debug, Default)] 2 2 pub struct Track { 3 - pub id: usize, 3 + pub id: String, 4 + pub path: String, 4 5 pub title: String, 5 6 pub artist: String, 6 7 pub album: String, 8 + pub album_id: String, 9 + pub artist_id: String, 7 10 pub genre: String, 8 11 pub duration: u64, 9 12 pub track_number: u32, 13 + pub album_art: Option<String>, 10 14 } 15 + 16 + pub type ArtistImages = std::collections::HashMap<String, String>; 11 17 12 18 #[derive(Clone, Copy, Debug, PartialEq)] 13 19 pub enum PlaybackStatus { ··· 18 24 19 25 pub struct AppState { 20 26 pub tracks: Vec<Track>, 21 - pub queue: Vec<usize>, 27 + pub queue: Vec<Track>, 22 28 pub current_idx: Option<usize>, 23 29 pub status: PlaybackStatus, 24 30 pub position: u64, 25 31 pub volume: f32, 26 32 pub shuffling: bool, 27 33 pub repeat: bool, 34 + pub artist_images: ArtistImages, 28 35 } 29 36 30 37 impl AppState { 31 38 pub fn new() -> Self { 32 - let tracks = mock_tracks(); 33 - let n = tracks.len(); 34 39 AppState { 35 - tracks, 36 - queue: (0..n).collect(), 37 - current_idx: Some(0), 38 - status: PlaybackStatus::Paused, 39 - position: 42, 40 + tracks: vec![], 41 + queue: vec![], 42 + current_idx: None, 43 + status: PlaybackStatus::Stopped, 44 + position: 0, 40 45 volume: 0.8, 41 46 shuffling: false, 42 47 repeat: false, 48 + artist_images: Default::default(), 43 49 } 44 50 } 45 51 46 52 pub fn current_track(&self) -> Option<&Track> { 47 - self.current_idx.map(|i| &self.tracks[i]) 53 + self.current_idx.and_then(|i| self.queue.get(i)) 48 54 } 49 55 50 - pub fn play(&mut self) { 51 - self.status = PlaybackStatus::Playing; 52 - } 53 - 54 - pub fn pause(&mut self) { 55 - self.status = PlaybackStatus::Paused; 56 - } 57 - 58 - pub fn next(&mut self) { 59 - if let Some(idx) = self.current_idx { 60 - self.current_idx = Some((idx + 1) % self.tracks.len()); 61 - self.position = 0; 62 - } 63 - } 64 - 65 - pub fn prev(&mut self) { 66 - if let Some(idx) = self.current_idx { 67 - self.current_idx = Some(if idx == 0 { 68 - self.tracks.len() - 1 69 - } else { 70 - idx - 1 71 - }); 72 - self.position = 0; 73 - } 56 + /// Index into `self.tracks` matching the current queue track by path. 57 + pub fn current_library_idx(&self) -> Option<usize> { 58 + let current = self.current_track()?; 59 + self.tracks.iter().position(|t| t.path == current.path) 74 60 } 75 61 76 62 pub fn toggle_shuffle(&mut self) { ··· 80 66 pub fn toggle_repeat(&mut self) { 81 67 self.repeat = !self.repeat; 82 68 } 83 - 84 - pub fn play_track(&mut self, idx: usize) { 85 - self.current_idx = Some(idx); 86 - self.position = 0; 87 - self.status = PlaybackStatus::Playing; 88 - } 89 69 } 90 70 91 - fn mock_tracks() -> Vec<Track> { 92 - vec![ 93 - Track { 94 - id: 1, 95 - title: "Bohemian Rhapsody".into(), 96 - artist: "Queen".into(), 97 - album: "A Night at the Opera".into(), 98 - genre: "Rock".into(), 99 - duration: 354, 100 - track_number: 11, 101 - }, 102 - Track { 103 - id: 2, 104 - title: "Hotel California".into(), 105 - artist: "Eagles".into(), 106 - album: "Hotel California".into(), 107 - genre: "Rock".into(), 108 - duration: 391, 109 - track_number: 1, 110 - }, 111 - Track { 112 - id: 3, 113 - title: "Stairway to Heaven".into(), 114 - artist: "Led Zeppelin".into(), 115 - album: "Led Zeppelin IV".into(), 116 - genre: "Rock".into(), 117 - duration: 482, 118 - track_number: 4, 119 - }, 120 - Track { 121 - id: 4, 122 - title: "Blinding Lights".into(), 123 - artist: "The Weeknd".into(), 124 - album: "After Hours".into(), 125 - genre: "Pop".into(), 126 - duration: 200, 127 - track_number: 2, 128 - }, 129 - Track { 130 - id: 5, 131 - title: "Shape of You".into(), 132 - artist: "Ed Sheeran".into(), 133 - album: "Divide".into(), 134 - genre: "Pop".into(), 135 - duration: 234, 136 - track_number: 1, 137 - }, 138 - Track { 139 - id: 6, 140 - title: "Lose Yourself".into(), 141 - artist: "Eminem".into(), 142 - album: "8 Mile".into(), 143 - genre: "Hip-Hop".into(), 144 - duration: 326, 145 - track_number: 1, 146 - }, 147 - Track { 148 - id: 7, 149 - title: "One More Time".into(), 150 - artist: "Daft Punk".into(), 151 - album: "Discovery".into(), 152 - genre: "Electronic".into(), 153 - duration: 320, 154 - track_number: 1, 155 - }, 156 - Track { 157 - id: 8, 158 - title: "Get Lucky".into(), 159 - artist: "Daft Punk".into(), 160 - album: "Random Access Memories".into(), 161 - genre: "Electronic".into(), 162 - duration: 248, 163 - track_number: 8, 164 - }, 165 - Track { 166 - id: 9, 167 - title: "Comfortably Numb".into(), 168 - artist: "Pink Floyd".into(), 169 - album: "The Wall".into(), 170 - genre: "Rock".into(), 171 - duration: 382, 172 - track_number: 27, 173 - }, 174 - Track { 175 - id: 10, 176 - title: "Wish You Were Here".into(), 177 - artist: "Pink Floyd".into(), 178 - album: "Wish You Were Here".into(), 179 - genre: "Rock".into(), 180 - duration: 310, 181 - track_number: 1, 182 - }, 183 - Track { 184 - id: 11, 185 - title: "Superstition".into(), 186 - artist: "Stevie Wonder".into(), 187 - album: "Talking Book".into(), 188 - genre: "Soul".into(), 189 - duration: 245, 190 - track_number: 1, 191 - }, 192 - Track { 193 - id: 12, 194 - title: "Billie Jean".into(), 195 - artist: "Michael Jackson".into(), 196 - album: "Thriller".into(), 197 - genre: "Pop".into(), 198 - duration: 294, 199 - track_number: 6, 200 - }, 201 - ] 71 + pub enum StateUpdate { 72 + Status(PlaybackStatus), 73 + Position(u64), 74 + Playlist { 75 + queue: Vec<Track>, 76 + current_idx: Option<usize>, 77 + }, 78 + Tracks(Vec<Track>), 79 + ArtistImages(ArtistImages), 202 80 } 203 81 204 82 pub fn format_duration(secs: u64) -> String {
+4
gpui/src/ui/components/icons.rs
··· 149 149 pub enum Icons { 150 150 Music, 151 151 MusicList, 152 + Artist, 153 + Disc, 152 154 WinClose, 153 155 Play, 154 156 Pause, ··· 170 172 match self { 171 173 Icons::Music => "icons/music.svg", 172 174 Icons::MusicList => "icons/list-music.svg", 175 + Icons::Artist => "icons/artist.svg", 176 + Icons::Disc => "icons/disc.svg", 173 177 Icons::WinClose => "icons/window-close.svg", 174 178 Icons::Play => "icons/play.svg", 175 179 Icons::Pause => "icons/pause.svg",
+25 -17
gpui/src/ui/components/miniplayer.rs
··· 1 1 use crate::controller::Controller; 2 - use crate::state::{format_duration, AppState, PlaybackStatus}; 2 + use crate::state::{format_duration, PlaybackStatus}; 3 3 use crate::ui::components::icons::{Icon, Icons}; 4 4 use crate::ui::components::Page; 5 + use crate::ui::global_keybinds::play_pause; 5 6 use crate::ui::helpers::secs_to_slider; 6 7 use crate::ui::theme::Theme; 7 8 use gpui::{ 8 - div, px, relative, App, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, 9 - Render, StatefulInteractiveElement, Styled, Window, 9 + div, img, px, relative, App, Context, FontWeight, InteractiveElement, IntoElement, ObjectFit, 10 + ParentElement, Render, StatefulInteractiveElement, Styled, StyledImage, Window, 10 11 }; 11 12 12 13 pub struct MiniPlayer; ··· 27 28 .unwrap_or_default(); 28 29 29 30 let duration = state.current_track().map(|t| t.duration).unwrap_or(0); 31 + let album_art_url = state 32 + .current_track() 33 + .and_then(|t| t.album_art.as_deref()) 34 + .filter(|s| !s.is_empty()) 35 + .map(|id| format!("http://localhost:6062/covers/{id}")); 30 36 let position = state.position; 31 37 let fill = secs_to_slider(position, duration) / 100.0; 32 38 let vol_pct = (state.volume * 100.0) as u32; ··· 54 60 .flex() 55 61 .items_center() 56 62 .gap_x_3() 57 - .child( 63 + .child(if let Some(url) = album_art_url { 64 + div() 65 + .w(px(48.0)) 66 + .h(px(48.0)) 67 + .rounded_lg() 68 + .flex_shrink_0() 69 + .overflow_hidden() 70 + .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 71 + .into_any_element() 72 + } else { 58 73 div() 59 74 .w(px(48.0)) 60 75 .h(px(48.0)) ··· 65 80 .justify_center() 66 81 .bg(theme.library_art_bg) 67 82 .text_color(theme.player_icons_text) 68 - .child(Icon::new(Icons::Music).size_4()), 69 - ) 83 + .child(Icon::new(Icons::Music).size_4()) 84 + .into_any_element() 85 + }) 70 86 .child( 71 87 div() 72 88 .id("mini_info") ··· 125 141 .bg(theme.player_icons_bg_hover) 126 142 }) 127 143 .on_click(|_, _, cx: &mut App| { 128 - let state = cx.global::<Controller>().state.clone(); 129 - state.update(cx, |s: &mut AppState, _| s.prev()); 144 + cx.global::<Controller>().prev(); 130 145 }) 131 146 .child(Icon::new(Icons::Prev).size_4()), 132 147 ) ··· 143 158 .hover(|this| this.bg(theme.player_play_pause_hover)) 144 159 .text_color(theme.player_play_pause_text) 145 160 .on_click(|_, _, cx: &mut App| { 146 - let state = cx.global::<Controller>().state.clone(); 147 - state.update(cx, |s: &mut AppState, _| { 148 - match s.status { 149 - PlaybackStatus::Playing => s.pause(), 150 - _ => s.play(), 151 - } 152 - }); 161 + play_pause(cx); 153 162 }) 154 163 .child(if is_playing { 155 164 Icon::new(Icons::Pause).size_4() ··· 172 181 .bg(theme.player_icons_bg_hover) 173 182 }) 174 183 .on_click(|_, _, cx: &mut App| { 175 - let state = cx.global::<Controller>().state.clone(); 176 - state.update(cx, |s: &mut AppState, _| s.next()); 184 + cx.global::<Controller>().next(); 177 185 }) 178 186 .child(Icon::new(Icons::Next).size_4()), 179 187 ),
+439 -253
gpui/src/ui/components/pages/library.rs
··· 7 7 use crate::ui::theme::Theme; 8 8 use gpui::prelude::FluentBuilder; 9 9 use gpui::{ 10 - div, px, uniform_list, App, AppContext, Entity, FontWeight, InteractiveElement, IntoElement, 11 - MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, UniformListScrollHandle, 12 - Window, 10 + div, img, px, uniform_list, AnyElement, App, AppContext, Entity, FontWeight, 11 + InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Render, 12 + StatefulInteractiveElement, Styled, StyledImage, UniformListScrollHandle, Window, 13 13 }; 14 14 15 + const COVERS_BASE: &str = "http://localhost:6062/covers/"; 16 + 17 + /// Square art tile that fills its container (used in grids). `icon_size` is the size_N shorthand number. 18 + fn art_tile( 19 + art: Option<String>, 20 + theme: crate::ui::theme::Theme, 21 + fallback: Icons, 22 + icon_size: u8, 23 + ) -> AnyElement { 24 + let art_url = art 25 + .filter(|s| !s.is_empty()) 26 + .map(|id| format!("{COVERS_BASE}{id}")); 27 + let mut container = div().w_full().rounded_lg().overflow_hidden(); 28 + container.style().aspect_ratio = Some(1.0_f32); 29 + if let Some(url) = art_url { 30 + container 31 + .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 32 + .into_any_element() 33 + } else { 34 + container 35 + .bg(theme.library_art_bg) 36 + .flex() 37 + .items_center() 38 + .justify_center() 39 + .text_color(theme.player_icons_text) 40 + .child(icon_sized(fallback, icon_size)) 41 + .into_any_element() 42 + } 43 + } 44 + 45 + /// Fixed-size square art element (used in detail headers). 46 + fn art_fixed( 47 + art: Option<String>, 48 + theme: crate::ui::theme::Theme, 49 + fallback: Icons, 50 + size: gpui::Pixels, 51 + ) -> AnyElement { 52 + let art_url = art 53 + .filter(|s| !s.is_empty()) 54 + .map(|id| format!("{COVERS_BASE}{id}")); 55 + if let Some(url) = art_url { 56 + div() 57 + .w(size) 58 + .h(size) 59 + .rounded_lg() 60 + .flex_shrink_0() 61 + .overflow_hidden() 62 + .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 63 + .into_any_element() 64 + } else { 65 + div() 66 + .w(size) 67 + .h(size) 68 + .rounded_lg() 69 + .flex_shrink_0() 70 + .bg(theme.library_art_bg) 71 + .flex() 72 + .items_center() 73 + .justify_center() 74 + .text_color(theme.player_icons_text) 75 + .child(icon_sized(fallback, 10)) 76 + .into_any_element() 77 + } 78 + } 79 + 80 + fn icon_sized(icon: Icons, size: u8) -> Icon { 81 + use crate::ui::components::icons::Icon; 82 + let i = Icon::new(icon); 83 + match size { 84 + 4 => i.size_4(), 85 + 5 => i.size_5(), 86 + 6 => i.size_6(), 87 + 8 => i.size_8(), 88 + 10 => i.size_10(), 89 + 16 => i.size_16(), 90 + _ => i.size_8(), 91 + } 92 + } 93 + 15 94 pub struct LibraryPage { 16 95 scroll_handle: UniformListScrollHandle, 17 96 detail_scroll_handle: UniformListScrollHandle, ··· 48 127 let detail_album_cols = ((content_width / 180.0).floor() as u16).max(2); 49 128 50 129 // Pre-compute all section data in a single state borrow 51 - let (n_songs, albums, artists, current_idx, album_tracks, album_artist, artist_tracks, artist_albums_detail) = { 130 + let ( 131 + n_songs, 132 + albums, 133 + artists, 134 + current_idx, 135 + album_tracks, 136 + album_artist, 137 + album_detail_art, 138 + artist_tracks, 139 + artist_albums_detail, 140 + artist_detail_image, 141 + ) = { 52 142 let state = cx.global::<Controller>().state.read(cx); 53 143 54 - let current_idx = state.current_idx; 144 + let current_idx = state.current_library_idx(); 55 145 let n_songs = state.tracks.len(); 56 146 57 - let mut album_map: std::collections::BTreeMap<String, (String, usize)> = 147 + // (name, artist, count, album_art) 148 + let mut album_map: std::collections::BTreeMap<String, (String, usize, Option<String>)> = 58 149 Default::default(); 59 150 for track in &state.tracks { 60 - let e = album_map 61 - .entry(track.album.clone()) 62 - .or_insert((track.artist.clone(), 0)); 151 + let e = album_map.entry(track.album.clone()).or_insert(( 152 + track.artist.clone(), 153 + 0, 154 + track.album_art.clone(), 155 + )); 63 156 e.1 += 1; 64 157 } 65 - let albums: Vec<(String, String, usize)> = album_map 158 + let albums: Vec<(String, String, usize, Option<String>)> = album_map 66 159 .into_iter() 67 - .map(|(name, (artist, count))| (name, artist, count)) 160 + .map(|(name, (artist, count, art))| (name, artist, count, art)) 68 161 .collect(); 69 162 70 163 let mut artist_map: std::collections::BTreeMap<String, usize> = Default::default(); 71 164 for track in &state.tracks { 72 165 *artist_map.entry(track.artist.clone()).or_default() += 1; 73 166 } 74 - let artists: Vec<(String, usize)> = artist_map.into_iter().collect(); 167 + // (name, count, image) 168 + let artists: Vec<(String, usize, Option<String>)> = artist_map 169 + .into_iter() 170 + .map(|(name, count)| { 171 + let img = state.artist_images.get(&name).cloned(); 172 + (name, count, img) 173 + }) 174 + .collect(); 75 175 76 - // Album detail: tracks filtered by selected album 77 - let album_tracks: Vec<(usize, String, String, u64)> = state 176 + // Album detail: tracks filtered by selected album — (global_idx, path, title, num, dur) 177 + let mut album_tracks: Vec<(usize, String, String, String, u64)> = state 78 178 .tracks 79 179 .iter() 80 180 .enumerate() 81 181 .filter(|(_, t)| t.album == selected_album) 82 - .map(|(idx, t)| (idx, t.title.clone(), t.track_number.to_string(), t.duration)) 182 + .map(|(idx, t)| { 183 + ( 184 + idx, 185 + t.path.clone(), 186 + t.title.clone(), 187 + t.track_number.to_string(), 188 + t.duration, 189 + ) 190 + }) 83 191 .collect(); 192 + album_tracks.sort_by_key(|(_, _, _, num, _)| num.parse::<u32>().unwrap_or(0)); 84 193 85 - let album_artist = state 86 - .tracks 87 - .iter() 88 - .find(|t| t.album == selected_album) 194 + let album_first_track = state.tracks.iter().find(|t| t.album == selected_album); 195 + let album_artist = album_first_track 89 196 .map(|t| t.artist.clone()) 90 197 .unwrap_or_default(); 198 + let album_detail_art = album_first_track.and_then(|t| t.album_art.clone()); 91 199 92 - // Artist detail: tracks and albums filtered by selected artist 93 - let artist_tracks: Vec<(usize, String, String, u64)> = state 200 + // Artist detail: tracks filtered by selected artist — (global_idx, path, title, album, dur) 201 + let artist_tracks: Vec<(usize, String, String, String, u64)> = state 94 202 .tracks 95 203 .iter() 96 204 .enumerate() 97 205 .filter(|(_, t)| t.artist == selected_artist) 98 - .map(|(idx, t)| (idx, t.title.clone(), t.album.clone(), t.duration)) 206 + .map(|(idx, t)| { 207 + ( 208 + idx, 209 + t.path.clone(), 210 + t.title.clone(), 211 + t.album.clone(), 212 + t.duration, 213 + ) 214 + }) 99 215 .collect(); 100 216 101 - let mut artist_album_map: std::collections::BTreeMap<String, usize> = 217 + // (album_name, count, album_art) 218 + let mut artist_album_map: std::collections::BTreeMap<String, (usize, Option<String>)> = 102 219 Default::default(); 103 220 for track in &state.tracks { 104 221 if track.artist == selected_artist { 105 - *artist_album_map.entry(track.album.clone()).or_default() += 1; 222 + let e = artist_album_map 223 + .entry(track.album.clone()) 224 + .or_insert((0, track.album_art.clone())); 225 + e.0 += 1; 106 226 } 107 227 } 108 - let artist_albums_detail: Vec<(String, usize)> = 109 - artist_album_map.into_iter().collect(); 228 + let artist_albums_detail: Vec<(String, usize, Option<String>)> = artist_album_map 229 + .into_iter() 230 + .map(|(n, (c, a))| (n, c, a)) 231 + .collect(); 232 + 233 + let artist_detail_image = state.artist_images.get(&selected_artist).cloned(); 110 234 111 235 ( 112 236 n_songs, ··· 115 239 current_idx, 116 240 album_tracks, 117 241 album_artist, 242 + album_detail_art, 118 243 artist_tracks, 119 244 artist_albums_detail, 245 + artist_detail_image, 120 246 ) 121 247 }; 122 248 ··· 126 252 let _detail_scroll_handle = self.detail_scroll_handle.clone(); 127 253 128 254 // Sidebar nav item — Albums/Artists stay active while in their detail view 129 - let make_nav_item = move |label: &'static str, target: LibrarySection| { 255 + let make_nav_item = move |icon: Icons, label: &'static str, target: LibrarySection| { 130 256 let is_active = section == target 131 257 || (section == LibrarySection::AlbumDetail && target == LibrarySection::Albums) 132 258 || (section == LibrarySection::ArtistDetail && target == LibrarySection::Artists) ··· 157 283 .on_click(move |_, _, cx: &mut App| { 158 284 *cx.global_mut::<LibrarySection>() = target; 159 285 }) 160 - .child(label) 286 + .child( 287 + div() 288 + .flex() 289 + .items_center() 290 + .gap_x_2() 291 + .child(Icon::new(icon).size_5()) 292 + .child(label), 293 + ) 161 294 }; 162 295 163 296 // ── track row helper (shared between Songs, AlbumDetail, ArtistDetail) ────── 164 - let track_row = 165 - move |row_id: (&'static str, usize), 166 - global_idx: usize, 167 - num: String, 168 - title: String, 169 - secondary: String, // artist or album depending on context 170 - show_secondary: bool, 171 - duration: u64, 172 - is_current: bool| { 173 - div() 174 - .id(row_id) 175 - .w_full() 176 - .flex() 177 - .items_center() 178 - .px_6() 179 - .py_3() 180 - .cursor_pointer() 181 - .hover(|this| this.bg(theme.library_track_bg_hover)) 182 - .when(is_current, |this| { 183 - this.bg(theme.library_track_bg_active) 184 - .border_b_2() 185 - .border_color(theme.switcher_active) 186 - }) 187 - .on_click(move |_, _, cx: &mut App| { 188 - let state = cx.global::<Controller>().state.clone(); 189 - state.update(cx, |s: &mut crate::state::AppState, _| { 190 - s.play_track(global_idx) 191 - }); 192 - }) 193 - .child( 297 + let track_row = move |row_id: (&'static str, usize), 298 + path: String, 299 + num: String, 300 + title: String, 301 + artist: Option<String>, 302 + album: Option<String>, 303 + duration: u64, 304 + is_current: bool| { 305 + let show_artist = artist.is_some(); 306 + let show_album = album.is_some(); 307 + let artist_text = artist.unwrap_or_default(); 308 + let album_text = album.unwrap_or_default(); 309 + div() 310 + .id(row_id) 311 + .w_full() 312 + .flex() 313 + .items_center() 314 + .gap_x_4() 315 + .px_6() 316 + .py_3() 317 + .cursor_pointer() 318 + .hover(|this| this.bg(theme.library_track_bg_hover)) 319 + .when(is_current, |this| { 320 + this.bg(theme.library_track_bg_active) 321 + .border_b_2() 322 + .border_color(theme.switcher_active) 323 + }) 324 + .on_click(move |_, _, cx: &mut App| { 325 + let rt = cx.global::<Controller>().rt(); 326 + rt.spawn(crate::client::play_track(path.clone())); 327 + }) 328 + .child( 329 + div() 330 + .w(px(28.0)) 331 + .flex_shrink_0() 332 + .text_sm() 333 + .text_color(theme.library_header_text) 334 + .child(num), 335 + ) 336 + .child( 337 + div() 338 + .flex_1() 339 + .min_w_0() 340 + .text_sm() 341 + .truncate() 342 + .text_color(if is_current { 343 + theme.library_track_title_active 344 + } else { 345 + theme.library_text 346 + }) 347 + .font_weight(if is_current { 348 + FontWeight(600.0) 349 + } else { 350 + FontWeight(400.0) 351 + }) 352 + .child(title), 353 + ) 354 + .when(show_artist, move |this| { 355 + this.child( 194 356 div() 195 - .w(px(32.0)) 357 + .w_40() 358 + .flex_shrink_0() 196 359 .text_sm() 360 + .truncate() 197 361 .text_color(theme.library_header_text) 198 - .child(num), 362 + .child(artist_text), 199 363 ) 200 - .child( 364 + }) 365 + .when(show_album, move |this| { 366 + this.child( 201 367 div() 202 - .flex_1() 368 + .w_40() 369 + .flex_shrink_0() 203 370 .text_sm() 204 371 .truncate() 205 - .text_color(if is_current { 206 - theme.library_track_title_active 207 - } else { 208 - theme.library_text 209 - }) 210 - .font_weight(if is_current { 211 - FontWeight(600.0) 212 - } else { 213 - FontWeight(400.0) 214 - }) 215 - .child(title), 216 - ) 217 - .when(show_secondary, |this| { 218 - this.child( 219 - div() 220 - .w_48() 221 - .text_sm() 222 - .truncate() 223 - .text_color(theme.library_header_text) 224 - .child(secondary), 225 - ) 226 - }) 227 - .child( 228 - div() 229 - .w_16() 230 - .text_sm() 231 372 .text_color(theme.library_header_text) 232 - .child(format_duration(duration)), 373 + .child(album_text), 233 374 ) 234 - }; 375 + }) 376 + .child( 377 + div() 378 + .w(px(56.0)) 379 + .flex_shrink_0() 380 + .text_sm() 381 + .text_color(theme.library_header_text) 382 + .child(format_duration(duration)), 383 + ) 384 + }; 235 385 236 386 let content = match section { 237 387 // ── Songs ───────────────────────────────────────────────────────────── ··· 246 396 .flex_shrink_0() 247 397 .flex() 248 398 .items_center() 399 + .gap_x_4() 249 400 .px_6() 250 401 .py_4() 251 402 .border_b_1() 252 403 .border_color(theme.library_table_border) 253 404 .child( 254 405 div() 255 - .w(px(32.0)) 406 + .w(px(28.0)) 407 + .flex_shrink_0() 256 408 .text_xs() 257 409 .font_weight(FontWeight::MEDIUM) 258 410 .text_color(theme.library_header_text) ··· 261 413 .child( 262 414 div() 263 415 .flex_1() 416 + .min_w_0() 264 417 .text_xs() 265 418 .font_weight(FontWeight::MEDIUM) 266 419 .text_color(theme.library_header_text) ··· 268 421 ) 269 422 .child( 270 423 div() 271 - .w_48() 424 + .w_40() 425 + .flex_shrink_0() 272 426 .text_xs() 273 427 .font_weight(FontWeight::MEDIUM) 274 428 .text_color(theme.library_header_text) ··· 276 430 ) 277 431 .child( 278 432 div() 279 - .w_48() 433 + .w_40() 434 + .flex_shrink_0() 280 435 .text_xs() 281 436 .font_weight(FontWeight::MEDIUM) 282 437 .text_color(theme.library_header_text) ··· 284 439 ) 285 440 .child( 286 441 div() 287 - .w_16() 442 + .w(px(56.0)) 443 + .flex_shrink_0() 288 444 .text_xs() 289 445 .font_weight(FontWeight::MEDIUM) 290 446 .text_color(theme.library_header_text) ··· 293 449 ) 294 450 .child( 295 451 uniform_list("library_tracks", n_songs, move |range, _window, cx| { 296 - let _theme = *cx.global::<Theme>(); 297 452 let state = cx.global::<Controller>().state.read(cx); 298 - let current_idx = state.current_idx; 453 + let current_idx = state.current_library_idx(); 299 454 range 300 455 .map(|idx| { 301 456 let track = &state.tracks[idx]; 302 457 let is_current = current_idx == Some(idx); 303 458 track_row( 304 459 ("track_row", idx), 305 - idx, 306 - track.track_number.to_string(), 460 + track.path.clone(), 461 + (idx + 1).to_string(), 307 462 track.title.clone(), 308 - track.artist.clone(), 309 - true, 463 + Some(track.artist.clone()), 464 + Some(track.album.clone()), 310 465 track.duration, 311 466 is_current, 312 467 ) ··· 333 488 .grid_cols(album_cols) 334 489 .gap_6() 335 490 .children(albums.into_iter().enumerate().map( 336 - |(idx, (name, artist, _count))| { 491 + |(idx, (name, artist, _count, album_art))| { 337 492 let name_clone = name.clone(); 338 493 div() 339 494 .id(("album_card", idx)) ··· 350 505 *cx.global_mut::<LibrarySection>() = 351 506 LibrarySection::AlbumDetail; 352 507 }) 353 - .child({ 354 - let mut art = div() 355 - .w_full() 356 - .rounded_lg() 357 - .bg(theme.library_art_bg) 358 - .flex() 359 - .items_center() 360 - .justify_center() 361 - .text_color(theme.player_icons_text) 362 - .child(Icon::new(Icons::Music).size_8()); 363 - art.style().aspect_ratio = Some(1.0_f32); 364 - art 365 - }) 508 + .child(art_tile(album_art, theme, Icons::Music, 8)) 366 509 .child( 367 510 div() 368 511 .flex() ··· 402 545 .grid() 403 546 .grid_cols(artist_cols) 404 547 .gap_6() 405 - .children(artists.into_iter().enumerate().map(|(idx, (name, count))| { 406 - let name_clone = name.clone(); 407 - div() 408 - .id(("artist_card", idx)) 409 - .flex() 410 - .flex_col() 411 - .items_center() 412 - .gap_y_2() 413 - .cursor_pointer() 414 - .hover(|this| this.opacity(0.8)) 415 - .on_click(move |_, _, cx: &mut App| { 416 - *cx.global_mut::<SelectedArtist>() = 417 - SelectedArtist(name_clone.clone()); 418 - *cx.global_mut::<LibrarySection>() = 419 - LibrarySection::ArtistDetail; 420 - }) 421 - .child({ 422 - let mut avatar = div() 423 - .w_full() 424 - .rounded_full() 425 - .bg(theme.library_art_bg) 426 - .flex() 427 - .items_center() 428 - .justify_center() 429 - .text_color(theme.player_icons_text) 430 - .child(Icon::new(Icons::Music).size_8()); 431 - avatar.style().aspect_ratio = Some(1.0_f32); 432 - avatar 433 - }) 434 - .child( 435 - div() 436 - .flex() 437 - .flex_col() 438 - .items_center() 439 - .gap_y_0p5() 440 - .child( 441 - div() 442 - .text_sm() 443 - .font_weight(FontWeight(500.0)) 444 - .text_color(theme.library_text) 445 - .truncate() 446 - .child(name), 447 - ) 448 - .child( 449 - div() 450 - .text_xs() 451 - .text_color(theme.library_header_text) 452 - .child(format!("{count} tracks")), 453 - ), 454 - ) 455 - })), 548 + .children(artists.into_iter().enumerate().map( 549 + |(idx, (name, count, image))| { 550 + let name_clone = name.clone(); 551 + div() 552 + .id(("artist_card", idx)) 553 + .flex() 554 + .flex_col() 555 + .items_center() 556 + .gap_y_2() 557 + .cursor_pointer() 558 + .hover(|this| this.opacity(0.8)) 559 + .on_click(move |_, _, cx: &mut App| { 560 + *cx.global_mut::<SelectedArtist>() = 561 + SelectedArtist(name_clone.clone()); 562 + *cx.global_mut::<LibrarySection>() = 563 + LibrarySection::ArtistDetail; 564 + }) 565 + .child({ 566 + let img_url = image.filter(|s| !s.is_empty()).map(|s| { 567 + if s.starts_with("http") { 568 + s 569 + } else { 570 + format!("{COVERS_BASE}{s}") 571 + } 572 + }); 573 + let mut container = div() 574 + .w_full() 575 + .rounded_full() 576 + .overflow_hidden() 577 + .flex_shrink_0(); 578 + container.style().aspect_ratio = Some(1.0_f32); 579 + if let Some(url) = img_url { 580 + container.child( 581 + img(url) 582 + .w_full() 583 + .h_full() 584 + .rounded_full() 585 + .object_fit(ObjectFit::Cover), 586 + ) 587 + } else { 588 + container 589 + .bg(theme.library_art_bg) 590 + .flex() 591 + .items_center() 592 + .justify_center() 593 + .text_color(theme.player_icons_text) 594 + .child(Icon::new(Icons::Artist).size_8()) 595 + } 596 + }) 597 + .child( 598 + div() 599 + .w_full() 600 + .flex() 601 + .flex_col() 602 + .items_center() 603 + .gap_y_0p5() 604 + .child( 605 + div() 606 + .w_full() 607 + .text_sm() 608 + .font_weight(FontWeight(500.0)) 609 + .text_color(theme.library_text) 610 + .text_center() 611 + .truncate() 612 + .child(name), 613 + ) 614 + .child( 615 + div() 616 + .text_xs() 617 + .text_color(theme.library_header_text) 618 + .child(format!("{count} tracks")), 619 + ), 620 + ) 621 + }, 622 + )), 456 623 ) 457 624 .into_any_element(), 458 625 ··· 509 676 .flex() 510 677 .items_center() 511 678 .gap_x_6() 512 - .child( 513 - div() 514 - .w(px(128.0)) 515 - .h(px(128.0)) 516 - .rounded_lg() 517 - .flex_shrink_0() 518 - .bg(theme.library_art_bg) 519 - .flex() 520 - .items_center() 521 - .justify_center() 522 - .text_color(theme.player_icons_text) 523 - .child(Icon::new(Icons::Music).size_10()), 524 - ) 679 + .child(art_fixed( 680 + album_detail_art, 681 + theme, 682 + Icons::Music, 683 + px(128.0), 684 + )) 525 685 .child( 526 686 div() 527 687 .flex() ··· 555 715 .w_full() 556 716 .flex() 557 717 .items_center() 718 + .gap_x_4() 558 719 .px_6() 559 720 .py_3() 560 721 .border_b_1() 561 722 .border_color(theme.library_table_border) 562 723 .child( 563 724 div() 564 - .w(px(32.0)) 725 + .w(px(28.0)) 726 + .flex_shrink_0() 565 727 .text_xs() 566 728 .font_weight(FontWeight::MEDIUM) 567 729 .text_color(theme.library_header_text) ··· 570 732 .child( 571 733 div() 572 734 .flex_1() 735 + .min_w_0() 573 736 .text_xs() 574 737 .font_weight(FontWeight::MEDIUM) 575 738 .text_color(theme.library_header_text) ··· 577 740 ) 578 741 .child( 579 742 div() 580 - .w_16() 743 + .w(px(56.0)) 744 + .flex_shrink_0() 581 745 .text_xs() 582 746 .font_weight(FontWeight::MEDIUM) 583 747 .text_color(theme.library_header_text) ··· 586 750 ) 587 751 // Track rows 588 752 .children(album_tracks.into_iter().enumerate().map( 589 - |(i, (global_idx, title, num, duration))| { 753 + |(i, (global_idx, path, title, num, duration))| { 590 754 let is_current = current_idx == Some(global_idx); 591 755 track_row( 592 756 ("album_detail_row", i), 593 - global_idx, 757 + path, 594 758 num, 595 759 title, 596 - String::new(), 597 - false, 760 + None, 761 + None, 598 762 duration, 599 763 is_current, 600 764 ) ··· 653 817 .flex() 654 818 .items_center() 655 819 .gap_x_6() 656 - .child( 657 - div() 658 - .w(px(96.0)) 659 - .h(px(96.0)) 660 - .rounded_full() 661 - .flex_shrink_0() 662 - .bg(theme.library_art_bg) 663 - .flex() 664 - .items_center() 665 - .justify_center() 666 - .text_color(theme.player_icons_text) 667 - .child(Icon::new(Icons::Music).size_8()), 668 - ) 820 + .child({ 821 + let img_url = artist_detail_image 822 + .filter(|s| !s.is_empty()) 823 + .map(|s| { 824 + if s.starts_with("http") { 825 + s 826 + } else { 827 + format!("{COVERS_BASE}{s}") 828 + } 829 + }); 830 + if let Some(url) = img_url { 831 + div() 832 + .w(px(96.0)) 833 + .h(px(96.0)) 834 + .rounded_full() 835 + .flex_shrink_0() 836 + .overflow_hidden() 837 + .child( 838 + img(url) 839 + .w_full() 840 + .h_full() 841 + .rounded_full() 842 + .object_fit(ObjectFit::Cover), 843 + ) 844 + .into_any_element() 845 + } else { 846 + div() 847 + .w(px(96.0)) 848 + .h(px(96.0)) 849 + .rounded_full() 850 + .flex_shrink_0() 851 + .bg(theme.library_art_bg) 852 + .flex() 853 + .items_center() 854 + .justify_center() 855 + .text_color(theme.player_icons_text) 856 + .child(Icon::new(Icons::Artist).size_8()) 857 + .into_any_element() 858 + } 859 + }) 669 860 .child( 670 861 div() 671 862 .flex() ··· 705 896 .grid() 706 897 .grid_cols(detail_album_cols) 707 898 .gap_4() 708 - .children( 709 - artist_albums_detail.into_iter().enumerate().map( 710 - |(idx, (album_name, _count))| { 711 - let album_name_clone = album_name.clone(); 712 - let sa = sa_clone.clone(); 713 - div() 714 - .id(("artist_album_card", idx)) 715 - .flex() 716 - .flex_col() 717 - .gap_y_2() 718 - .cursor_pointer() 719 - .hover(|this| this.opacity(0.8)) 720 - .on_click(move |_, _, cx: &mut App| { 721 - *cx.global_mut::<SelectedAlbum>() = 722 - SelectedAlbum(album_name_clone.clone()); 723 - *cx.global_mut::<SelectedArtist>() = 724 - SelectedArtist(sa.clone()); 725 - *cx.global_mut::<BackSection>() = 726 - BackSection(LibrarySection::ArtistDetail); 727 - *cx.global_mut::<LibrarySection>() = 728 - LibrarySection::AlbumDetail; 729 - }) 730 - .child({ 731 - let mut art = div() 732 - .w_full() 733 - .rounded_lg() 734 - .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::Music).size_6()); 740 - art.style().aspect_ratio = Some(1.0_f32); 741 - art 742 - }) 743 - .child( 744 - div() 745 - .text_xs() 746 - .font_weight(FontWeight(500.0)) 747 - .text_color(theme.library_text) 748 - .truncate() 749 - .child(album_name), 750 - ) 751 - }, 752 - ), 753 - ), 899 + .children(artist_albums_detail.into_iter().enumerate().map( 900 + |(idx, (album_name, _count, album_art))| { 901 + let album_name_clone = album_name.clone(); 902 + let sa = sa_clone.clone(); 903 + div() 904 + .id(("artist_album_card", idx)) 905 + .flex() 906 + .flex_col() 907 + .gap_y_2() 908 + .cursor_pointer() 909 + .hover(|this| this.opacity(0.8)) 910 + .on_click(move |_, _, cx: &mut App| { 911 + *cx.global_mut::<SelectedAlbum>() = 912 + SelectedAlbum(album_name_clone.clone()); 913 + *cx.global_mut::<SelectedArtist>() = 914 + SelectedArtist(sa.clone()); 915 + *cx.global_mut::<BackSection>() = 916 + BackSection(LibrarySection::ArtistDetail); 917 + *cx.global_mut::<LibrarySection>() = 918 + LibrarySection::AlbumDetail; 919 + }) 920 + .child(art_tile(album_art, theme, Icons::Music, 6)) 921 + .child( 922 + div() 923 + .text_xs() 924 + .font_weight(FontWeight(500.0)) 925 + .text_color(theme.library_text) 926 + .truncate() 927 + .child(album_name), 928 + ) 929 + }, 930 + )), 754 931 ) 755 932 // Songs section header 756 933 .child( ··· 767 944 .w_full() 768 945 .flex() 769 946 .items_center() 947 + .gap_x_4() 770 948 .px_6() 771 949 .py_3() 772 950 .border_b_1() 773 951 .border_color(theme.library_table_border) 774 952 .child( 775 953 div() 776 - .w(px(32.0)) 954 + .w(px(28.0)) 955 + .flex_shrink_0() 777 956 .text_xs() 778 957 .font_weight(FontWeight::MEDIUM) 779 958 .text_color(theme.library_header_text) ··· 782 961 .child( 783 962 div() 784 963 .flex_1() 964 + .min_w_0() 785 965 .text_xs() 786 966 .font_weight(FontWeight::MEDIUM) 787 967 .text_color(theme.library_header_text) ··· 789 969 ) 790 970 .child( 791 971 div() 792 - .w_48() 972 + .w_40() 973 + .flex_shrink_0() 793 974 .text_xs() 794 975 .font_weight(FontWeight::MEDIUM) 795 976 .text_color(theme.library_header_text) ··· 797 978 ) 798 979 .child( 799 980 div() 800 - .w_16() 981 + .w(px(56.0)) 982 + .flex_shrink_0() 801 983 .text_xs() 802 984 .font_weight(FontWeight::MEDIUM) 803 985 .text_color(theme.library_header_text) ··· 806 988 ) 807 989 // Artist track rows 808 990 .children(artist_tracks.into_iter().enumerate().map( 809 - |(i, (global_idx, title, album, duration))| { 991 + |(i, (global_idx, path, title, album, duration))| { 810 992 let is_current = current_idx == Some(global_idx); 811 993 track_row( 812 994 ("artist_detail_row", i), 813 - global_idx, 995 + path, 814 996 format!("{}", i + 1), 815 997 title, 816 - album, 817 - true, 998 + None, 999 + Some(album), 818 1000 duration, 819 1001 is_current, 820 1002 ) ··· 851 1033 .pt_4() 852 1034 .child(self.search_input.clone()) 853 1035 .gap_y_1() 854 - .child(make_nav_item("Songs", LibrarySection::Songs)) 855 - .child(make_nav_item("Albums", LibrarySection::Albums)) 856 - .child(make_nav_item("Artists", LibrarySection::Artists)), 1036 + .child(make_nav_item(Icons::Music, "Songs", LibrarySection::Songs)) 1037 + .child(make_nav_item(Icons::Disc, "Albums", LibrarySection::Albums)) 1038 + .child(make_nav_item( 1039 + Icons::Artist, 1040 + "Artists", 1041 + LibrarySection::Artists, 1042 + )), 857 1043 ) 858 1044 .child(content), 859 1045 )
+42 -31
gpui/src/ui/components/pages/player.rs
··· 2 2 use crate::state::PlaybackStatus; 3 3 use crate::ui::components::controlbar::ControlBar; 4 4 use crate::ui::components::icons::{Icon, Icons}; 5 + use crate::ui::global_keybinds::play_pause; 5 6 use crate::ui::theme::Theme; 6 7 use gpui::prelude::FluentBuilder; 7 8 use gpui::px; 8 9 use gpui::{ 9 - div, App, Context, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, Render, 10 - StatefulInteractiveElement, Styled, Window, 10 + div, img, App, Context, Entity, FontWeight, InteractiveElement, IntoElement, ObjectFit, 11 + ParentElement, Render, StatefulInteractiveElement, Styled, StyledImage, Window, 11 12 }; 12 13 13 14 pub struct PlayerPage { ··· 40 41 .current_track() 41 42 .map(|t| t.album.clone()) 42 43 .unwrap_or_default(); 44 + let album_art_url = state 45 + .current_track() 46 + .and_then(|t| t.album_art.as_deref()) 47 + .filter(|s| !s.is_empty()) 48 + .map(|id| format!("http://localhost:6062/covers/{id}")); 49 + let queue_total = state.queue.len(); 50 + let queue_pos = state.current_idx.map(|i| i + 1); 43 51 44 52 div() 45 53 .size_full() ··· 56 64 .gap_y_6() 57 65 .px_16() 58 66 .pt_8() 59 - // Album art placeholder 60 - .child( 67 + .child(if let Some(url) = album_art_url { 68 + div() 69 + .w(px(360.0)) 70 + .h(px(360.0)) 71 + .rounded_xl() 72 + .overflow_hidden() 73 + .flex_shrink_0() 74 + .child(img(url).w_full().h_full().object_fit(ObjectFit::Cover)) 75 + .into_any_element() 76 + } else { 61 77 div() 62 - .w(px(192.0)) 63 - .h(px(192.0)) 78 + .w(px(360.0)) 79 + .h(px(360.0)) 64 80 .rounded_xl() 65 81 .border_2() 66 82 .border_color(theme.border) ··· 69 85 .justify_center() 70 86 .bg(theme.library_art_bg) 71 87 .text_color(theme.player_icons_text) 72 - .child(Icon::new(Icons::Music).size_16()), 73 - ) 74 - // Track info 88 + .child(Icon::new(Icons::Music).size_16()) 89 + .into_any_element() 90 + }) 75 91 .child( 76 92 div() 77 93 .flex() ··· 102 118 .max_w_96() 103 119 .truncate() 104 120 .child(album), 105 - ), 121 + ) 122 + .child(div().text_xs().text_color(theme.player_icons_text).child( 123 + if let Some(pos) = queue_pos { 124 + format!("{pos} / {queue_total}") 125 + } else { 126 + String::new() 127 + }, 128 + )), 106 129 ) 107 - // Transport controls 108 130 .child( 109 131 div() 110 132 .flex() ··· 130 152 }) 131 153 .on_click(|_, _, cx: &mut App| { 132 154 let state = cx.global::<Controller>().state.clone(); 133 - state.update(cx, |s: &mut crate::state::AppState, _| { 134 - s.toggle_shuffle() 155 + state.update(cx, |s, cx| { 156 + s.toggle_shuffle(); 157 + cx.notify(); 135 158 }); 136 159 }) 137 160 .child(Icon::new(Icons::Shuffle).size_4()), ··· 151 174 .text_color(theme.player_icons_text_hover) 152 175 }) 153 176 .on_click(|_, _, cx: &mut App| { 154 - let state = cx.global::<Controller>().state.clone(); 155 - state.update(cx, |s: &mut crate::state::AppState, _| { 156 - s.prev() 157 - }); 177 + cx.global::<Controller>().prev(); 158 178 }) 159 179 .child(Icon::new(Icons::Prev).size_4()), 160 180 ) ··· 171 191 .hover(|this| this.bg(theme.player_play_pause_hover)) 172 192 .text_color(theme.player_play_pause_text) 173 193 .on_click(|_, _, cx: &mut App| { 174 - let state = cx.global::<Controller>().state.clone(); 175 - state.update( 176 - cx, 177 - |s: &mut crate::state::AppState, _| match s.status { 178 - PlaybackStatus::Playing => s.pause(), 179 - _ => s.play(), 180 - }, 181 - ); 194 + play_pause(cx); 182 195 }) 183 196 .child(if is_playing { 184 197 Icon::new(Icons::Pause).size_5() ··· 201 214 .text_color(theme.player_icons_text_hover) 202 215 }) 203 216 .on_click(|_, _, cx: &mut App| { 204 - let state = cx.global::<Controller>().state.clone(); 205 - state.update(cx, |s: &mut crate::state::AppState, _| { 206 - s.next() 207 - }); 217 + cx.global::<Controller>().next(); 208 218 }) 209 219 .child(Icon::new(Icons::Next).size_4()), 210 220 ) ··· 228 238 }) 229 239 .on_click(|_, _, cx: &mut App| { 230 240 let state = cx.global::<Controller>().state.clone(); 231 - state.update(cx, |s: &mut crate::state::AppState, _| { 232 - s.toggle_repeat() 241 + state.update(cx, |s, cx| { 242 + s.toggle_repeat(); 243 + cx.notify(); 233 244 }); 234 245 }) 235 246 .child(Icon::new(Icons::Repeat).size_4()),
+32 -15
gpui/src/ui/components/pages/queue.rs
··· 5 5 use gpui::prelude::FluentBuilder; 6 6 use gpui::{ 7 7 div, px, uniform_list, App, AppContext, Entity, FontWeight, InteractiveElement, IntoElement, 8 - ParentElement, Render, StatefulInteractiveElement, Styled, UniformListScrollHandle, Window, 8 + ParentElement, Render, ScrollStrategy, StatefulInteractiveElement, Styled, 9 + UniformListScrollHandle, Window, 9 10 }; 10 11 11 12 pub struct QueuePage { 12 13 scroll_handle: UniformListScrollHandle, 13 14 miniplayer: Entity<MiniPlayer>, 15 + last_scrolled_idx: Option<usize>, 14 16 } 15 17 16 18 impl QueuePage { ··· 18 20 QueuePage { 19 21 scroll_handle: UniformListScrollHandle::new(), 20 22 miniplayer: cx.new(|_| MiniPlayer), 23 + last_scrolled_idx: None, 21 24 } 22 25 } 23 26 } ··· 27 30 let theme = *cx.global::<Theme>(); 28 31 let state = cx.global::<Controller>().state.read(cx); 29 32 let n = state.queue.len(); 33 + let current_idx = state.current_idx; 34 + let position_label = current_idx 35 + .map(|i| format!("{} / {}", i + 1, n)) 36 + .unwrap_or_else(|| format!("{n} tracks")); 30 37 let scroll_handle = self.scroll_handle.clone(); 31 38 39 + // Scroll to current track whenever it changes (covers page-open and track changes). 40 + if current_idx != self.last_scrolled_idx { 41 + if let Some(idx) = current_idx { 42 + self.scroll_handle 43 + .scroll_to_item(idx, ScrollStrategy::Center); 44 + } 45 + self.last_scrolled_idx = current_idx; 46 + } 47 + 32 48 div() 33 49 .size_full() 34 50 .flex() ··· 56 72 .ml_auto() 57 73 .text_sm() 58 74 .text_color(theme.queue_item_artist) 59 - .child(format!("{n} tracks")), 75 + .child(position_label), 60 76 ), 61 77 ) 62 78 .child( 63 79 uniform_list("queue_list", n, move |range, _window, cx| { 64 80 let theme = *cx.global::<Theme>(); 65 81 let state = cx.global::<Controller>().state.read(cx); 66 - let current_idx = state.current_idx; 82 + let ctrl = cx.global::<Controller>(); 67 83 68 84 range 69 85 .map(|pos| { 70 - let track_idx = state.queue[pos]; 71 - let track = &state.tracks[track_idx]; 72 - let is_current = current_idx == Some(track_idx); 86 + let track = &state.queue[pos]; 87 + let is_current = current_idx == Some(pos); 88 + let title = track.title.clone(); 89 + let artist = track.artist.clone(); 90 + let duration = track.duration; 91 + let rt = ctrl.rt(); 73 92 74 93 div() 75 94 .id(("queue_row", pos)) ··· 87 106 .border_b_2() 88 107 .border_color(theme.switcher_active) 89 108 }) 90 - .on_click(move |_, _, cx: &mut App| { 91 - let state = cx.global::<Controller>().state.clone(); 92 - state.update(cx, |s: &mut crate::state::AppState, _| { 93 - s.play_track(track_idx) 94 - }); 109 + .on_click(move |_, _, _cx: &mut App| { 110 + rt.spawn(crate::client::jump_to_queue_position(pos as i32)); 95 111 }) 96 112 .child( 97 113 div() 98 - .w(px(20.0)) 114 + .w(px(32.0)) 115 + .flex_shrink_0() 99 116 .text_xs() 100 117 .text_color(theme.queue_item_artist) 101 118 .child(format!("{}", pos + 1)), ··· 120 137 } else { 121 138 FontWeight(400.0) 122 139 }) 123 - .child(track.title.clone()), 140 + .child(title), 124 141 ) 125 142 .child( 126 143 div() 127 144 .text_xs() 128 145 .truncate() 129 146 .text_color(theme.queue_item_artist) 130 - .child(track.artist.clone()), 147 + .child(artist), 131 148 ), 132 149 ) 133 150 .child( 134 151 div() 135 152 .text_xs() 136 153 .text_color(theme.queue_item_artist) 137 - .child(format_duration(track.duration)), 154 + .child(format_duration(duration)), 138 155 ) 139 156 }) 140 157 .collect()
+37 -13
gpui/src/ui/global_keybinds.rs
··· 1 1 use crate::controller::Controller; 2 - use crate::state::PlaybackStatus; 2 + use crate::state::{AppState, PlaybackStatus}; 3 3 use crate::ui::components::Page; 4 - use gpui::{actions, App, KeyBinding}; 4 + use gpui::{actions, App, Entity, KeyBinding}; 5 5 6 6 actions!(player, [PlayPause, Next, Prev, Shuffle, Repeat]); 7 7 actions!(pages, [CycleNext, CyclePrev, Library, Player, Queue]); ··· 25 25 ]); 26 26 27 27 cx.on_action(|_: &PlayPause, cx| { 28 - let state = cx.global::<Controller>().state.clone(); 29 - state.update(cx, |s, _| match s.status { 30 - PlaybackStatus::Playing => s.pause(), 31 - _ => s.play(), 32 - }); 28 + play_pause(cx); 33 29 }); 34 30 35 31 cx.on_action(|_: &Next, cx| { 36 - let state = cx.global::<Controller>().state.clone(); 37 - state.update(cx, |s, _| s.next()); 32 + cx.global::<Controller>().next(); 38 33 }); 39 34 40 35 cx.on_action(|_: &Prev, cx| { 41 - let state = cx.global::<Controller>().state.clone(); 42 - state.update(cx, |s, _| s.prev()); 36 + cx.global::<Controller>().prev(); 43 37 }); 44 38 45 39 cx.on_action(|_: &Shuffle, cx| { 46 40 let state = cx.global::<Controller>().state.clone(); 47 - state.update(cx, |s, _| s.toggle_shuffle()); 41 + state.update(cx, |s, cx| { 42 + s.toggle_shuffle(); 43 + cx.notify(); 44 + }); 48 45 }); 49 46 50 47 cx.on_action(|_: &Repeat, cx| { 51 48 let state = cx.global::<Controller>().state.clone(); 52 - state.update(cx, |s, _| s.toggle_repeat()); 49 + state.update(cx, |s, cx| { 50 + s.toggle_repeat(); 51 + cx.notify(); 52 + }); 53 53 }); 54 54 55 55 cx.on_action(|_: &Quit, cx| cx.quit()); ··· 76 76 }; 77 77 }); 78 78 } 79 + 80 + pub fn play_pause(cx: &mut App) { 81 + let (rt, state): (tokio::runtime::Handle, Entity<AppState>) = { 82 + let ctrl = cx.global::<Controller>(); 83 + (ctrl.rt(), ctrl.state.clone()) 84 + }; 85 + let status = state.read(cx).status; 86 + match status { 87 + PlaybackStatus::Playing => { 88 + rt.spawn(crate::client::pause()); 89 + state.update(cx, |s, cx| { 90 + s.status = PlaybackStatus::Paused; 91 + cx.notify(); 92 + }); 93 + } 94 + _ => { 95 + rt.spawn(crate::client::resume()); 96 + state.update(cx, |s, cx| { 97 + s.status = PlaybackStatus::Playing; 98 + cx.notify(); 99 + }); 100 + } 101 + } 102 + }
+15 -3
gpui/src/ui/rockbox.rs
··· 49 49 _ => 0.0, 50 50 }; 51 51 let page_el = match page { 52 - Page::Player => div().w_full().h_full().min_h_0().child(self.player_page.clone()), 53 - Page::Library => div().w_full().h_full().min_h_0().child(self.library_page.clone()), 54 - Page::Queue => div().w_full().h_full().min_h_0().child(self.queue_page.clone()), 52 + Page::Player => div() 53 + .w_full() 54 + .h_full() 55 + .min_h_0() 56 + .child(self.player_page.clone()), 57 + Page::Library => div() 58 + .w_full() 59 + .h_full() 60 + .min_h_0() 61 + .child(self.library_page.clone()), 62 + Page::Queue => div() 63 + .w_full() 64 + .h_full() 65 + .min_h_0() 66 + .child(self.queue_page.clone()), 55 67 }; 56 68 div() 57 69 .id("root")