From ea373a601c91e90db04090c3050f269c3b6abaea Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:36:40 -0600 Subject: [PATCH] =?UTF-8?q?fix(qt):=20four=20AUDIBLE=20parity=20rows=20?= =?UTF-8?q?=E2=80=94=20stream=20errors,=20seek=20clamp,=20label=20shuffle,?= =?UTF-8?q?=20Plex=20keys,=20Library=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PARITY-DEBT #7, #15, #17, #4 and #5. - **#7** the stream error was drained and only logged, so an ALSA / exclusive failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no explanation. It now toasts, with the sandbox-aware rewrite the reference carries (playback.rs:5093-5115). This is the support issue that fix was written for. - **#15** seek was clamped to the full duration instead of the downloaded fraction, so the head could be dragged into bytes that do not exist. The poll loop publishes `npSeekableMax` and both bars clamp to it, matching SeekBar.slint:93-98. The cache overlay was already drawn and purely decorative. - **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL shuffle mode on and leaving it on for everything played afterwards. The reference shuffles the vector in place and never touches the mode. - **#4** a `plex:` album-boundary key was passed where a Plex rating key belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`). - **#5** clicking a track in Library played exactly one track and stopped. It now queues the whole VISIBLE list from the clicked row (`order_by_visible`). The Library row play needed one edit outside its author's lane, which the singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was called from QML and implemented in `library_qt`, but never declared on the bridge. That is precisely the failure the xref exists for — QML resolves singleton members lazily, so a missing invokable compiles clean and throws when the user clicks. The QML had shipped a `typeof === "function"` feature-detect with a fallback, so it degraded instead of throwing; the invokable now exists and the fallback is dead code by design. cc --- .../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 30 +- crates/qbz-qt/qml/shell/PlayerBar.qml | 41 ++- crates/qbz-qt/qml/views/LibraryView.qml | 46 ++- crates/qbz-qt/src/label_qt.rs | 40 ++- crates/qbz-qt/src/library_bridge.rs | 21 ++ crates/qbz-qt/src/library_qt.rs | 261 +++++++++++++++++- crates/qbz-qt/src/local_album_actions.rs | 19 +- crates/qbz-qt/src/local_playback.rs | 63 ++++- crates/qbz-qt/src/now_playing.rs | 38 ++- crates/qbz-qt/src/playback_qt.rs | 247 +++++++++++++++-- crates/qbz-qt/src/player_bridge.rs | 13 + 11 files changed, 759 insertions(+), 60 deletions(-) diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index aa1f095d2..1f4e0f5b8 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -95,6 +95,27 @@ Rectangle { return m + ":" + (s < 10 ? "0" : "") + s } + // --- Seek clamp (closes PARITY-DEBT #15, QML half) ------------------- + // Same SeekBar the full bar mounts (PlayerBarSmall.slint:186 passes + // seekable-max: NowPlayingState.seekable-max into SeekBar.slint), so the + // behaviour is identical here: while a track is still downloading the + // seek target is LOCKED to the furthest fraction that has arrived and the + // cursor turns not-allowed past it. Source: state.slint:4402, fed by + // playback.rs:5304 `buffer_progress.clamp(0,1)`, published as + // QbzPlayer.npSeekableMax. A fully-available track reports 1.0, so the + // Math.min() is a no-op and local/cached seeking stays free. + function clamp01(v) { + return Math.min(Math.max(v, 0), 1) + } + // SeekBar.slint:98 — Math.min(clamp01(mouse-x / width), seekable-max). + function seekTarget(fraction) { + return Math.min(root.clamp01(fraction), QbzPlayer.npSeekableMax) + } + // SeekBar.slint:93 — clamp01(mouse-x / width) > seekable-max. + function beyondSeekable(fraction) { + return root.clamp01(fraction) > QbzPlayer.npSeekableMax + } + // --- Track Info (album/TrackInfoModal.slint) ------------------------- // The (i) button and the song-card title open the MODAL (scrim + centered // card) — PlayerBarSmall.slint fires media-action("track", id, @@ -191,9 +212,14 @@ Rectangle { height: 18 anchors.verticalCenter: seekTrack.verticalCenter hoverEnabled: true - cursorShape: Qt.PointingHandCursor + // No-drop cursor over the not-yet-downloaded region + // (SeekBar.slint:93-95). + cursorShape: root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor + : Qt.PointingHandCursor enabled: QbzPlayer.npHasTrack - onClicked: QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1)) + // Lock the seek target to what has downloaded while streaming + // (SeekBar.slint:96-99). + onClicked: QbzPlayer.seek(root.seekTarget(mouseX / width)) } } diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 209c6e1b8..fa06def69 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -155,6 +155,29 @@ Rectangle { return m + ":" + (s < 10 ? "0" : "") + s } + // --- Seek clamp (closes PARITY-DEBT #15, QML half) --------------------- + // SeekBar.slint:24-26 / 93-98: while a track is still downloading the seek + // target is LOCKED to the furthest fraction that has arrived, and the + // cursor turns not-allowed over the region that has not. The source is + // NowPlayingState.seekable-max (state.slint:4402), fed by + // playback.rs:5304 `buffer_progress.clamp(0,1)` and published here as + // QbzPlayer.npSeekableMax. + // + // A fully-available track (local, cached, or a finished download) reports + // 1.0, so Math.min() is a no-op and seeking stays completely free — the + // clamp can never fight a local track. + function clamp01(v) { + return Math.min(Math.max(v, 0), 1) + } + // SeekBar.slint:98 — Math.min(clamp01(mouse-x / width), seekable-max). + function seekTarget(fraction) { + return Math.min(root.clamp01(fraction), QbzPlayer.npSeekableMax) + } + // SeekBar.slint:93 — clamp01(mouse-x / width) > seekable-max. + function beyondSeekable(fraction) { + return root.clamp01(fraction) > QbzPlayer.npSeekableMax + } + // --- Track Info (album/TrackInfoModal.slint) --------------------------- // The (i) button and the song-card title open the MODAL (scrim + centered // card), which is what the .slint does: the bars fire @@ -263,9 +286,21 @@ Rectangle { anchors.right: seekTrack.right anchors.top: parent.top anchors.bottom: parent.bottom - cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor - onPressed: if (QbzPlayer.npHasTrack) QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1)) - onPositionChanged: if (pressed && QbzPlayer.npHasTrack) QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1)) + // Hover is what keeps mouseX live for the cursor binding + // below (PlayerBar.slint:180 -> SeekBar.slint's TouchArea + // always tracks mouse-x). + hoverEnabled: true + // No-drop cursor over the not-yet-downloaded region + // (SeekBar.slint:93-95). The npHasTrack arm is the Qt bar's + // own idle guard and stays first. + cursorShape: !QbzPlayer.npHasTrack + ? Qt.ArrowCursor + : (root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor + : Qt.PointingHandCursor) + // Lock the seek target to what has downloaded while streaming + // (SeekBar.slint:96-99). + onPressed: if (QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width)) + onPositionChanged: if (pressed && QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width)) } } diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 1b94210e7..96a3fc89c 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -510,6 +510,42 @@ Rectangle { // shared qml/ files (TrackCard/AlbumCard/ArtistCard/PlaylistCard/ // LabelCard.qml). The old one-size FeedGridCard is gone. + // ------------------- per-row play (PARITY-DEBT #5) ------------------- + // Every track row in this view queues the list the user is LOOKING AT, + // anchored on the clicked row — it does NOT play one track and stop. + // + // Slint: playback.rs:3518-3524 + // order_by_visible(FavoritesState.tracks-visible, // rendered order + // favorites::play_tracks(), // FAV_CURRENT + // clicked_id) -> play_tracks(tracks, idx) + // and, when order_by_visible returns None (the clicked row does not + // resolve inside the visible list), the single-track fallback at :3620. + // + // The visible ORDER is the whole point: it carries the tab, the search, + // the sort and the genre/source filters. In this port that order exists + // only here (the feed is derived in JS), so it goes down as a JSON id + // array and `library_qt::order_by_visible` resolves it against the feed. + // `visibleRows` is THE derived model both views bind to — never read + // `grid.model` / `list.model` back off a view (see the note on it). + function visibleTrackIds() { + var rows = root.visibleRows + var ids = [] + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === "track") ids.push(rows[i].id) + return ids + } + function playTrackInContext(trackId) { + // Feature-detect the seam so the view degrades to the old one-track + // play instead of throwing if the invokable is not there yet. + if (typeof QbzLibrary.libraryPlayVisible === "function") { + QbzLibrary.libraryPlayVisible(JSON.stringify(visibleTrackIds()), trackId) + return + } + console.warn("[qbz-qt] LibraryView: QbzLibrary.libraryPlayVisible is missing;" + + " falling back to a one-track queue (PARITY-DEBT #5)") + QbzPlayer.playTrack(trackId) + } + // Track context-menu model (TrackCard.slint track-menu) + dispatch — // shared by the LIST rows here (the grid card carries its own copy in // TrackCard.qml). @@ -533,7 +569,7 @@ Rectangle { // own property (a write to `item.isFavorite` notifies nothing). function trackAction(row, a) { var item = row.item - if (a === "play") QbzPlayer.playTrack(item.id) + if (a === "play") root.playTrackInContext(item.id) else if (a === "next") QbzPlayer.enqueueTrack(item.id, "next") else if (a === "later") QbzPlayer.enqueueTrack(item.id, "later") else if (a === "queue") QbzPlayer.enqueueTrack(item.id, "queue") @@ -585,7 +621,7 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - if (item.kind === "track") QbzPlayer.playTrack(item.id) + if (item.kind === "track") root.playTrackInContext(item.id) else if (item.kind === "album") QbzAlbum.openAlbum(item.id) else if (item.kind === "artist") QbzArtist.openArtist(item.id) // playlist/label pages: out of scope (POC-NOTE). @@ -663,7 +699,7 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - if (item.kind === "track") QbzPlayer.playTrack(item.id) + if (item.kind === "track") root.playTrackInContext(item.id) else if (item.kind === "album") QbzPlayer.playAlbum(item.id) // playlist play: out of scope (POC-NOTE). } @@ -691,7 +727,7 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - if (item.kind === "track") QbzPlayer.playTrack(item.id) + if (item.kind === "track") root.playTrackInContext(item.id) else if (item.kind === "album") QbzAlbum.openAlbum(item.id) else if (item.kind === "artist") QbzArtist.openArtist(item.id) } @@ -1425,7 +1461,7 @@ Rectangle { TrackRow { item: modelData number: index + 1 - onPlayRequested: QbzPlayer.playTrack(item.id) + onPlayRequested: root.playTrackInContext(item.id) onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) } // MyQBZ "Add to mixtape" — the HOST builds the // AddItem array (TrackRow does not know diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index 41dbd019f..04fc1cd33 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -21,9 +21,13 @@ //! (`add-to-playlist`, `add-to-mixtape`) have no bridge seam at all. The //! select disc renders DIMMED and inert; the `MultiSelectBar` is not //! ported. See the view's header note. -//! - **Blacklist**: `crate::artist_blacklist` does not exist in this port -//! (the phase-1 skip documented in `home_qt.rs`), so the `releases` / -//! `critics` drop the Slint applies is inherited as a no-op. +//! - **Blacklist**: the premise of this note ("`crate::artist_blacklist` does +//! not exist in this port") is DEAD — the module is bound at `auth_qt.rs` +//! and the QUEUE is filtered since PARITY-DEBT #1. What is still missing is +//! the LISTING drop: the Slint filters the releases / critics rails through +//! `album_blacklisted` (label.rs:92, :133, :537, :567) and stamps the row +//! flag (:900); `derive_releases` here does neither, so a blocked artist's +//! album still RENDERS on a label page (playing it is already blocked). //! - **Share** is QML-side (a clipboard write through the `TrackRow.qml` //! Loader/TextEdit pattern) — there is no Rust clipboard bridge and no //! toast seam, so no invokable exists for it here. @@ -542,12 +546,21 @@ pub fn toggle_follow() { } /// Popular Tracks Play (`shuffle == false`) / header Shuffle (`true`). +/// +/// PARITY-DEBT #17. `shuffle` is a ONE-SHOT reorder of this queue, played from +/// index 0 — `play_label_top_shuffled` (playback.rs:3120-3150) xorshift-mixes +/// the Vec and hands it to `play_tracks_ctx`; it never touches the player's +/// shuffle MODE, which is why the bar's toggle must stay exactly where the +/// user left it after pressing Shuffle here. The reorder itself lives in +/// `playback_qt::play_track_list_in` (one implementation for every caller of +/// the flag). The label context is EXPLICIT because a label's popular tracks +/// share neither album nor artist and nothing could derive it. pub fn play_top(shuffle: bool) { - let queue = { + let (queue, label_id) = { let Ok(s) = LABEL.lock() else { return; }; - s.play_queue.clone() + (s.play_queue.clone(), s.id.clone()) }; if queue.is_empty() { log::info!("[qbz-qt] label play-top: no playable tracks"); @@ -555,7 +568,10 @@ pub fn play_top(shuffle: bool) { } let runtime = crate::app(); crate::spawn(async move { - if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, 0, shuffle).await { + let ctx = crate::playback_qt::PlayContext::label(&label_id); + if let Err(e) = + crate::playback_qt::play_track_list_in(&runtime, queue, 0, shuffle, ctx).await + { log::error!("[qbz-qt] label play-top failed: {e}"); } }); @@ -567,7 +583,7 @@ pub fn play_track(track_id: String) { let Ok(wanted) = track_id.parse::() else { return; }; - let (queue, start) = { + let (queue, start, label_id) = { let Ok(s) = LABEL.lock() else { return; }; @@ -576,14 +592,20 @@ pub fn play_track(track_id: String) { .iter() .position(|t| t.id == wanted) .unwrap_or(0); - (s.play_queue.clone(), start) + (s.play_queue.clone(), start, s.id.clone()) }; if queue.is_empty() { return; } let runtime = crate::app(); crate::spawn(async move { - if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, start, false).await { + // Same explicit label origin as the header play (the ContentView::Label + // arm of `play_track_in_context`, playback.rs:3527-3543): + // a row click on a label page is still "playing from" the label. + let ctx = crate::playback_qt::PlayContext::label(&label_id); + if let Err(e) = + crate::playback_qt::play_track_list_in(&runtime, queue, start, false, ctx).await + { log::error!("[qbz-qt] label row play failed: {e}"); } }); diff --git a/crates/qbz-qt/src/library_bridge.rs b/crates/qbz-qt/src/library_bridge.rs index cdcba734f..3247f825a 100644 --- a/crates/qbz-qt/src/library_bridge.rs +++ b/crates/qbz-qt/src/library_bridge.rs @@ -63,6 +63,19 @@ pub mod qbz_library_bridge { #[qinvokable] fn library_toggle_favorite(self: Pin<&mut QbzLibrary>, kind: QString, id: QString); + /// LibraryView row play (PARITY-DEBT #5). `visible_ids_json` is the + /// JSON array of the track ids the view is CURRENTLY rendering, in + /// render order; `clicked_id` is the row that was hit. The Slint + /// queues the whole VISIBLE list from the clicked row + /// (playback.rs:3518-3524 `order_by_visible`) — this port used to play + /// exactly one track and then stop dead. + #[qinvokable] + fn library_play_visible( + self: Pin<&mut QbzLibrary>, + visible_ids_json: QString, + clicked_id: QString, + ); + /// Emitted when a dispatched cover lands on disk (id-keyed — /// `{kind}:{id}`); QML updates its artwork map. #[qsignal] @@ -137,6 +150,14 @@ impl qbz_library_bridge::QbzLibrary { crate::library_toggle_favorite(kind.to_string(), id.to_string()); } + pub fn library_play_visible( + self: Pin<&mut Self>, + visible_ids_json: QString, + clicked_id: QString, + ) { + crate::library_qt::play_from_visible(visible_ids_json.to_string(), clicked_id.to_string()); + } + pub fn toggle_pin(self: Pin<&mut Self>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString) { crate::toggle_pin( kind.to_string(), diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index 38d9227b4..889bcc822 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -37,7 +37,7 @@ use qbz_app::settings::local_favorites::{LocalFavoritesService, DB_FILE_NAME}; use qbz_app::shell::AppRuntime; use qbz_app::user_data::UserDataPaths; use qbz_core::LoggingAdapter; -use qbz_models::{Album, Artist, Playlist, Track}; +use qbz_models::{Album, Artist, Playlist, QueueTrack, Track}; use serde::Serialize; use crate::home_qt; @@ -1085,3 +1085,262 @@ pub(crate) fn apply_pin_change(kind: &str, id: &str, pinned: bool) { } }); } + +// --------------------------------------------------------------------------- +// Per-row play: the VISIBLE list becomes the queue (PARITY-DEBT #5) +// --------------------------------------------------------------------------- +// +// Reference: `playback.rs::play_track_in_context` (:3459) — the ONE entry point +// every Slint tracklist row goes through. Its Favorites arm is +// +// ContentView::Favorites => { +// if let Some((tracks, idx)) = order_by_visible( +// &FavoritesState.tracks_visible, // what the list RENDERS +// crate::favorites::play_tracks(), // FAV_CURRENT, the authority +// clicked_id, +// ) { play_tracks(runtime, weak, handle, tracks, idx); return; } +// } +// // …no resolvable list context: play just the track. +// if let Ok(tid) = clicked_id.parse::() { play_track_now(…) } +// +// and `order_by_visible` (playback.rs:3408-3428) is: take the ids in VISIBLE +// order, resolve each against the authoritative cache (unresolvable rows are +// dropped), then locate the clicked id INSIDE that ordered list — `None` when +// it is not there, so the caller plays the single track instead of starting +// the queue at the wrong row. +// +// Port shape: the port has ONE merged feed (`LibraryData.feed`) and derives the +// rendered list in QML, so the visible ORDER can only come from QML — it +// carries the tab, the search, the sort and the genre/source filters the user +// is actually looking at, which is the whole point of ordering by the visible +// model rather than by the cache. QML passes that order down as a JSON id +// array; the feed plays the part of `FAV_CURRENT`. +// +// One deliberate widening, called out because it is a deviation: the Slint +// always orders by the TRACKS-tab model, so a click in its mixed All feed +// queues the Tracks tab (and plays a lone track when that tab was never +// opened and its model is still empty). Here the visible list IS the list +// under the pointer — identical on the Tracks tab, and in the All feed it +// queues the mixed feed's track rows in the order shown instead of nothing. + +/// `playback_qt::feed_queue_track` for a feed row already in hand. +/// +/// Mirrors that function field-for-field (it looks the same row up by id and +/// is private to `playback_qt`); the follow-up is to make ONE of them +/// `pub(crate)` and delete the other. +/// +/// `None` for a non-Qobuz row: local / Plex favourites carry a path or a group +/// key as their id, not a numeric one, and must not be resolved through the +/// Qobuz track resolver. The Slint cannot hit this at all — `FAV_CURRENT` is +/// Qobuz-only — so dropping them is the same queue it would have built. +fn feed_track_to_queue(item: &FeedItem) -> Option { + let id = item.id.parse::().ok()?; + Some(QueueTrack { + id, + title: item.title.clone(), + version: None, + artist: item.artist.clone(), + album: item.album.clone(), + album_version: None, + duration_secs: duration_secs(&item.duration), + artwork_url: if item.image_url.is_empty() { + None + } else { + Some(item.image_url.clone()) + }, + hires: item.quality_tier == "hires", + bit_depth: None, + sample_rate: None, + is_local: false, + album_id: if item.album_id.is_empty() { + None + } else { + Some(item.album_id.clone()) + }, + artist_id: item.artist_id.parse::().ok(), + streamable: true, + source: Some("qobuz".to_string()), + parental_warning: item.explicit, + source_item_id_hint: None, + context_kind: None, + context_id: None, + }) +} + +/// Inverse of `mmss` — the feed carries the duration as the rendered `M:SS` +/// string, so the queue row has to read it back. Anything unparseable is 0, +/// which is what `playback_qt::feed_queue_track` does too (the engine +/// re-reports the real duration once the track resolves). +fn duration_secs(mmss: &str) -> u64 { + let mut parts = mmss.split(':'); + parts.next().and_then(|m| m.parse::().ok()).unwrap_or(0) * 60 + + parts.next().and_then(|s| s.parse::().ok()).unwrap_or(0) +} + +/// `playback.rs::order_by_visible` (:3408-3428), ported: build the queue in +/// VISIBLE order out of the authoritative feed and anchor it on `clicked_id`. +/// +/// `None` — and the caller then plays the single track — when the clicked id +/// does not resolve inside the ordered list: an orphan row, a cache miss, or a +/// local row that carries no numeric id. Same guard, same reason: better one +/// track than a queue that starts on the wrong one. +fn order_by_visible( + feed: &[FeedItem], + visible_ids: &[String], + clicked_id: &str, +) -> Option<(Vec, usize)> { + let by_id: std::collections::HashMap<&str, &FeedItem> = feed + .iter() + .filter(|i| i.kind == "track") + .map(|i| (i.id.as_str(), i)) + .collect(); + let ordered: Vec = visible_ids + .iter() + .filter_map(|id| by_id.get(id.as_str()).copied()) + .filter_map(feed_track_to_queue) + .collect(); + let idx = ordered.iter().position(|t| t.id.to_string() == clicked_id)?; + Some((ordered, idx)) +} + +/// LibraryView row play. `visible_ids_json` is the JSON string array of the +/// track ids the view is CURRENTLY rendering, in render order (the +/// `libraryArtworkWindow` convention); `clicked_id` is the row that was hit. +/// +/// Fire-and-forget like every other `*_qt` play entry point (`label_qt:: +/// play_track`): the queue is built synchronously off the feed, the play is +/// spawned. Blacklisted rows are dropped and the start index remapped inside +/// `playback_qt::set_queue_stamped`, so nothing to do here. +pub fn play_from_visible(visible_ids_json: String, clicked_id: String) { + let visible_ids: Vec = serde_json::from_str(&visible_ids_json).unwrap_or_default(); + let queued = with_library(|d| order_by_visible(&d.feed, &visible_ids, &clicked_id)).flatten(); + let Some((queue, start)) = queued else { + // order_by_visible == None: the Slint's fallback is the single track + // (playback.rs:3620-3623). + log::info!( + "[qbz-qt] library play: {clicked_id} does not resolve in the visible list \ + ({} rows) -> single track", + visible_ids.len() + ); + if let Ok(id) = clicked_id.parse::() { + crate::play_track(id); + } + return; + }; + log::info!( + "[qbz-qt] library play: queueing {} visible track(s) from index {start}", + queue.len() + ); + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, start, false).await { + log::error!("[qbz-qt] library play_from_visible failed: {e}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(id: &str, title: &str) -> FeedItem { + FeedItem { + kind: "track".into(), + id: id.into(), + title: title.into(), + duration: "3:07".into(), + ..Default::default() + } + } + + fn album(id: &str) -> FeedItem { + FeedItem { + kind: "album".into(), + id: id.into(), + ..Default::default() + } + } + + #[test] + fn duration_secs_reads_back_mmss() { + // `mmss` never emits hours — 75 minutes renders as "75:04". + assert_eq!(duration_secs("3:07"), 187); + assert_eq!(duration_secs("0:59"), 59); + assert_eq!(duration_secs("75:04"), 4504); + assert_eq!(duration_secs(""), 0); + assert_eq!(duration_secs("x:y"), 0); + } + + #[test] + fn queue_follows_the_visible_order_not_the_feed_order() { + let feed = vec![track("1", "a"), track("2", "b"), track("3", "c")]; + // What the user is looking at: sorted by title descending. + let visible = vec!["3".to_string(), "2".to_string(), "1".to_string()]; + let (queue, start) = order_by_visible(&feed, &visible, "2").expect("clicked row resolves"); + assert_eq!( + queue.iter().map(|t| t.id).collect::>(), + vec![3, 2, 1] + ); + assert_eq!(start, 1); + } + + #[test] + fn non_track_and_unresolvable_rows_drop_out() { + let feed = vec![track("1", "a"), album("2"), track("9", "z")]; + // The All feed's visible order: a track, an album row, a local track + // (path id), a track that is not in the feed at all. + let visible = vec![ + "1".to_string(), + "2".to_string(), + "/home/u/x.flac".to_string(), + "404".to_string(), + "9".to_string(), + ]; + let (queue, start) = order_by_visible(&feed, &visible, "9").expect("clicked row resolves"); + assert_eq!(queue.iter().map(|t| t.id).collect::>(), vec![1, 9]); + assert_eq!(start, 1); + } + + #[test] + fn clicked_row_outside_the_visible_list_is_none() { + let feed = vec![track("1", "a"), track("2", "b")]; + let visible = vec!["1".to_string()]; + // Reference: the caller then plays the single track rather than + // starting the queue at the wrong row. + assert!(order_by_visible(&feed, &visible, "2").is_none()); + // A local row is unresolvable even when it IS visible. + assert!(order_by_visible(&feed, &["/home/u/x.flac".to_string()], "/home/u/x.flac").is_none()); + // Nothing visible at all. + assert!(order_by_visible(&feed, &[], "1").is_none()); + } + + #[test] + fn queue_row_carries_the_feed_metadata() { + let mut item = track("42", "Title"); + item.artist = "Artist".into(); + item.artist_id = "7".into(); + item.album = "Album".into(); + item.album_id = "abc".into(); + item.image_url = "https://x/1.jpg".into(); + item.quality_tier = "hires".into(); + item.explicit = true; + let qt = feed_track_to_queue(&item).expect("numeric id"); + assert_eq!(qt.id, 42); + assert_eq!(qt.title, "Title"); + assert_eq!(qt.artist_id, Some(7)); + assert_eq!(qt.album_id.as_deref(), Some("abc")); + assert_eq!(qt.artwork_url.as_deref(), Some("https://x/1.jpg")); + assert_eq!(qt.duration_secs, 187); + assert!(qt.hires); + assert!(qt.parental_warning); + assert_eq!(qt.source.as_deref(), Some("qobuz")); + // Empty strings become None, never Some("") — the now-playing card + // tests `is_some()`. + let bare = track("43", "T"); + let qt = feed_track_to_queue(&bare).expect("numeric id"); + assert!(qt.artwork_url.is_none()); + assert!(qt.album_id.is_none()); + assert!(qt.artist_id.is_none()); + assert!(!qt.hires); + } +} diff --git a/crates/qbz-qt/src/local_album_actions.rs b/crates/qbz-qt/src/local_album_actions.rs index 77feb1d81..c0b17c7f1 100644 --- a/crates/qbz-qt/src/local_album_actions.rs +++ b/crates/qbz-qt/src/local_album_actions.rs @@ -35,7 +35,7 @@ use qbz_models::QueueTrack; use serde::Serialize; use crate::local_bridge::ui; -use crate::local_playback::{local_queue_track, play_local_file, play_plex_track}; +use crate::local_playback::{local_queue_track, play_local_file, play_plex_track, plex_rating_key}; use crate::local_rows::{ album_key, badge_source, map_track, tier_of, to_json, total_duration, AlbumRow, TrackRow, }; @@ -438,16 +438,13 @@ fn publish_doc(doc: Option) { /// the PROTECTED audio path is entered, never modified. async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool { match track.source.as_deref() { - Some("plex") => match track.source_item_id_hint.clone() { - Some(rating_key) => play_plex_track(runtime, rating_key, track.id).await, - None => { - log::error!( - "[qbz-qt] plex play: queue track {} has no rating key", - track.id - ); - false - } - }, + // PARITY-DEBT #4: the hint is NOT always a rating key — a `plex:` + // hint is the MyQBZ album-boundary key and 404s the resolve. The guard + // lives with the other one, in `local_playback` (`playback.rs:666-680`). + Some("plex") => { + let rating_key = plex_rating_key(track.source_item_id_hint.as_deref(), track.id); + play_plex_track(runtime, rating_key, track.id).await + } _ => play_local_file(runtime, track.id).await, } } diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 87d9ceeeb..54412898f 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -211,14 +211,33 @@ pub async fn play_plex_track(runtime: &Runtime, rating_key: String, play_id: u64 } } +/// Resolve the Plex track rating key for a queue row (PARITY-DEBT #4, ports +/// `playback.rs:666-680`, commit `b5c1a76e`). +/// +/// The string rating_key rides in `source_item_id_hint` on the LocalLibrary +/// path (`local_queue_track` above stamps `file_path`, which for a Plex row IS +/// the raw key). The MyQBZ collections path stamps the per-item ALBUM key there +/// instead (`plex:` from `qbz_plex::plex_album_key`, for shuffle boundary +/// detection) — that is NOT a track rating key, so ignore any `plex:`-prefixed +/// hint and fall back to the numeric queue id (= rating_key for the common +/// numeric-key case). Using it verbatim made +/// `GET /library/metadata/plex:` 404: the Plex track never started and +/// the previous track kept playing under the new card. +/// +/// A MISSING hint falls back the same way — the reference's `_` arm covers both +/// `None` and the prefixed case, so this no longer refuses the row. +pub(crate) fn plex_rating_key(hint: Option<&str>, track_id: u64) -> String { + match hint { + Some(hint) if !hint.starts_with("plex:") => hint.to_string(), + _ => track_id.to_string(), + } +} + /// Route ONE queue track to its audible step. async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool { match track.source.as_deref() { Some("plex") => { - let Some(rating_key) = track.source_item_id_hint.clone() else { - log::error!("[qbz-qt] plex play: queue track {} has no rating key", track.id); - return false; - }; + let rating_key = plex_rating_key(track.source_item_id_hint.as_deref(), track.id); play_plex_track(runtime, rating_key, track.id).await } _ => play_local_file(runtime, track.id).await, @@ -387,3 +406,39 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String) } crate::playback_qt::publish_queue(runtime).await; } + +#[cfg(test)] +mod tests { + use super::plex_rating_key; + + /// LocalLibrary path: the hint IS the raw rating key (`local_queue_track` + /// stamps `file_path`, which for a Plex row is the server key). Unchanged + /// by the guard — `playback.rs:673-676` first arm. + #[test] + fn raw_hint_is_used_verbatim() { + assert_eq!(plex_rating_key(Some("12345"), 999), "12345"); + // Non-numeric server keys are legal and must survive untouched. + assert_eq!( + plex_rating_key(Some("/library/metadata/771"), 999), + "/library/metadata/771" + ); + } + + /// PARITY-DEBT #4: the MyQBZ collections path stamps the ALBUM boundary key + /// (`qbz_plex::plex_album_key` -> `plex:`). It is not a rating key, so + /// it is ignored in favour of the numeric queue id — the `b5c1a76e` fix. + #[test] + fn plex_prefixed_hint_falls_back_to_queue_id() { + assert_eq!(plex_rating_key(Some("plex:deadbeef"), 771), "771"); + // Prefix test is on the LEADING bytes only, exactly like `starts_with`. + assert_eq!(plex_rating_key(Some("plex:"), 42), "42"); + assert_eq!(plex_rating_key(Some("77plex:1"), 42), "77plex:1"); + } + + /// The reference's `_` arm also covers a missing hint: fall back rather than + /// refuse the row. + #[test] + fn missing_hint_falls_back_to_queue_id() { + assert_eq!(plex_rating_key(None, 771), "771"); + } +} diff --git a/crates/qbz-qt/src/now_playing.rs b/crates/qbz-qt/src/now_playing.rs index 4410ef495..207339399 100644 --- a/crates/qbz-qt/src/now_playing.rs +++ b/crates/qbz-qt/src/now_playing.rs @@ -50,6 +50,12 @@ pub struct NowPlayingModel { pub loading: bool, /// Streaming buffer fill 0..1 (seekbar cache overlay); 0 = not streaming. pub cache: f32, + /// SEEK LOCK 0..1 — the furthest fraction of the track the user may seek + /// to (PARITY-DEBT #15, Slint `NowPlayingState.seekable-max`). While a + /// stream is downloading this is `buffer_progress` clamped to 0..1; a + /// fully-available track is 1.0. Distinct from `cache`, which is the + /// decorative overlay of the same fill: this one is enforced. + pub seekable_max: f32, pub volume: f32, pub muted: bool, pub shuffle: bool, @@ -75,10 +81,12 @@ pub struct NowPlayingModel { pub cast_protocol: String, } -/// The idle bar: no track, full volume, no badge. +/// The idle bar: no track, full volume, no badge, seek unlocked (the Slint +/// `NowPlayingState` defaults — `seekable-max: 1.0`, state.slint:4402). fn idle() -> NowPlayingModel { NowPlayingModel { volume: 1.0, + seekable_max: 1.0, ..NowPlayingModel::default() } } @@ -129,6 +137,7 @@ fn publish(m: &NowPlayingModel) { b.as_mut().set_np_duration_secs(m.duration_secs); b.as_mut().set_np_progress(progress); b.as_mut().set_np_cache_progress(m.cache); + b.as_mut().set_np_seekable_max(m.seekable_max); b.as_mut().set_np_playing(m.playing); b.as_mut().set_np_loading(m.loading); b.as_mut().set_np_volume(m.volume); @@ -326,6 +335,10 @@ pub fn clear_track() { let kept = NowPlayingModel { volume: m.volume, muted: m.muted, + // The seek lock belongs to the STREAM that just ended — an idle + // bar seeks nothing, but leaving the last stream's fraction here + // would carry a 0.3 lock into the next track's first tick. + seekable_max: 1.0, is_remote: m.is_remote, cast_target: m.cast_target.clone(), cast_active: m.cast_active, @@ -409,3 +422,26 @@ pub fn set_position( m.cache = cache; }); } + +/// The seek lock (PARITY-DEBT #15) — `buffer_progress.clamp(0,1)` while a +/// stream is downloading, 1.0 for a fully-available track +/// (`playback.rs:5304`). Kept OUT of `set_position` because the cast path +/// (`cast_qt.rs`) calls that one too and must stay on 1.0 the way the Slint +/// cast publish does (`cast_service.rs:1118`); a signature change there would +/// have silently locked casting to the local stream's fill. +/// +/// Deduped: a fully-available track holds 1.0 forever and costs no Qt-thread +/// hop (same pattern as `set_effective_stream`). +pub fn set_seekable_max(seekable_max: f32) { + let seekable_max = seekable_max.clamp(0.0, 1.0); + let (changed, snapshot) = with_model(|m| { + if m.seekable_max == seekable_max { + return false; + } + m.seekable_max = seekable_max; + true + }); + if changed { + publish(&snapshot); + } +} diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 191f4f9b6..1e6fd5986 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -12,17 +12,6 @@ //! — add there, not here, so a note cannot outlive its gap unnoticed. That is //! exactly how gapless stayed broken: this header advertised it for weeks. //! -//! - Blacklist filtering of the QUEUE (PARITY-DEBT #1, AUDIBLE): the old note -//! here said "store not open", which is FALSE — `artist_blacklist` is bound -//! at `auth_qt.rs`. The Slint drops blacklisted tracks at ~18 play/enqueue -//! entry points (`filter_blacklisted_queue`, playback.rs:2624-2651); this -//! module has none, so a blocked artist still plays. -//! - `advance_to_playable`, the bounded skip-unavailable walk -//! (playback.rs:455-511) (PARITY-DEBT #2, AUDIBLE): the advance here takes -//! the core queue's next directly and a failed play logs and returns, so one -//! unavailable track wedges the queue — and the early return skips the -//! refresh, leaving the bar on the previous track. -//! - Volume persistence (PARITY-DEBT #3, AUDIBLE): never written or restored. //! - Session persist / resume-position: both prefs are persisted by settings //! and read by nobody. //! - Streaming quality: seeded from ui_prefs ("streaming_quality") and @@ -31,10 +20,22 @@ //! - Offline-cache tier (offline bytes), prefetch warming, stop-after, //! infinite refill, QConnect branches, recently-played recording. //! -//! DONE, previously listed here: gapless prefetch — the engine raises -//! `gapless_ready` ~10s out and this module now answers it with `play_next` -//! (both the streaming and the local/DSD arms), so transitions are -//! sample-accurate again. +//! DONE, previously listed here — kept as one line each so the list above +//! only ever names REAL gaps: +//! - Gapless prefetch: the engine raises `gapless_ready` ~10s out and this +//! module now answers it with `play_next` (both the streaming and the +//! local/DSD arms), so transitions are sample-accurate again. +//! - Blacklist filtering of the QUEUE (#1) — `filter_blacklisted_queue` + +//! `set_queue_stamped` / `stamped`, the two seams every play/enqueue path +//! already went through. +//! - The bounded skip-unavailable walk (#2) — `auto_skip_unavailable`. +//! - Volume persistence (#3) — persisted on drag-end, restored at shell entry. +//! - The stream-failure toast with the Flatpak/Snap ALSA rewrite (#7) — +//! `stream_error_text`, drained in the poll loop. +//! - The streaming seek lock (#15) — `seekable_max` from `buffer_progress`, +//! published as `np_seekable_max`. +//! - Shuffle-play reorders the queue instead of latching the global shuffle +//! MODE (#17) — `xorshift_shuffle` in `play_track_list_in`. use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; @@ -116,6 +117,15 @@ impl PlayContext { pub fn playlist(id: &str) -> Option { Self::new("playlist", id) } + + /// A label page's Popular Tracks. The reference stamps this on BOTH label + /// play paths — the header Shuffle (`playback.rs:3149`) and a row click + /// (`:3541`) — and it cannot be derived: a label's popular tracks share + /// neither an album nor an artist, so `derive_context` returns None and + /// the queue would ship contextless (PARITY-DEBT #17's reference path). + pub fn label(id: &str) -> Option { + Self::new("label", id) + } } /// Infer the container from the queue itself: one shared album -> that album, @@ -652,6 +662,49 @@ pub async fn play_album_from_track( Ok(()) } +/// The reference's shuffle: a Fisher–Yates pass driven by an xorshift64 PRNG +/// seeded from the wall clock (`playback.rs:3131-3144`, byte-for-byte the same +/// mix as `play_album_shuffled` / `play_artist_top_shuffled`). Split from the +/// seed so the permutation is unit-testable — the seedless wrapper is what the +/// play paths call. +fn xorshift_shuffle_seeded(items: &mut [T], seed: u64) { + // The reference ORs the seed with 1 at construction: xorshift64 is stuck + // on zero forever, and a nanosecond clock can land there. + let mut seed = seed | 1; + for i in (1..items.len()).rev() { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let j = (seed % (i as u64 + 1)) as usize; + items.swap(i, j); + } +} + +/// `xorshift_shuffle_seeded` with the reference's wall-clock seed. +fn xorshift_shuffle(items: &mut [T]) { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1); + xorshift_shuffle_seeded(items, seed); +} + +/// The toast text for a failed audio stream (`playback.rs:5102-5113`). +/// `sandboxed` is the caller's `detect_sandbox() != Sandbox::None`: inside a +/// Flatpak/Snap sandbox direct `hw:` access is blocked by design, so an +/// ALSA-shaped failure is rewritten into the actionable sentence instead of +/// the raw engine error. Pure, so the rewrite rule is unit-tested. +fn stream_error_text(msg: &str, sandboxed: bool) -> String { + let lower = msg.to_lowercase(); + if sandboxed && (lower.contains("alsa") || lower.contains("hw:")) { + qbz_i18n::t( + "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default", + ) + } else { + format!("{}: {}", qbz_i18n::t("Audio output error"), msg) + } +} + /// Play a pre-built queue starting at `start` (ArtistView Popular Tracks: /// the visible list becomes the queue, anchored at the clicked track). /// @@ -659,6 +712,9 @@ pub async fn play_album_from_track( /// `stamp_context`), so this path can never publish a contextless track — the /// artist page's Popular Tracks span many albums but one artist and resolve to /// ("artist", artist_id) without the caller lifting a finger. +/// +/// `shuffle` REORDERS THIS QUEUE — it does not switch the player's shuffle +/// mode. See `play_track_list_in`. pub async fn play_track_list( runtime: &Arc>, tracks: Vec, @@ -671,6 +727,18 @@ pub async fn play_track_list( /// `play_track_list` with an EXPLICIT origin — preferred whenever the caller /// knows it (artist page, playlist, label), because it survives a queue whose /// tracks share nothing derivable. +/// +/// PARITY-DEBT #17. `shuffle` means "play THIS list in a random order", which +/// in the reference is a one-shot reorder of the Vec followed by a play from +/// index 0 — the global shuffle MODE is never touched +/// (`playback.rs:3120-3145 play_label_top_shuffled`, and the identical body in +/// `play_artist_top_shuffled`:3083-3111 / `play_album_shuffled`:3945-3957). +/// This port used to answer the flag with `core().set_shuffle(true)` + +/// `now_playing::set_shuffle(true)`, so pressing Shuffle on a label or artist +/// page silently latched the bar's shuffle toggle ON for everything played +/// afterwards, AND still started on the list's #1 track. Both callers that +/// pass `true` (`label_qt::play_top`, `main::play_artist_top`) mirror a Slint +/// entry point that reorders in place, so the flag is reordering here too. pub async fn play_track_list_in( runtime: &Arc>, tracks: Vec, @@ -681,10 +749,16 @@ pub async fn play_track_list_in( if tracks.is_empty() { return Err("empty track list".to_string()); } - if shuffle { - runtime.core().set_shuffle(true).await; - crate::now_playing::set_shuffle(true); - } + // Shuffle-play starts at the top of the SHUFFLED order (`tracks[0]` after + // the mix, exactly as the reference does), so the caller's anchor index is + // meaningless on this path and is dropped rather than carried. + let (tracks, start) = if shuffle { + let mut tracks = tracks; + xorshift_shuffle(&mut tracks); + (tracks, 0) + } else { + (tracks, start) + }; let start = start.min(tracks.len() - 1); let first_id = tracks[start].id; set_queue_stamped(runtime, tracks, Some(start), context).await; @@ -1344,10 +1418,22 @@ pub fn start_poll_loop(runtime: Arc>) { loop { ticker.tick().await; - // Surface audio-stream failures once (playback.rs drains the same - // slot for its toast). + // --- Stream-failure toast (PARITY-DEBT #7) ------------------- + // Surface audio-stream failures as a toast, 1:1 with + // `playback.rs:5093-5115` (#508/#534/#500). The player records a + // user-readable message when a stream fails to open; this port + // drained the slot and only logged it, so an ALSA Direct / + // exclusive-mode failure left the bar frozen at 0:00 with no + // explanation — the support issue the Slint fix was written for. + // take_stream_error_message() drains exactly once per recorded + // error, so each failed attempt toasts once. if let Some(msg) = runtime.core().player().state.take_stream_error_message() { log::error!("[qbz-qt] audio output error: {msg}"); + let sandboxed = !matches!( + qbz_audio::health::detect_sandbox(), + qbz_audio::health::Sandbox::None + ); + crate::toast_qt::error(stream_error_text(&msg, sandboxed)); } // CAST: the cast service runs its own 1 s poll and the local player is @@ -1357,6 +1443,11 @@ pub fn start_poll_loop(runtime: Arc>) { last_track_id = 0; was_playing = false; seen_position = 0; + // A cast target owns playback and there is no local download to + // outrun: seek is unlocked while casting, the way the Slint cast + // publish forces it (`cast_service.rs:1118`). Without this the last + // local stream's fraction would stay latched on the bar. + crate::now_playing::set_seekable_max(1.0); continue; } let event = runtime.core().player().get_playback_event(); @@ -1365,6 +1456,12 @@ pub fn start_poll_loop(runtime: Arc>) { let duration = event.duration; let is_playing = event.is_playing; let cache = event.buffer_progress.unwrap_or(0.0); + // Seek lock (PARITY-DEBT #15, playback.rs:5304): while streaming + // (`buffer_progress` is Some) the user can only seek up to what + // has downloaded; a fully-available track (None) seeks freely. + // `cache` above is the DECORATIVE overlay of the same fill — this + // is the value the seek bars must enforce. + let seekable_max = event.buffer_progress.map(|p| p.clamp(0.0, 1.0)).unwrap_or(1.0); // --- Track-change edge (new current track surfaced) ---------- if track_id != 0 && track_id != last_track_id { @@ -1398,6 +1495,10 @@ pub fn start_poll_loop(runtime: Arc>) { } // --- Position / state push ------------------------------------ + // Seek lock first, so the bar never renders a position push whose + // limit still belongs to the previous tick (the setter dedupes, + // so a fully-available track costs no extra hop). + crate::now_playing::set_seekable_max(seekable_max); crate::now_playing::set_position( position as i32, duration as i32, @@ -1537,8 +1638,11 @@ pub fn start_poll_loop(runtime: Arc>) { && seen_position + 2 >= duration; if track_ended { last_track_id = 0; - // POC-NOTE: no stop-after / infinite-refill / skip-unavailable - // walk — the core queue's own next (repeat/shuffle aware). + // POC-NOTE: no stop-after / infinite-refill — the core queue's + // own next (repeat/shuffle aware). The skip-unavailable walk + // this note also used to claim is missing IS ported: a failed + // play inside `play_queue_track` hands off to + // `auto_skip_unavailable` (PARITY-DEBT #2). if let Some(track) = runtime.core().next_track().await { let next_id = track.id; log::info!("[qbz-qt] poll: advancing to {next_id}"); @@ -1564,7 +1668,102 @@ pub fn start_poll_loop(runtime: Arc>) { #[cfg(test)] mod tests { - use super::filter_queue_with; + use super::{filter_queue_with, stream_error_text, xorshift_shuffle_seeded}; + + // --- PARITY-DEBT #17: the shuffle flag reorders, it does not switch mode + // + // The permutation itself is part of the contract being ported: the + // reference walks Fisher–Yates DOWNWARD (`for i in (1..len).rev()`) over + // an xorshift64 stream seeded once. A different loop direction or a + // different mix order is still "random" and still passes a smoke test, so + // it is pinned here against an independently-computed expectation. + + /// Reference permutation for seed 0x2545F4914F6CDD1D over 0..8, computed + /// from the algorithm at `playback.rs:3131-3144`. + #[test] + fn xorshift_shuffle_matches_the_reference_permutation() { + let mut items: Vec = (0..8).collect(); + xorshift_shuffle_seeded(&mut items, 0x2545_F491_4F6C_DD1D); + assert_eq!(items, vec![1, 0, 3, 2, 6, 5, 4, 7]); + } + + /// A second seed, so the test pins the PRNG and not one lucky constant. + #[test] + fn xorshift_shuffle_is_seed_driven() { + let mut items: Vec = (0..5).collect(); + xorshift_shuffle_seeded(&mut items, 12345); + assert_eq!(items, vec![3, 0, 2, 1, 4]); + } + + /// The reference's loop never runs for 0 or 1 elements — a one-track label + /// Shuffle must not panic on the `1..len` range. + #[test] + fn xorshift_shuffle_is_a_no_op_below_two_elements() { + let mut one = vec![7u32]; + xorshift_shuffle_seeded(&mut one, 42); + assert_eq!(one, vec![7]); + let mut none: Vec = vec![]; + xorshift_shuffle_seeded(&mut none, 42); + assert!(none.is_empty()); + } + + /// The seed is OR-ed with 1 the way the reference does, so a zero clock + /// reading cannot park xorshift64 on its fixed point (which would leave + /// the list in its original order — a "shuffle" that never shuffles). + #[test] + fn a_zero_seed_still_shuffles() { + let mut items: Vec = (0..8).collect(); + xorshift_shuffle_seeded(&mut items, 0); + assert_ne!(items, (0..8).collect::>()); + } + + /// Whatever the seed, shuffling is a permutation: nothing is lost and + /// nothing is duplicated (the queue the user gets is the queue they asked + /// for, reordered). + #[test] + fn xorshift_shuffle_is_a_permutation() { + for seed in [1u64, 2, 3, 999, u64::MAX] { + let mut items: Vec = (0..64).collect(); + xorshift_shuffle_seeded(&mut items, seed); + items.sort_unstable(); + assert_eq!(items, (0..64).collect::>()); + } + } + + // --- PARITY-DEBT #7: the stream-error toast text ---------------------- + // + // Tests run with the default (English) catalog, so `qbz_i18n::t` returns + // the msgid — which is exactly the string the reference builds. + + /// Outside a sandbox the raw engine error is shown, prefixed. + #[test] + fn stream_error_outside_a_sandbox_keeps_the_raw_message() { + assert_eq!( + stream_error_text("ALSA: device or resource busy", false), + "Audio output error: ALSA: device or resource busy" + ); + } + + /// Inside a sandbox an ALSA-shaped failure is rewritten into the + /// actionable sentence (`playback.rs:5105-5111`). + #[test] + fn stream_error_inside_a_sandbox_rewrites_alsa_failures() { + let expected = "Direct ALSA device access is blocked inside Flatpak/Snap — switch the audio backend to PipeWire or System Default"; + assert_eq!(stream_error_text("ALSA open failed", true), expected); + // The match is case-insensitive and also fires on the device string. + assert_eq!(stream_error_text("cannot open hw:1,0", true), expected); + assert_eq!(stream_error_text("alsa: no such device", true), expected); + } + + /// A sandboxed failure that is NOT ALSA-shaped keeps the raw message — + /// the rewrite must not swallow an unrelated cause. + #[test] + fn stream_error_inside_a_sandbox_keeps_non_alsa_failures() { + assert_eq!( + stream_error_text("HTTP 403 from the CDN", true), + "Audio output error: HTTP 403 from the CDN" + ); + } /// Drop the odd numbers; `start` must follow the CLICKED item, not its old /// index. Without the remap the core would start on a different track than diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index 55b469d80..53a77cdb5 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -36,6 +36,14 @@ pub mod qbz_player { #[qproperty(i32, np_duration_secs)] #[qproperty(f32, np_progress)] #[qproperty(f32, np_cache_progress)] + // SEEK LOCK (PARITY-DEBT #15, Slint `NowPlayingState.seekable-max`, + // playback.rs:5304). The fraction of the track the user may seek to: + // while a stream is still downloading it is `buffer_progress` clamped + // to 0..1, and a fully-available track (local/offline/complete) is + // 1.0. The seek bars must clamp the drag/click position to it — + // `np_cache_progress` is the DECORATIVE overlay of the same fill and + // must not be used as the limit. + #[qproperty(f32, np_seekable_max)] #[qproperty(bool, np_playing)] #[qproperty(bool, np_loading)] #[qproperty(f32, np_volume)] @@ -198,6 +206,7 @@ pub struct QbzPlayerRust { np_duration_secs: i32, np_progress: f32, np_cache_progress: f32, + np_seekable_max: f32, np_playing: bool, np_loading: bool, np_volume: f32, @@ -243,6 +252,10 @@ impl Default for QbzPlayerRust { np_duration_secs: 0, np_progress: 0.0, np_cache_progress: 0.0, + // Seek freely until a stream says otherwise — the Slint default + // (`NowPlayingState.seekable-max: 1.0`). Zero here would lock the + // bar shut before the first track ever opens. + np_seekable_max: 1.0, np_playing: false, np_loading: false, np_muted: false,