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.

Fetch initial playback status and fix Now Playing

Call the status RPC once at startup because the server's status stream
only emits on changes, so the UI starts with the correct play/pause
state.

Do not touch MPNowPlayingInfoCenter while no track is present to avoid
macOS deregistering the app. When the track or cover changes, update
metadata first and then playback progress/state so elapsed time is
merged into the fresh nowPlayingInfo.

+65 -31
+22 -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, StreamLibraryRequest, StreamPlaylistRequest, StreamStatusRequest, 14 - TreeGetEntriesRequest, UnlikeTrackRequest, 13 + StatusRequest, StreamCurrentTrackRequest, StreamLibraryRequest, StreamPlaylistRequest, 14 + StreamStatusRequest, TreeGetEntriesRequest, UnlikeTrackRequest, 15 15 }; 16 16 use crate::state::{SearchAlbum, SearchArtist, SearchResults}; 17 17 ··· 326 326 Err(e) => log::warn!("resume info sync: {e}"), 327 327 }, 328 328 Err(e) => log::warn!("resume info sync connect: {e}"), 329 + } 330 + 331 + // The status stream only fires on changes; fetch the initial status once so 332 + // the Now Playing widget shows correctly when the app opens with a paused track. 333 + match PlaybackServiceClient::connect(URL).await { 334 + Ok(mut c) => match c.status(StatusRequest {}).await { 335 + Ok(resp) => { 336 + let s = resp.into_inner(); 337 + let init_status = if s.status & 0x02 != 0 { 338 + PlaybackStatus::Paused 339 + } else if s.status & 0x01 != 0 { 340 + PlaybackStatus::Playing 341 + } else { 342 + PlaybackStatus::Stopped 343 + }; 344 + let _ = tx.send(StateUpdate::Status(init_status)).await; 345 + } 346 + Err(e) => log::warn!("initial status fetch: {e}"), 347 + }, 348 + Err(e) => log::warn!("initial status connect: {e}"), 329 349 } 330 350 } 331 351
+43 -29
gpui/src/now_playing.rs
··· 80 80 81 81 /// Push the current playback state and track metadata to the OS. 82 82 pub fn update(&mut self, track: Option<&Track>, status: PlaybackStatus, position: u64) { 83 - let progress = Some(MediaPosition(Duration::from_secs(position))); 84 - 85 - let playback = match status { 86 - PlaybackStatus::Playing => MediaPlayback::Playing { progress }, 87 - PlaybackStatus::Paused => MediaPlayback::Paused { progress }, 88 - PlaybackStatus::Stopped => MediaPlayback::Stopped, 89 - }; 90 - let _ = self.controls.set_playback(playback); 91 - 92 83 let track_id = track.map(|t| t.id.as_str()).unwrap_or(""); 93 84 // album_art is a bare filename served by rockboxd's cover HTTP server. 94 85 let cover_url = track ··· 96 87 .filter(|s| !s.is_empty()) 97 88 .map(|name| format!("http://localhost:6062/covers/{}", name)); 98 89 99 - // Re-send metadata when either the track or the cover art changes. 100 - // Cover art can arrive in a later poll tick after the track id was set. 90 + // Don't touch MPNowPlayingInfoCenter until we have (or previously had) a track. 91 + // Calling set_playback(Stopped) every 100ms during the startup window before 92 + // any queue data arrives causes macOS to deregister the app from Now Playing, 93 + // and a subsequent set_playback(Paused/Playing) then fails to re-show the widget. 94 + let had_track = !self.last_track_id.is_empty(); 95 + let has_track = !track_id.is_empty(); 96 + if !had_track && !has_track { 97 + return; 98 + } 99 + 101 100 let track_changed = track_id != self.last_track_id; 102 101 let cover_changed = cover_url != self.last_cover_url; 103 - if !track_changed && !cover_changed { 104 - return; 102 + 103 + // souvlaki's macOS set_playback_metadata() replaces nowPlayingInfo with a fresh 104 + // dict (no elapsed time). set_playback_progress() reads the existing dict and 105 + // merges elapsed time into it. Calling set_metadata BEFORE set_playback in the 106 + // same tick ensures the progress merge sees the fresh metadata dict, so the 107 + // final nowPlayingInfo always contains both metadata and elapsed time. 108 + if track_changed || cover_changed { 109 + self.last_track_id = track_id.to_string(); 110 + self.last_cover_url = cover_url.clone(); 111 + 112 + if has_track { 113 + let meta = track 114 + .map(|t| MediaMetadata { 115 + title: if t.title.is_empty() { None } else { Some(t.title.as_str()) }, 116 + artist: if t.artist.is_empty() { None } else { Some(t.artist.as_str()) }, 117 + album: if t.album.is_empty() { None } else { Some(t.album.as_str()) }, 118 + cover_url: cover_url.as_deref(), 119 + duration: if t.duration > 0 { 120 + Some(Duration::from_secs(t.duration)) 121 + } else { 122 + None 123 + }, 124 + }) 125 + .unwrap_or_default(); 126 + let _ = self.controls.set_metadata(meta); 127 + } 105 128 } 106 - self.last_track_id = track_id.to_string(); 107 - self.last_cover_url = cover_url.clone(); 108 129 109 - let meta = track 110 - .map(|t| MediaMetadata { 111 - title: if t.title.is_empty() { None } else { Some(t.title.as_str()) }, 112 - artist: if t.artist.is_empty() { None } else { Some(t.artist.as_str()) }, 113 - album: if t.album.is_empty() { None } else { Some(t.album.as_str()) }, 114 - cover_url: cover_url.as_deref(), 115 - duration: if t.duration > 0 { 116 - Some(Duration::from_secs(t.duration)) 117 - } else { 118 - None 119 - }, 120 - }) 121 - .unwrap_or_default(); 122 - let _ = self.controls.set_metadata(meta); 130 + let progress = Some(MediaPosition(Duration::from_secs(position))); 131 + let playback = match status { 132 + PlaybackStatus::Playing => MediaPlayback::Playing { progress }, 133 + PlaybackStatus::Paused => MediaPlayback::Paused { progress }, 134 + PlaybackStatus::Stopped => MediaPlayback::Stopped, 135 + }; 136 + let _ = self.controls.set_playback(playback); 123 137 } 124 138 }