From b3d12e6b2c680b9171877b78b8ed4e655a489c52 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:11:43 -0600 Subject: [PATCH] fix(playback): give the now-playing "source" button a per-track context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "playing from" button read a single global context set once at play time; refresh_now_playing_meta never republished it, so it went stale across track changes (previous track's album) and single-track plays fell back to the current track's album instead of the playlist it came from. Add per-track context_kind/context_id to QueueTrack (serde-default None, back-compat), stamp the real container at every play path (album/artist/playlist/label), and republish the context from the current track on EVERY track change (gapless included) — never cached. open-context routes all stamped kinds. cc --- crates/qbz-mixtape/src/enqueue.rs | 12 +++ crates/qbz-mixtape/src/shuffle.rs | 2 + crates/qbz-models/src/playback.rs | 13 +++ crates/qbz-models/src/source.rs | 2 + crates/qbz-player/src/queue.rs | 2 + crates/qbz/src/local_playlist.rs | 8 +- crates/qbz/src/offline_favorites.rs | 2 + crates/qbz/src/playback.rs | 140 ++++++++++++++++++++++------ crates/qbz/src/queue.rs | 2 + crates/qbz/src/session_persist.rs | 5 + crates/qconnect-app/src/renderer.rs | 2 + 11 files changed, 157 insertions(+), 33 deletions(-) diff --git a/crates/qbz-mixtape/src/enqueue.rs b/crates/qbz-mixtape/src/enqueue.rs index be57daee2..792d0c8b1 100644 --- a/crates/qbz-mixtape/src/enqueue.rs +++ b/crates/qbz-mixtape/src/enqueue.rs @@ -280,6 +280,8 @@ pub async fn resolve_qobuz_album( parental_warning: track.parental_warning, // Stamped centrally by resolve_collection_tracks; left None here. source_item_id_hint: None, + context_kind: None, + context_id: None, } }) .collect(); @@ -464,6 +466,8 @@ pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { source: Some("qobuz".to_string()), parental_warning: track.parental_warning, source_item_id_hint: None, + context_kind: None, + context_id: None, } } @@ -498,6 +502,8 @@ pub fn plex_cached_track_to_queue_track(track: &qbz_plex::PlexCachedTrack) -> Co source: Some("plex".to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } @@ -541,6 +547,8 @@ pub fn local_track_to_queue_track(track: &qbz_library::LocalTrack) -> CoreQueueT source: Some("local".to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } @@ -585,6 +593,8 @@ mod tests { }), parental_warning: false, source_item_id_hint: None, // stamped by resolve_collection_tracks + context_kind: None, + context_id: None, }) .collect()) } @@ -697,6 +707,8 @@ mod tests { source: Some("qobuz".into()), parental_warning: false, source_item_id_hint: item.map(String::from), + context_kind: None, + context_id: None, } } diff --git a/crates/qbz-mixtape/src/shuffle.rs b/crates/qbz-mixtape/src/shuffle.rs index 9ee26b4c5..b109bc415 100644 --- a/crates/qbz-mixtape/src/shuffle.rs +++ b/crates/qbz-mixtape/src/shuffle.rs @@ -363,6 +363,8 @@ mod tests { source: None, parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } diff --git a/crates/qbz-models/src/playback.rs b/crates/qbz-models/src/playback.rs index dc7601e0d..bf85693ce 100644 --- a/crates/qbz-models/src/playback.rs +++ b/crates/qbz-models/src/playback.rs @@ -56,6 +56,19 @@ pub struct QueueTrack { /// (the skip commands fall back to album_id when this is absent). #[serde(default, skip_serializing_if = "Option::is_none")] pub source_item_id_hint: Option, + /// The container this track was launched FROM — the "playing from" origin + /// used by the now-playing song-card "layers" button. `context_kind` is one + /// of "album" | "artist" | "playlist" | "label"; `context_id` is that + /// container's navigation id. Stamped per-track at enqueue time so the + /// button always carries the CURRENT track's true source and is re-derived + /// on every track change (never a stale single global). None = no container + /// origin (bare single-track / favorites / mix / search play) → the button + /// falls back to the track's own album. `serde(default)` keeps the persisted + /// session-queue back-compatible (older payloads restore as None). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_id: Option, } fn default_streamable() -> bool { diff --git a/crates/qbz-models/src/source.rs b/crates/qbz-models/src/source.rs index 96139fccb..3aa1e6bb0 100644 --- a/crates/qbz-models/src/source.rs +++ b/crates/qbz-models/src/source.rs @@ -345,6 +345,8 @@ mod tests { source: source.map(|s| s.to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index c4b2a1c8d..e6d6c5e4f 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -1258,6 +1258,8 @@ mod tests { source: Some("test".to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } diff --git a/crates/qbz/src/local_playlist.rs b/crates/qbz/src/local_playlist.rs index e0a729965..75b2eb9a4 100644 --- a/crates/qbz/src/local_playlist.rs +++ b/crates/qbz/src/local_playlist.rs @@ -920,6 +920,8 @@ pub(crate) fn row_queue_track(item: &RowItem) -> Option { source: Some("qobuz".to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, }), // `local_queue_track` is source-aware: Plex rows get `source = // "plex"` + the rating key in `source_item_id_hint` + the raw @@ -1571,7 +1573,7 @@ pub fn play_all( } } let ctx_id = window.global::().get_id().to_string(); - crate::playback::set_now_playing_context(&weak, "playlist", &ctx_id); + crate::playback::stamp_queue_context(&mut tracks, "playlist", &ctx_id); handle.spawn(async move { play_stamped(&runtime, &weak, tracks, 0).await; }); @@ -1587,12 +1589,12 @@ pub fn play_from_visible( handle: tokio::runtime::Handle, clicked_id: &str, ) -> bool { - let tracks = visible_ordered_queue(window); + let mut tracks = visible_ordered_queue(window); let Some(idx) = tracks.iter().position(|q| q.id.to_string() == clicked_id) else { return false; }; let ctx_id = window.global::().get_id().to_string(); - crate::playback::set_now_playing_context(&weak, "playlist", &ctx_id); + crate::playback::stamp_queue_context(&mut tracks, "playlist", &ctx_id); handle.spawn(async move { play_stamped(&runtime, &weak, tracks, idx).await; }); diff --git a/crates/qbz/src/offline_favorites.rs b/crates/qbz/src/offline_favorites.rs index 962c4a3d7..889c88c57 100644 --- a/crates/qbz/src/offline_favorites.rs +++ b/crates/qbz/src/offline_favorites.rs @@ -84,6 +84,8 @@ fn index_queue_track(row: &CachedTrackInfo, cover: &str) -> QueueTrack { source: Some("qobuz_download".to_string()), parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index 95be265c0..8db316732 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -1348,6 +1348,10 @@ pub(crate) fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack { } else { None }, + // Container origin is stamped by the play path (stamp_queue_context); + // the generic builder leaves it unset. + context_kind: None, + context_id: None, } } @@ -1676,6 +1680,20 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We }; let album_id = track.album_id.clone().unwrap_or_default(); let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default(); + // "Playing from" origin for the now-playing song-card layers button. Derived + // from the CURRENT track's own per-track stamp (context_kind/context_id set + // at enqueue time by stamp_queue_context) and re-published on EVERY track + // change below — so the button always carries the right source for the track + // that is actually playing and is never a stale single global. When the + // track carries no container origin (bare single-track / favorites / mix / + // search / restored-session play) it falls back to the track's own album. + let (context_kind, context_id) = match ( + track.context_kind.as_deref().filter(|s| !s.is_empty()), + track.context_id.as_deref().filter(|s| !s.is_empty()), + ) { + (Some(kind), Some(id)) => (kind.to_string(), id.to_string()), + _ => ("album".to_string(), album_id.clone()), + }; let track_id_num = track.id; let track_id = track.id.to_string(); // Ephemeral tracks have no DB row → metadata-bound actions (favorite, @@ -1886,6 +1904,10 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We np.set_album_id(album_id.into()); np.set_album_favorite(album_favorite); np.set_artist_id(artist_id.into()); + // Re-publish the "playing from" origin for THIS track every change — the + // authoritative source for the song-card layers button (no stale global). + np.set_context_kind(context_kind.into()); + np.set_context_id(context_id.into()); np.set_track_id(track_id.into()); // Mirror the active track + playing flag onto the Purchases globals so a // purchase track-row highlights/animates when it is the now-playing one. @@ -1969,9 +1991,27 @@ fn mirror_now_playing_to_purchases(w: &AppWindow, track_id: u64, is_playing: boo /// Record the playback CONTEXT — the source the queue was launched from — on /// `NowPlayingState`, so the song-card layers button can navigate back to it. -/// Set once at play time; `refresh_now_playing_meta` leaves it untouched so it -/// survives track changes within the queue. Pass `("", "")` to clear it (the -/// button then falls back to the playing track's album). +/// Stamp every queued track with the container it was launched FROM, so the +/// now-playing song-card "playing from" button — re-derived per track in +/// `refresh_now_playing_meta` — always points at the right source for whatever +/// is actually playing. Pass the same `kind`/`id` the old +/// `set_now_playing_context` call used at that play path ("album"/album_id, +/// "artist"/artist_id, "playlist"/playlist_id, "label"/label_id). This replaces +/// the single-global approach that went stale across track changes: the origin +/// now travels WITH each track and is republished on every advance (gapless +/// included), never cached. +pub(crate) fn stamp_queue_context(tracks: &mut [QueueTrack], kind: &str, id: &str) { + for t in tracks.iter_mut() { + t.context_kind = Some(kind.to_string()); + t.context_id = Some(id.to_string()); + } +} + +/// LEGACY single-global context setter. Superseded by per-track +/// `stamp_queue_context` + the per-track republish in +/// `refresh_now_playing_meta` (which is now authoritative). Retained for the +/// miniplayer mirror path / potential reuse; no live caller remains. +#[allow(dead_code)] pub fn set_now_playing_context(weak: &slint::Weak, kind: &str, id: &str) { let kind = kind.to_string(); let id = id.to_string(); @@ -2024,6 +2064,11 @@ pub(crate) fn make_queue_track( source: Some("qobuz".to_string()), parental_warning: track.parental_warning, source_item_id_hint: Some(album_id.to_string()), + // Container origin is stamped by the play path (stamp_queue_context); + // the generic builder leaves it unset so single-track / search plays + // fall back to the track's own album. + context_kind: None, + context_id: None, } } @@ -2282,12 +2327,12 @@ pub fn play_album( start_index: usize, ) { handle.spawn(async move { - let Some(tracks) = fetch_album_for_play(&runtime, &weak, &album_id).await else { + let Some(mut tracks) = fetch_album_for_play(&runtime, &weak, &album_id).await else { return; }; + stamp_queue_context(&mut tracks, "album", &album_id); let start = start_index.min(tracks.len() - 1); let start_track_id = tracks[start].id; - set_now_playing_context(&weak, "album", &album_id); runtime.core().set_queue(tracks, Some(start)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -2310,13 +2355,13 @@ pub fn play_album_from( let Some(tracks) = fetch_album_for_play(&runtime, &weak, &album_id).await else { return; }; - let tracks = reorder_queue_by_visible(tracks, &visible_ids); + let mut tracks = reorder_queue_by_visible(tracks, &visible_ids); + stamp_queue_context(&mut tracks, "album", &album_id); let start = tracks .iter() .position(|t| t.id.to_string() == clicked_id) .unwrap_or(0); let start_track_id = tracks[start].id; - set_now_playing_context(&weak, "album", &album_id); runtime.core().set_queue(tracks, Some(start)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -2379,11 +2424,11 @@ pub fn play_artist_top_tracks( artist_id: String, ) { handle.spawn(async move { - let Some(tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else { + let Some(mut tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else { return; }; + stamp_queue_context(&mut tracks, "artist", &artist_id); let start_track_id = tracks[0].id; - set_now_playing_context(&weak, "artist", &artist_id); runtime.core().set_queue(tracks, Some(0)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -2469,8 +2514,8 @@ pub fn play_artist_top_shuffled( let j = (seed % (i as u64 + 1)) as usize; tracks.swap(i, j); } + stamp_queue_context(&mut tracks, "artist", &artist_id); let start_track_id = tracks[0].id; - set_now_playing_context(&weak, "artist", &artist_id); runtime.core().set_queue(tracks, Some(0)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -2493,8 +2538,8 @@ pub fn play_artist_top_from( let Some(tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else { return; }; - set_now_playing_context(&weak, "artist", &artist_id); - let tracks = reorder_queue_by_visible(tracks, &visible_ids); + let mut tracks = reorder_queue_by_visible(tracks, &visible_ids); + stamp_queue_context(&mut tracks, "artist", &artist_id); let start = tracks .iter() .position(|t| t.id.to_string() == clicked_id) @@ -2559,6 +2604,9 @@ fn make_top_track_queue( source: Some("qobuz".to_string()), parental_warning: track.parental_warning.unwrap_or(false), source_item_id_hint: album_id, + // Stamped "artist" by the artist play paths; unset here. + context_kind: None, + context_id: None, } } @@ -2661,6 +2709,9 @@ fn track_item_to_queue(it: &TrackItem) -> Option { }, parental_warning: it.explicit, source_item_id_hint: album_id, + // Stamped by the play path when launched from a container; unset here. + context_kind: None, + context_id: None, }) } @@ -2785,11 +2836,13 @@ pub fn play_track_in_context( clicked_id: &str, ) { let view = window.global::().get_view(); - // Default: clear the playback context so the song-card layers button falls - // back to the track's album. The album/artist/playlist/label branches below - // (and the album/artist play paths they call) override it with the real - // source; favorites/mix/search/single-track keep the album fallback. - set_now_playing_context(&weak, "", ""); + // Playback context is now stamped PER-TRACK on the enqueued queue (see + // stamp_queue_context) and republished every track change in + // refresh_now_playing_meta. The playlist/label branches below stamp their + // container; favorites/mix/search/single-track leave the tracks unstamped so + // the song-card layers button falls back to each track's own album. No + // global clear needed — a fresh set_queue replaces the whole queue, and an + // unstamped current track derives the album fallback authoritatively. match view { // Views with an authoritative Vec cache: order it by the // visible model so sort/filter are respected. @@ -2822,12 +2875,15 @@ pub fn play_track_in_context( crate::playlist::current_tracks(), clicked_id, ) { - set_now_playing_context( - &weak, - "playlist", - window.global::().get_id().as_str(), + let ctx_id = window.global::().get_id().to_string(); + play_tracks_ctx( + runtime, + weak, + handle, + tracks, + idx, + Some(("playlist".to_string(), ctx_id)), ); - play_tracks(runtime, weak, handle, tracks, idx); return; } } @@ -2847,12 +2903,15 @@ pub fn play_track_in_context( crate::label::top_tracks_for_play(), clicked_id, ) { - set_now_playing_context( - &weak, - "label", - window.global::().get_id().as_str(), + let ctx_id = window.global::().get_id().to_string(); + play_tracks_ctx( + runtime, + weak, + handle, + tracks, + idx, + Some(("label".to_string(), ctx_id)), ); - play_tracks(runtime, weak, handle, tracks, idx); return; } } @@ -2948,12 +3007,28 @@ pub fn play_tracks( handle: tokio::runtime::Handle, tracks: Vec, start_index: usize, +) -> bool { + play_tracks_ctx(runtime, weak, handle, tracks, start_index, None) +} + +/// Like [`play_tracks`] but stamps every built queue track with the container +/// it was launched FROM (`Some((kind, id))`) so the now-playing "playing from" +/// button resolves to the right source per track. Pass `None` for flat lists +/// with no container origin (radio / mix / favorites) — those fall back to each +/// track's own album. +pub fn play_tracks_ctx( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + tracks: Vec, + start_index: usize, + context: Option<(String, String)>, ) -> bool { // Drop blacklisted tracks (performer OR composer — D-FEAT) before building // the queue. Shared sink for radio results, the mix views, and album // shuffle, so this single filter covers all three. No album-primary // fallback here (these are flat track lists, not an album context). - let queue: Vec = tracks + let mut queue: Vec = tracks .iter() .filter(|track| !track_is_blacklisted_full(track, None)) .map(|track| { @@ -2976,6 +3051,11 @@ pub fn play_tracks( make_queue_track(track, &album_id, &album_title, &album_artist, &album_artwork, None) }) .collect(); + // Stamp the container origin onto every track so the "playing from" button + // is correct for whichever one is current (republished per change). + if let Some((kind, id)) = &context { + stamp_queue_context(&mut queue, kind, id); + } if queue.is_empty() { // Either nothing was passed, or every track was blacklisted. Silent // early-return (the caller logs); radio callers surface their existing @@ -3438,7 +3518,7 @@ pub fn play_playlist( let rows = crate::playlist::interleave_rows(qobuz_tracks, sidecar); // Drop blacklisted Qobuz rows (performer; local/Plex rows kept by the // source guard). Silent early-return when nothing playable remains. - let tracks: Vec = filter_blacklisted_queue( + let mut tracks: Vec = filter_blacklisted_queue( rows.iter() .filter_map(|row| crate::local_playlist::row_queue_track(&row.item)) .collect(), @@ -3446,8 +3526,8 @@ pub fn play_playlist( if tracks.is_empty() { return; } + stamp_queue_context(&mut tracks, "playlist", &playlist_id); let start_track_id = tracks[0].id; - set_now_playing_context(&weak, "playlist", &playlist_id); runtime.core().set_queue(tracks, Some(0)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); diff --git a/crates/qbz/src/queue.rs b/crates/qbz/src/queue.rs index 4aa15b75c..5860d980b 100644 --- a/crates/qbz/src/queue.rs +++ b/crates/qbz/src/queue.rs @@ -1027,6 +1027,8 @@ mod tests { source: None, parental_warning: false, source_item_id_hint: None, + context_kind: None, + context_id: None, } } diff --git a/crates/qbz/src/session_persist.rs b/crates/qbz/src/session_persist.rs index 371f44bf7..8f0a0b63b 100644 --- a/crates/qbz/src/session_persist.rs +++ b/crates/qbz/src/session_persist.rs @@ -196,6 +196,11 @@ fn from_persisted(t: PersistedQueueTrack) -> QueueTrack { source: t.source, parental_warning: t.parental_warning, source_item_id_hint: t.source_item_id_hint, + // Not persisted in the session schema — a restored track carries no + // container origin, so the "playing from" button falls back to the + // track's own album until the next container play re-stamps the queue. + context_kind: None, + context_id: None, } } diff --git a/crates/qconnect-app/src/renderer.rs b/crates/qconnect-app/src/renderer.rs index 7c086e6e7..a9304ad02 100644 --- a/crates/qconnect-app/src/renderer.rs +++ b/crates/qconnect-app/src/renderer.rs @@ -97,6 +97,8 @@ pub fn model_track_to_core_queue_track(track: &Track) -> QueueTrack { source: Some(QCONNECT_REMOTE_QUEUE_SOURCE.to_string()), parental_warning: track.parental_warning, source_item_id_hint: album_id, + context_kind: None, + context_id: None, } }