fix(qt): four AUDIBLE parity rows — stream errors, seek clamp, label shuffle, Plex keys, Library queue
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:<hash>` 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
This commit is contained in:
11 files changed
+759
-60
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<u64>() 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}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::<u64>() { 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<QueueTrack> {
|
||||
let id = item.id.parse::<u64>().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::<u64>().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::<u64>().ok()).unwrap_or(0) * 60
|
||||
+ parts.next().and_then(|s| s.parse::<u64>().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<QueueTrack>, 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<QueueTrack> = 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<String> = 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::<u64>() {
|
||||
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<_>>(),
|
||||
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<_>>(), 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AlbumDetailDoc>) {
|
||||
/// 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:<hash>`
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<hash>` 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:<hash>` 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:<hash>`). 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
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> {
|
||||
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<T>(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<T>(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<AppRuntime<LoggingAdapter>>,
|
||||
tracks: Vec<QueueTrack>,
|
||||
@@ -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<AppRuntime<LoggingAdapter>>,
|
||||
tracks: Vec<QueueTrack>,
|
||||
@@ -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<AppRuntime<LoggingAdapter>>) {
|
||||
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<AppRuntime<LoggingAdapter>>) {
|
||||
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<AppRuntime<LoggingAdapter>>) {
|
||||
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<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
|
||||
// --- 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<AppRuntime<LoggingAdapter>>) {
|
||||
&& 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<AppRuntime<LoggingAdapter>>) {
|
||||
|
||||
#[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<u32> = (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<u32> = (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<u32> = 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<u32> = (0..8).collect();
|
||||
xorshift_shuffle_seeded(&mut items, 0);
|
||||
assert_ne!(items, (0..8).collect::<Vec<u32>>());
|
||||
}
|
||||
|
||||
/// 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<u32> = (0..64).collect();
|
||||
xorshift_shuffle_seeded(&mut items, seed);
|
||||
items.sort_unstable();
|
||||
assert_eq!(items, (0..64).collect::<Vec<u32>>());
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user