Files
qbz/crates/qbz-qt/src/playback_qt.rs
T
vicrodh 9174b33b1b feat(qt): A-B loop from the player bar's + menu
Mark A, mark B, loop between them until cleared; local playback only,
never persisted, cleared on track change or a seek outside the section.
A 100 ms watcher reads the millisecond position and reissues the local
seek; the seekbar shows the section and its two marks.

cc
2026-09-13 03:50:29 -06:00

6192 lines
273 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Minimal playback controller — drives `QbzCore`'s EXISTING public API
//! only (qbz-audio / qbz-player are protected; no audio logic is
//! reimplemented here). Ports the *shape* of `crates/qbz/src/playback.rs`:
//! play_album (fetch -> QueueTrack vec -> set_queue -> play_track_resolved)
//! and the poll loop (PlaybackEvent -> UI state, track-change meta refresh,
//! end-of-track advance).
//!
//! Frontend-owned playback responsibilities implemented here include:
//! - Stop-after-this-song (#35) — the end-of-track arm consumes the marker and
//! pauses AHEAD of repeat/shuffle, and the gapless pre-queue guard that was
//! already here now defends a marker the UI can actually place.
//! - Infinite refill (#33) — `try_infinite_refill` chains a smart artist radio
//! off the ended track when the queue runs out and the ∞ toggle is on.
//! - 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) + the unavailability drop (contract
//! 2026-08-17 §5.3 D5) — `filter_unplayable_queue` behind
//! `set_queue_stamped` / `stamped`, the two seams every play/enqueue path
//! already went through. Both seams DROP; `set_queue_stamped` additionally
//! returns the surviving anchor, because a caller that computes its own
//! `first_id` off the pre-filter list plays a track the core does not hold
//! (F1 — silent, and reachable today through the blacklist alone).
//! - 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`.
//! - Recently-played / Most-played recording — `recently_qt::record_queue_track`
//! on the de-duped track edge, EPHEMERAL-EXEMPT: an ephemeral folder plays
//! and leaves no row behind (owner rule, 2026-07-30). The Slint reference
//! does NOT have that exemption; do not "restore parity" by removing it.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::{PageArtistTrack, Quality, QueueTrack, RepeatMode};
use qbz_player::PlaybackEvent;
/// A lease for work that belongs to the signed-in owner's local queue.
///
/// QConnect is initialized after the playback surface during shell boot, so a
/// missing service means the ordinary pre-QConnect local path is still valid.
/// Once the service exists, however, failure to obtain its permit means a
/// delegated renderer owns the queue (or an authority handoff is draining):
/// callers must treat the local action as handled/refused and do no work.
pub(crate) struct OwnerActionLease {
_permit: Option<qconnect_app::AuthorityActionPermit>,
token: OwnerActionToken,
}
/// Copyable stamp for a continuation derived from one exact owner snapshot.
///
/// `BeforeService` is intentionally invalidated as soon as the QConnect
/// singleton appears. Otherwise work queued during shell boot could be
/// re-admitted after an authority generation that did not exist when its
/// inputs were read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OwnerActionToken {
BeforeService,
Qconnect(qconnect_app::OwnerAuthorityToken),
}
/// Atomic classification used by playback observations that also own edge
/// bookkeeping. A fence is neither owner nor guest: it is a short retry window
/// while a still-fallible candidate drains owner work.
enum OwnerActionObservation {
Owner(OwnerActionLease),
Delegated,
Fenced,
}
impl OwnerActionLease {
pub(crate) const fn token(&self) -> OwnerActionToken {
self.token
}
}
/// A local transport control may operate on either owner or delegated audio,
/// but it still has to drain before an authority handoff can swap runtimes.
pub(crate) struct TransportActionLease {
_permit: Option<qconnect_app::AuthorityActionPermit>,
}
pub(crate) fn begin_owner_action() -> Option<OwnerActionLease> {
match crate::qconnect_qt::service() {
None => Some(OwnerActionLease {
_permit: None,
token: OwnerActionToken::BeforeService,
}),
Some(service) => service
.try_owner_action_permit_observed()
.map(|(token, permit)| OwnerActionLease {
_permit: Some(permit),
token: OwnerActionToken::Qconnect(token),
}),
}
}
fn observe_owner_action() -> OwnerActionObservation {
match crate::qconnect_qt::service() {
None => OwnerActionObservation::Owner(OwnerActionLease {
_permit: None,
token: OwnerActionToken::BeforeService,
}),
Some(service) => match service.observe_owner_authority() {
qconnect_app::OwnerAuthorityObservation::Owner { token, permit } => {
OwnerActionObservation::Owner(OwnerActionLease {
_permit: Some(permit),
token: OwnerActionToken::Qconnect(token),
})
}
qconnect_app::OwnerAuthorityObservation::Delegated => OwnerActionObservation::Delegated,
qconnect_app::OwnerAuthorityObservation::Fenced => OwnerActionObservation::Fenced,
},
}
}
enum OwnerScopedSnapshot<T> {
Captured {
owner_action: Option<OwnerActionLease>,
snapshot: T,
},
Fenced,
}
/// Freeze the authority observation before reading a local playback snapshot.
/// A fallible transition fence does not consume the snapshot at all; delegated
/// playback may still be read for UI/reporting but carries no owner permit.
fn capture_owner_scoped_snapshot<T>(
observe: impl FnOnce() -> OwnerActionObservation,
read: impl FnOnce() -> T,
) -> OwnerScopedSnapshot<T> {
match observe() {
OwnerActionObservation::Owner(owner_action) => OwnerScopedSnapshot::Captured {
owner_action: Some(owner_action),
snapshot: read(),
},
OwnerActionObservation::Delegated => OwnerScopedSnapshot::Captured {
owner_action: None,
snapshot: read(),
},
OwnerActionObservation::Fenced => OwnerScopedSnapshot::Fenced,
}
}
fn owner_generation_changed(
observed: Option<OwnerActionToken>,
previous: Option<OwnerActionToken>,
) -> bool {
observed.is_some() && observed != previous
}
/// Re-admit a queued continuation only for the authority generation that read
/// its input. This is deliberately different from [`begin_owner_action`]: a
/// guest -> owner promotion must not legitimize work derived from guest state.
pub(crate) async fn begin_owner_action_exact(token: OwnerActionToken) -> Option<OwnerActionLease> {
match token {
OwnerActionToken::BeforeService => {
crate::qconnect_qt::service()
.is_none()
.then_some(OwnerActionLease {
_permit: None,
token,
})
}
OwnerActionToken::Qconnect(token) => crate::qconnect_qt::service()?
.wait_for_exact_owner_action_permit(token)
.await
.map(|permit| OwnerActionLease {
_permit: Some(permit),
token: OwnerActionToken::Qconnect(token),
}),
}
}
pub(crate) fn begin_transport_action() -> Option<TransportActionLease> {
match crate::qconnect_qt::service() {
None => Some(TransportActionLease { _permit: None }),
Some(service) => service
.try_transport_action_permit()
.map(|permit| TransportActionLease {
_permit: Some(permit),
}),
}
}
/// Cheap refusal check for route funnels. The real local operation always
/// acquires its own lease as well; this check only makes a delegated click look
/// handled instead of falling through to an error/toast path.
fn delegated_authority_owns_queue() -> bool {
begin_owner_action().is_none()
}
/// The request tier for every play. Persisted in ui_prefs.json ("streaming_quality")
/// and applied here from Settings > Audio (settings_qt). Default "hires_plus".
static STREAMING_QUALITY: RwLock<Quality> = RwLock::new(Quality::UltraHiRes);
/// Map a ui_prefs quality key to the request tier (settings.rs STREAMING_QUALITIES).
fn quality_for_key(key: &str) -> Quality {
match key {
"mp3" => Quality::Mp3,
"cd" => Quality::Lossless,
"hires" => Quality::HiRes,
_ => Quality::UltraHiRes,
}
}
/// Settings > Audio > Streaming quality writes through here (also used at
/// startup to seed from the persisted prefs).
pub fn set_streaming_quality(key: &str) {
*STREAMING_QUALITY.write().unwrap() = quality_for_key(key);
}
/// The raw streaming-quality PREFERENCE, uncapped.
///
/// Three callers, and that is the whole list by design: the capped resolver
/// below, and `cast_qt::effective_cast_quality`, which must NEVER see the
/// local cap (#638: the local DAC is not in a cast's signal path). Every
/// LOCAL play goes through `local_playback_quality` instead — reach for this
/// one only if you can say why a cap must not apply.
pub(crate) fn current_quality() -> Quality {
*STREAMING_QUALITY.read().unwrap()
}
/// The request tier for LOCAL playback + the cause that shaped it — the one
/// place the preference and the detected device cap (#638 fix 3) are
/// reconciled. Port of `crates/qbz/src/playback.rs local_playback_quality`.
///
/// Costs two uncontended `RwLock` reads (`STREAMING_QUALITY` + the device-cap
/// cache), so it is safe to resolve per play; nothing here reads a file or
/// probes hardware. That is what lets the play funnel own the decision
/// instead of taking it as a parameter.
pub(crate) fn local_playback_quality() -> (Quality, qbz_models::QualityLimit) {
reconcile_device_cap(
current_quality(),
qbz_app::device_cap::cap().map(|(t, _)| t),
)
}
/// The decision itself, lifted out of the two lock reads so it is testable.
/// Port of the match in `crates/qbz/src/playback.rs local_playback_quality`.
fn reconcile_device_cap(
pref: Quality,
cap: Option<Quality>,
) -> (Quality, qbz_models::QualityLimit) {
use qbz_models::QualityLimit;
match cap {
Some(cap) if cap < pref => (cap, QualityLimit::LocalDeviceCap),
// The TIE arm, and it is not redundant: when the cap equals the
// preference and both are below Hi-Res+, the device is the more
// specific and more surprising of the two causes, so it is the one the
// badge tooltip names. Without this the user reads "your preference"
// while the real reason they cannot go higher is the DAC.
Some(cap) if cap == pref && cap < Quality::UltraHiRes => {
(pref, QualityLimit::LocalDeviceCap)
}
_ => (
pref,
if pref < Quality::UltraHiRes {
QualityLimit::Preference
} else {
QualityLimit::None
},
),
}
}
/// How many immediate successors a new-track edge warms into L1/L2. This is
/// deliberately independent of the engine's `GAPLESS_LEAD_SECS`: the full
/// downloads begin as soon as the current track surfaces, while the existing
/// ~10 s gapless stage later consumes the first successor from cache (or its
/// normal fallback) and hands it to `play_next`.
const PREFETCH_LOOKAHEAD: usize = 2;
/// Seconds with nothing audible (local, remote or cast) before the poll loop
/// moves the L1 audio cache to disk. Five minutes: long enough that a pause
/// to answer the door keeps its warm cache, short enough that an app parked
/// after a session does not hold a listening session's worth of bytes.
const IDLE_L1_TRIM_SECS: u64 = 300;
static MEMORY_APPLY_BUSY: AtomicBool = AtomicBool::new(false);
pub(crate) fn memory_apply_busy() -> bool {
MEMORY_APPLY_BUSY.load(Ordering::SeqCst)
}
/// Explicit, confirmed rebuild of local playback buffers. The authority lane
/// drains owner work and prevents the poller from repopulating gapless/L1 while
/// Stop is acknowledged. Disk cache, queue order and audio settings survive.
pub(crate) async fn apply_playback_memory(request: &str) -> Result<(), String> {
let (mode, expected_track) = request.split_once(':')
.filter(|(mode, _)| matches!(*mode, "idle" | "restart"))
.ok_or("Playback changed. Apply again to confirm the current track.")?;
if MEMORY_APPLY_BUSY.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
return Ok(());
}
struct BusyGuard;
impl Drop for BusyGuard {
fn drop(&mut self) { MEMORY_APPLY_BUSY.store(false, Ordering::SeqCst); }
}
let _busy = BusyGuard;
crate::settings_qt::publish_snapshot().await;
let runtime = crate::APP.get().ok_or("Playback is not ready yet.")?;
let service = crate::qconnect_qt::service().ok_or("Playback is not ready yet.")?;
let (_lane, _fence) = tokio::time::timeout(
std::time::Duration::from_secs(20), service.playback_memory_fence(),
).await.map_err(|_| "Playback is busy. Try applying the memory profile again.".to_string())??;
if crate::cast_qt::is_casting().await || service.is_peer_renderer_active().await {
return Err("Playback memory can only be applied to local playback.".into());
}
let player = runtime.core().player();
let event = player.get_playback_event();
let pending = PENDING_PLAY_ID.load(Ordering::Relaxed);
let restart = event.is_playing || pending != 0;
let active = player.has_loaded_audio() || event.is_playing || pending != 0;
let current = runtime.core().current_track().await;
if (restart && mode != "restart")
|| (active && !current.as_ref().is_some_and(|t| t.id.to_string() == expected_track)) {
return Err("Playback changed. Apply again to confirm the current track.".into());
}
let _ = begin_resolved_play();
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
player.stop_and_evict_playback_memory().await?;
crate::now_playing::clear_loading();
crate::now_playing::set_position(0, event.duration as i32, false, 0.0, false);
if let Some(track) = current {
let _ = qbz_app::session_persist::take_resume_for(track.id);
if restart {
crate::now_playing::begin_loading();
let result = restore_owner_playback(runtime, track.id, 0, true).await;
crate::now_playing::clear_loading();
result?;
}
}
refresh_now_playing(runtime).await;
log::info!("[playback-memory] Applied profile; old playback buffers and L1 released, L2 retained");
Ok(())
}
/// Shared upper bound across overlapping track edges. A new current track can
/// surface while the second successor of the previous edge is still warming;
/// the player's own fetching marker de-duplicates the overlap, while this
/// semaphore keeps unrelated downloads bounded.
const MAX_CONCURRENT_PREFETCH: usize = 2;
static PREFETCH_SEMAPHORE: tokio::sync::Semaphore =
tokio::sync::Semaphore::const_new(MAX_CONCURRENT_PREFETCH);
/// Every background download that can retain an owner-action permit. The
/// delegation host closes admission first, then advances the generation and
/// aborts this registry before waiting for the authority drain. A start gate
/// makes registration race-free: a task cannot touch I/O until its AbortHandle
/// is either visible to cancellation or rejected by the newer generation.
static OWNER_PLAYBACK_TASK_GENERATION: AtomicU64 = AtomicU64::new(0);
static OWNER_PLAYBACK_TASKS: OnceLock<Mutex<Vec<(u64, tokio::task::AbortHandle)>>> =
OnceLock::new();
/// Frontend generation for the policy work that happens before the player's
/// own `begin_play`: preference lookup and bounded filesystem probes. The
/// player protects its fetch/decode stages, but it cannot fence work that has
/// not called it yet. A newer resolved start invalidates an older lookup
/// before that lookup is allowed to materialize a ticket or enter fallback.
static RESOLVED_PLAY_GENERATION: AtomicU64 = AtomicU64::new(0);
fn begin_resolved_play() -> u64 {
RESOLVED_PLAY_GENERATION
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |generation| {
generation.checked_add(1)
})
.expect("resolved playback generation exhausted")
+ 1
}
fn resolved_play_is_current(generation: u64) -> bool {
RESOLVED_PLAY_GENERATION.load(Ordering::SeqCst) == generation
}
fn owner_playback_tasks() -> &'static Mutex<Vec<(u64, tokio::task::AbortHandle)>> {
OWNER_PLAYBACK_TASKS.get_or_init(|| Mutex::new(Vec::new()))
}
fn owner_playback_task_generation() -> u64 {
OWNER_PLAYBACK_TASK_GENERATION.load(Ordering::SeqCst)
}
fn spawn_owner_playback_task<F>(future: F) -> tokio::task::JoinHandle<()>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let generation = owner_playback_task_generation();
let (start_tx, start_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
if start_rx.await.is_err() || owner_playback_task_generation() != generation {
return;
}
future.await;
});
let abort = task.abort_handle();
let should_start = {
let mut tasks = owner_playback_tasks()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
tasks.retain(|(_, handle)| !handle.is_finished());
if owner_playback_task_generation() == generation {
tasks.push((generation, abort.clone()));
true
} else {
false
}
};
if should_start {
let _ = start_tx.send(());
} else {
abort.abort();
}
task
}
/// Cancel downloads that were admitted before a transition fence. Callers
/// must close owner admission first; advancing the generation then makes a
/// concurrent registration either visible in the registry or self-rejecting.
pub(crate) fn cancel_owner_playback_tasks() {
OWNER_PLAYBACK_TASK_GENERATION
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |generation| {
generation.checked_add(1)
})
.expect("owner playback task generation exhausted");
let handles = {
let mut tasks = owner_playback_tasks()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
tasks
.drain(..)
.map(|(_, handle)| handle)
.collect::<Vec<_>>()
};
for handle in handles {
handle.abort();
}
}
/// The prefetch edge is separate from `last_track_id`: the latter belongs to
/// the LOCAL UI/integration pump and is intentionally reset while casting,
/// whereas immediate warming must follow whichever transport owns playback.
/// Zero means no owner/current track and rearms a later replay of the same id.
#[derive(Debug, Default)]
struct PrefetchTrackEdge {
last_track_id: u64,
}
impl PrefetchTrackEdge {
fn observe(&mut self, track_id: u64) -> bool {
if track_id == 0 {
self.last_track_id = 0;
return false;
}
if track_id == self.last_track_id {
return false;
}
self.last_track_id = track_id;
true
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PrefetchCandidate {
id: u64,
is_local: bool,
}
impl From<&QueueTrack> for PrefetchCandidate {
fn from(track: &QueueTrack) -> Self {
Self {
id: track.id,
is_local: track.is_local,
}
}
}
/// Pure policy seam for the deterministic contract tests. `upcoming` is
/// already the queue's repeat/shuffle-aware next-two snapshot; filtering a
/// local row does not reach beyond that snapshot and accidentally warm a third
/// successor.
fn prefetch_track_ids(
streaming_only: bool,
offline: bool,
throttle_cap: usize,
upcoming: &[PrefetchCandidate],
) -> Vec<u64> {
if streaming_only || offline || throttle_cap == 0 {
return Vec::new();
}
upcoming
.iter()
.take(PREFETCH_LOOKAHEAD)
.filter(|track| track.id != 0 && !track.is_local)
.map(|track| track.id)
.collect()
}
fn prefetch_quality_tag(quality: Quality) -> qbz_audio::network_throttle::PlaybackQualityTag {
use qbz_audio::network_throttle::PlaybackQualityTag;
match quality {
Quality::UltraHiRes => PlaybackQualityTag::UltraHiRes,
Quality::HiRes => PlaybackQualityTag::HiRes,
Quality::Lossless => PlaybackQualityTag::Lossless,
Quality::Mp3 => PlaybackQualityTag::Lossy,
}
}
/// Streaming-only gapless policy. Cached/offline successors stay on the
/// established byte path; only a cold network successor needs the bounded
/// initial-buffer handoff. A zero adaptive cap protects a live stream that is
/// already struggling instead of competing with it for the link.
#[derive(Debug, PartialEq, Eq)]
enum GaplessSource {
Download,
Catalog,
Local,
}
fn gapless_source(track: &QueueTrack) -> GaplessSource {
if crate::local_playback::belongs_to_the_offline_tier(track) {
GaplessSource::Download
} else if track.is_local {
GaplessSource::Local
} else {
GaplessSource::Catalog
}
}
fn should_stream_gapless_successor(
_streaming_only: bool,
player_cached: bool,
offline_cached: bool,
throttle_cap: usize,
) -> bool {
!offline_cached && (player_cached || throttle_cap > 0)
}
/// The queue/engine edge a completed gapless fetch still belongs to.
///
/// Full-track materialization can outlive the predecessor. Checking only the
/// requested successor lets a late result append that successor behind itself
/// after the normal end-edge already started it. Queue edits and a late
/// stop-after mark are equally stale. Keep the policy pure so all of those
/// races have deterministic coverage.
fn gapless_edge_matches(
predecessor_id: u64,
successor_id: u64,
engine_track_id: u64,
engine_queued_id: u64,
stop_after_id: Option<u64>,
queue_successor_id: Option<u64>,
) -> bool {
predecessor_id != 0
&& successor_id != 0
&& predecessor_id != successor_id
&& engine_track_id == predecessor_id
&& engine_queued_id == 0
&& stop_after_id != Some(predecessor_id)
&& queue_successor_id == Some(successor_id)
}
async fn gapless_edge_is_current(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
predecessor_id: u64,
successor_id: u64,
) -> bool {
let player = runtime.core().player();
let engine_track_id = player.state.current_track_id();
let engine_queued_id = player.state.get_gapless_next_track_id();
let stop_after_id = runtime.core().get_stop_after().await;
let queue_successor_id = runtime
.core()
.peek_upcoming(1)
.await
.first()
.map(|track| track.id);
gapless_edge_matches(
predecessor_id,
successor_id,
engine_track_id,
engine_queued_id,
stop_after_id,
queue_successor_id,
)
}
/// Warm the next two queue rows in the player's shared L1/L2 cache. This only
/// performs cheap policy/queue work inline; every full download is spawned in
/// the background and goes through `Player::prefetch_into_cache`, which owns
/// the in-flight marker, cache re-check, 20 s failure cooldown and cache write.
async fn kick_prefetch(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
owner_snapshot: &OwnerActionLease,
) {
let owner_token = owner_snapshot.token();
let streaming_only = crate::settings_qt::audio_settings().streaming_only;
let offline = crate::offline_fwd::engine().is_offline();
if streaming_only || offline {
return;
}
// Resolve once so the bandwidth estimate and every spawned request agree.
// A renderer owns its own cap; the local DAC cap must never leak into the
// quality-blind cache that the cast path reads first.
let quality = crate::cast_qt::service()
.casting_prefetch_quality()
.await
.unwrap_or_else(|| local_playback_quality().0);
let memory = runtime.core().player().playback_prefetch_policy();
if qbz_app::memory_watchdog::prefetch_halted()
|| ((!memory.allow_hires || qbz_app::memory_watchdog::hires_prefetch_paused())
&& matches!(quality, Quality::HiRes | Quality::UltraHiRes)) { return; }
let throttle_cap = qbz_audio::network_throttle::state().current_prefetch_cap(
qbz_audio::network_throttle::playback_mbps_for_quality(prefetch_quality_tag(quality)),
MAX_CONCURRENT_PREFETCH.min(memory.concurrency),
);
if throttle_cap == 0 {
log::debug!("[qbz-qt] prefetch: skipped (network throttle cap 0)");
return;
}
let upcoming = runtime.core().peek_upcoming(PREFETCH_LOOKAHEAD.min(memory.lookahead)).await;
let candidates: Vec<PrefetchCandidate> = upcoming.iter().map(PrefetchCandidate::from).collect();
let track_ids = prefetch_track_ids(streaming_only, offline, throttle_cap, &candidates);
for track_id in track_ids {
let player = runtime.core().player();
if player.is_track_cached(track_id) {
continue;
}
let runtime = runtime.clone();
drop(spawn_owner_playback_task(async move {
let _permit = match PREFETCH_SEMAPHORE.acquire().await {
Ok(permit) => permit,
Err(_) => return,
};
let Some(_owner_action) = begin_owner_action_exact(owner_token).await else {
return;
};
let client_lock = runtime.core().client();
let guard = client_lock.read().await;
let Some(client) = guard.as_ref() else {
return;
};
let player = runtime.core().player();
if !player.playback_prefetch_policy().allow_hires && matches!(quality, Quality::HiRes | Quality::UltraHiRes) { return; }
if let Err(error) = player.prefetch_into_cache(client, track_id, quality).await {
log::debug!("[qbz-qt] prefetch: track {track_id} failed: {error}");
}
}));
}
}
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
// dedicated mute API on the core — mute is volume 0 with a stash).
/// The track id of a COLD START that has been dispatched but has not yet
/// surfaced on the poll loop's track-change edge. 0 = nothing in flight.
///
/// It exists because the cold-start branch in `toggle_play` opened a window the
/// resume branch never had: between dispatch and the first audio frame the
/// engine reports `is_playing == false` AND `has_loaded_audio() == false` — the
/// exact condition that branch tests — so a second Play press would dispatch a
/// SECOND `play_track_resolved` for the same id, racing the first. On this port
/// that window is seconds, not milliseconds (the ACTIVE_TASK notes an
/// unexplained 5459 ms CMAF init).
///
/// THE SHAPE MATTERS, and it is the reference's, not Tauri's. Tauri had a
/// consumed-once `pendingSessionRestore` token that it cleared BEFORE awaiting
/// the load and never re-armed in the catch (`playerStore.ts:522`, `:603-608`),
/// so ONE failed load consumed the restore intent and every later Play press
/// fell through to a bare resume against an empty engine — the Play button was
/// dead for the rest of the session, silently. That is a large part of why this
/// feature "nunca quedaba bien" there.
///
/// This latch cannot do that, because it never gates the ABILITY to start:
/// - Its branch CANCELS the in-flight load (stop + clear) rather than
/// refusing, so a press always does something and always leaves a state the
/// next press can act on.
/// - It is cleared on the track edge AND on every failure path, so a failed
/// cold start re-opens the cold-start branch instead of latching it shut.
/// - Even if it were somehow stranded set, the next press stops and clears it,
/// and the press after that cold-starts normally. It self-heals in both
/// directions; a consumed-once token heals in neither.
///
/// This is also the reference's #661 fix (`crates/qbz/src/playback.rs:4811-4824`):
/// a pause issued during the load window used to fall through to the resume
/// branch and be swallowed — the track started anyway and the sleep inhibitor
/// stayed held with no successful pause to release it.
static PENDING_PLAY_ID: AtomicU64 = AtomicU64::new(0);
static MUTED: AtomicBool = AtomicBool::new(false);
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
// ---------------------------------------------------------------------------
// Playback CONTEXT — the "playing from" origin (song-card layers glyph)
// ---------------------------------------------------------------------------
/// The container a queue was launched FROM. `kind` is "album" | "artist" |
/// "playlist" | "label" and `id` that container's navigation id — 1:1 with the
/// Slint `open-context(kind, id)` -> `media-action(kind, id, "open")` arms
/// (qbz/src/main.rs:12695 artist, :12701 album, :12707 playlist, :13206 label).
///
/// HARDENING (owner ask): the origin is NOT something a play path has to
/// remember. It travels WITH the queue — every play/enqueue entry point in this
/// module funnels through `set_queue_stamped` / `stamped`, which stamp it onto
/// EVERY track, and when the caller passes none it is DERIVED from the queue
/// itself (`derive_context`). A new entry point therefore cannot silently ship
/// an unstamped queue; the worst case is the same album fallback the Slint uses
/// in `refresh_now_playing_meta` (playback.rs:1959-1965).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlayContext {
pub kind: String,
pub id: String,
}
impl PlayContext {
/// None for an empty kind/id — an empty context must never be stamped (it
/// would shadow the per-track album fallback with a dead glyph).
pub fn new(kind: &str, id: &str) -> Option<Self> {
if kind.is_empty() || id.is_empty() {
return None;
}
Some(Self {
kind: kind.to_string(),
id: id.to_string(),
})
}
pub fn album(id: &str) -> Option<Self> {
Self::new("album", id)
}
pub fn artist(id: &str) -> Option<Self> {
Self::new("artist", id)
}
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,
/// otherwise one shared artist -> that artist (an artist page's Popular Tracks
/// span many albums but ONE artist — the exact case that shipped contextless).
/// Nothing shared -> None, and the per-track album fallback takes over.
fn derive_context(tracks: &[QueueTrack]) -> Option<PlayContext> {
let mut album: Option<&str> = None;
let mut one_album = true;
let mut artist: Option<u64> = None;
let mut one_artist = true;
for track in tracks {
match track.album_id.as_deref().filter(|s| !s.is_empty()) {
None => one_album = false,
Some(id) => {
if let Some(prev) = album {
if prev != id {
one_album = false;
}
} else {
album = Some(id);
}
}
}
match track.artist_id {
None => one_artist = false,
Some(id) => {
if let Some(prev) = artist {
if prev != id {
one_artist = false;
}
} else {
artist = Some(id);
}
}
}
}
// ORDER IS DELIBERATE — album before artist. Do not flip it: a one-album
// queue (the common case: an album play whose producer forgot to stamp)
// legitimately wants the ALBUM origin, and an artist page's Popular Tracks
// only reach the artist arm because they span albums. Producers that know
// better (playlist / artist page / label) pass an EXPLICIT context and
// never depend on this order — this is the safety net for the next
// producer someone adds, not the primary mechanism.
if one_album {
if let Some(id) = album {
return PlayContext::album(id);
}
}
// A SINGLE track that shares "one artist" is not an artist container — it
// is a bare track play, whose Slint fallback is its own album. Only a
// multi-track queue earns the artist origin.
if one_artist && tracks.len() > 1 {
if let Some(id) = artist {
return PlayContext::artist(&id.to_string());
}
}
None
}
/// Stamp the origin onto every track that does not already carry one. Tracks
/// that arrive pre-stamped (the album/playlist/local builders) keep theirs, so
/// a mixed queue stays honest per row.
fn stamp_context(tracks: &mut [QueueTrack], explicit: Option<PlayContext>) {
let resolved = match explicit {
Some(ctx) => Some(ctx),
None => derive_context(tracks),
};
let Some(ctx) = resolved else {
return;
};
for track in tracks.iter_mut() {
let already_stamped = track.context_kind.as_deref().is_some_and(|k| !k.is_empty())
&& track.context_id.as_deref().is_some_and(|i| !i.is_empty());
if already_stamped {
continue;
}
track.context_kind = Some(ctx.kind.clone());
track.context_id = Some(ctx.id.clone());
}
}
/// THE blacklist drop predicate for a built queue row (playback.rs:2624-2632
/// `queue_track_blacklisted`). Local / Plex / no-id rows fail open — the
/// underlying `is_track_blacklisted` returns false for any source that is not
/// "qobuz", so a local file can never be dropped by an artist block.
fn queue_track_blacklisted(track: &QueueTrack) -> bool {
let source = track.source.as_deref().unwrap_or("qobuz");
crate::artist_blacklist::is_track_blacklisted(
source,
track.artist_id,
None,
track.album_id.as_deref(),
)
}
/// THE unavailable drop predicate for a built queue row (contract §5.1 with the
/// §5.3 / F5 exemption). Two halves, and the second is what keeps this feature
/// from becoming a worse bug than the one it fixes:
///
/// 1. `!track.streamable` — the API said `streamable: false` for this track.
/// `QueueTrack.streamable` defaults **true** (`qbz-models/src/playback.rs`
/// `default_streamable`), so a builder that never learned the answer fails
/// OPEN and the track plays. That default is deliberate, not laziness:
/// refusing to play because an endpoint was terse is strictly worse than
/// playing something that turns out to be dead, and the reactive
/// `auto_skip_unavailable` walk still catches the second case.
/// 2. `&& !is_cached_id(..)` — minus the tracks we already downloaded.
/// `qbz-core`'s play resolution takes the offline tier BEFORE any network
/// call and before any availability question, and every Qt play hands the
/// handle down (`play_resolved_offline_aware`), so a track Qobuz pulled from
/// the catalogue plays perfectly from disk TODAY. Dropping it would take
/// away a song the user deliberately downloaded, and it would bite hardest
/// in offline mode, where that copy is the only one that exists. The Slint
/// reference gates on exactly this (`crates/qbz/src/playback.rs:426`).
///
/// LOCAL / Plex rows can never be dropped here, and that is not luck: their
/// builder (`local_playback::local_queue_track`) sets `streamable: true`
/// because a file on disk is outside Qobuz's streaming rights entirely.
pub(crate) fn queue_track_unavailable(track: &QueueTrack) -> bool {
// Clause 2 (D3): a track the player already found terminally unavailable
// THIS RUN counts as dead even while the API keeps advertising it as
// streamable — which is the owner's test case exactly, since `/album/get`
// is honest but plenty of rows only fail at the stream-url step. The cache
// exemption applies to both clauses: downloaded bytes play regardless.
let dead =
!track.streamable || crate::track_replace_qt::session_unavailable::contains(track.id);
dead && !crate::offline_qt::is_cached_id(track.id)
}
/// Why rows were dropped, counted per reason. The reasons are NOT interchangeable
/// and must not collapse into one number: a blacklist drop is what the user
/// asked for and stays SILENT, while an unavailability drop is news they never
/// asked for and owes them a toast (D4 — "el usuario siempre debe saber"). One
/// counter would force one policy on both.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct QueueDrops {
/// Dropped by the artist blacklist. Silent by design.
pub blacklisted: usize,
/// Dropped because Qobuz pulled the track AND we hold no offline copy.
pub unavailable: usize,
/// Dropped while QConnect is enabled because its source is not resolvable
/// by Qobuz. These rows never enter the local queue in that mode.
pub qconnect_unresolvable: usize,
}
/// The ONE toast a queue build owes when it silently shortened itself
/// (§5.3). Once per build, never once per track — an album with six pulled
/// tracks must not raise six toasts.
///
/// PLURAL msgid pair, per acceptance §8-6: a singular-only string reads wrong
/// in every locale with plural rules, Russian worst of all. The pair follows
/// the catalog's existing convention (`"{} duplicate skipped"` /
/// `"{} duplicates skipped"`). The i18n lane lands it in all EIGHT locales;
/// nothing else in this module is a new msgid.
fn toast_unavailable_dropped(count: usize) {
if count == 0 {
return;
}
crate::toast_qt::info(qbz_i18n::tf(
"{} track skipped — no longer available",
"{} tracks skipped — no longer available",
count as i64,
&[&count.to_string()],
));
}
/// Drop the rows that must never reach the queue AND remap the start index onto
/// the surviving track (playback.rs:2634-2641 `filter_blacklisted_queue`, plus
/// the unavailability drop of contract §5.3 D5).
///
/// The Slint applies the blacklist at ~18 separate builder sites. This port
/// funnels every play path through `set_queue_stamped`, so it needs exactly one
/// — which is also why its absence was total rather than partial: the store was
/// open and the manager worked, but nothing between the two ever asked.
///
/// The start remap is the part the Slint gets for free by filtering before it
/// computes the index. Here the caller has already picked an index into the
/// UNFILTERED list, so dropping rows ahead of it would silently start the wrong
/// track. The clicked row surviving is the common case; when the clicked row is
/// itself dropped, the walk lands on the next survivor, which is what the user
/// asking for "play from here" means once "here" is gone.
///
/// Renamed from `filter_blacklisted_queue`: it now drops for three reasons and
/// the old name named only one of them.
fn filter_unplayable_queue(
tracks: Vec<QueueTrack>,
start: Option<usize>,
) -> (Vec<QueueTrack>, Option<usize>, QueueDrops) {
let qconnect_enabled = crate::qconnect_qt::queue_admission_enabled();
let (kept, start, dropped) = filter_queue_with(
tracks,
start,
queue_track_blacklisted,
queue_track_unavailable,
|track| qconnect_enabled && !crate::qconnect_qt::is_qconnect_queue_track(track),
);
if dropped.blacklisted > 0 {
log::info!(
"[qbz-qt] blacklist: dropped {} track(s) from the queue",
dropped.blacklisted
);
}
if dropped.unavailable > 0 {
log::info!(
"[qbz-qt] unavailable: dropped {} pulled track(s) from the queue",
dropped.unavailable
);
}
if dropped.qconnect_unresolvable > 0 {
log::info!(
"[qbz-qt] qconnect admission: dropped {} unresolvable track(s) from the queue",
dropped.qconnect_unresolvable
);
}
(kept, start, dropped)
}
/// The pure half, split out so the index remap is unit-testable without a bound
/// session (the blacklist store needs one). The three predicates decide;
/// everything else is arithmetic, and that arithmetic is unchanged.
///
/// Separate predicates, not one OR'd closure, because the caller has to know WHICH
/// one claimed each row (see [`QueueDrops`]). `blacklisted` is tested FIRST and
/// wins ties: a pulled track by an artist the user blocked is a row they
/// already chose not to see, and toasting "1 track skipped" for it would
/// announce exactly the content the blacklist exists to hide.
///
/// The log line that used to live here moved to `filter_unplayable_queue`: it
/// said "blacklist", which any additional reason turns into a lie.
fn filter_queue_with<T>(
tracks: Vec<T>,
start: Option<usize>,
blacklisted: impl Fn(&T) -> bool,
unavailable: impl Fn(&T) -> bool,
qconnect_unresolvable: impl Fn(&T) -> bool,
) -> (Vec<T>, Option<usize>, QueueDrops) {
let clicked = start.unwrap_or(0);
let mut kept = Vec::with_capacity(tracks.len());
// Surviving index for the clicked row, or for the first survivor after it.
let mut new_start: Option<usize> = None;
let mut dropped = QueueDrops::default();
for (i, track) in tracks.into_iter().enumerate() {
if blacklisted(&track) {
dropped.blacklisted += 1;
continue;
}
if unavailable(&track) {
dropped.unavailable += 1;
continue;
}
if qconnect_unresolvable(&track) {
dropped.qconnect_unresolvable += 1;
continue;
}
if new_start.is_none() && i >= clicked {
new_start = Some(kept.len());
}
kept.push(track);
}
// Every survivor sits BEFORE the clicked row (the whole tail was dropped):
// start at the last one rather than past the end.
let start = new_start
.or_else(|| kept.len().checked_sub(1))
.filter(|_| !kept.is_empty());
(kept, start, dropped)
}
/// What the queue seam actually published: the start index AFTER the filter,
/// and the id of the track sitting at it.
///
/// F1, and it is a blocker (contract §5.3). Every caller used to read `first_id`
/// from the UNFILTERED list, hand the list to this seam, and then play the id
/// the seam had just dropped. `play_track_resolved` returns `Err`, the caller
/// `log::error!`s it, and the USER GETS SILENCE with no toast. That is not a
/// hypothetical introduced by the availability work — it is reachable today
/// through the artist blacklist: click a blocked artist's album and nothing
/// plays. Returning the post-filter anchor is what makes "the id the caller
/// plays" and "the queue the core holds" one list again.
///
/// `None` means NOTHING survived. The caller must not play and must not report
/// an error: the seam has already told the user why (the skipped toast) and has
/// already left the previous queue untouched rather than publishing an empty
/// one over it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct QueueStart {
/// Index into the queue the core now holds.
pub index: usize,
/// The track at `index` — the id to hand to `play_resolved_offline_aware`
/// and to `route_play_to_peer`.
pub track_id: u64,
}
/// A replacement queue after blacklist/availability filtering and context
/// stamping, but before it is visible to the core or any UI surface.
///
/// Source-owned playback can fail before the player accepts new audio (dead
/// Plex part, unavailable NAS, server outage). Preparing first lets those
/// callers prove the anchor is audible without making Queue, NPB and MPRIS
/// announce it over the track that is still actually playing.
pub(crate) struct PreparedQueue {
tracks: Vec<QueueTrack>,
start: Option<usize>,
anchor: Option<QueueStart>,
unavailable_dropped: usize,
qconnect_unresolvable_dropped: usize,
}
impl PreparedQueue {
pub(crate) fn anchor_track(&self) -> Option<&QueueTrack> {
self.start.and_then(|index| self.tracks.get(index))
}
}
pub(crate) fn prepare_queue_stamped(
tracks: Vec<QueueTrack>,
start: Option<usize>,
context: Option<PlayContext>,
) -> Option<PreparedQueue> {
// Source-owned playback stages audio before committing the replacement,
// so it reaches this lower seam directly. Refuse before that staging work
// when a delegated renderer owns the queue.
let _owner_action = begin_owner_action()?;
let asked = tracks.len();
let (mut tracks, start, dropped) = filter_unplayable_queue(tracks, start);
if tracks.is_empty() && asked > 0 {
log::info!(
"[qbz-qt] set_queue: all {asked} track(s) filtered ({} blacklisted, {} unavailable, {} qconnect-unresolvable) \
— keeping the previous queue",
dropped.blacklisted,
dropped.unavailable,
dropped.qconnect_unresolvable
);
toast_unavailable_dropped(dropped.unavailable);
crate::qconnect_qt::toast_unresolvable_tracks(dropped.qconnect_unresolvable);
return None;
}
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "set_queue");
let anchor = start.and_then(|index| {
tracks.get(index).map(|track| QueueStart {
index,
track_id: track.id,
})
});
Some(PreparedQueue {
tracks,
start,
anchor,
unavailable_dropped: dropped.unavailable,
qconnect_unresolvable_dropped: dropped.qconnect_unresolvable,
})
}
pub(crate) async fn commit_prepared_queue(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
prepared: PreparedQueue,
) -> Option<QueueStart> {
commit_prepared_queue_with_offline_only(runtime, prepared, false).await
}
async fn commit_prepared_queue_with_offline_only(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
prepared: PreparedQueue,
offline_only: bool,
) -> Option<QueueStart> {
let Some(_owner_action) = begin_owner_action() else {
// Preserve the anchor as a handled/refused result. Callers route it
// through `route_play_remote` next, which consumes the delegated case
// without starting local audio or surfacing a misleading filter error.
return prepared.anchor;
};
let PreparedQueue {
tracks,
start,
anchor,
unavailable_dropped,
qconnect_unresolvable_dropped,
} = prepared;
runtime
.core()
.set_queue_with_offline_only(tracks, start, offline_only)
.await;
toast_unavailable_dropped(unavailable_dropped);
crate::qconnect_qt::toast_unresolvable_tracks(qconnect_unresolvable_dropped);
if let Some(svc) = crate::qconnect_qt::service() {
svc.sync_local_queue_if_changed().await;
}
anchor
}
/// The ONLY `core().set_queue` call in this module — every play path goes
/// through it, so the origin can never be dropped on the floor, and neither can
/// the blacklist (PARITY-DEBT #1) nor a track Qobuz pulled (contract §5.3 D5).
///
/// Returns the surviving anchor; see [`QueueStart`] for why no caller may
/// compute it itself any more.
pub(crate) async fn set_queue_stamped(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: Option<usize>,
context: Option<PlayContext>,
) -> Option<QueueStart> {
let prepared = prepare_queue_stamped(tracks, start, context)?;
commit_prepared_queue(runtime, prepared).await
}
/// Local-playlist variant of [`set_queue_stamped`] that publishes the D8
/// offline-only flag before the queue event becomes observable.
pub(crate) async fn set_queue_stamped_with_offline_only(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: Option<usize>,
context: Option<PlayContext>,
offline_only: bool,
) -> Option<QueueStart> {
let prepared = prepare_queue_stamped(tracks, start, context)?;
commit_prepared_queue_with_offline_only(runtime, prepared, offline_only).await
}
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
/// carry their own origin, so the glyph stays right after the queue advances
/// into them.
///
/// `pub(crate)` on purpose: the local/ephemeral/bulk enqueue paths live in
/// other modules and must be able to reach the SAME stamping seam — a private
/// helper is what forced them to hand-roll their context in the first place.
pub(crate) fn stamped(tracks: Vec<QueueTrack>, context: Option<PlayContext>) -> Vec<QueueTrack> {
if delegated_authority_owns_queue() {
// Enqueue callers already treat an empty post-seam batch as a handled
// no-op. Do not toast: the controller owns the visible guest queue.
return Vec::new();
}
// The SECOND seam (F2). `set_queue_stamped` guards the REPLACE paths; this
// guards play-next / play-later / add-to-queue, and its own reason for
// existing is that enqueue "must not be the back door" for the blacklist.
// D5 makes the same demand of a pulled track: a row the user cannot click
// in the list must not be reachable by dragging it into the queue either.
// No start index to remap here — these APPEND.
let mut dropped = QueueDrops::default();
let qconnect_enabled = crate::qconnect_qt::queue_admission_enabled();
let mut tracks: Vec<QueueTrack> = tracks
.into_iter()
.filter(|t| {
if queue_track_blacklisted(t) {
dropped.blacklisted += 1;
return false;
}
if queue_track_unavailable(t) {
dropped.unavailable += 1;
return false;
}
if qconnect_enabled && !crate::qconnect_qt::is_qconnect_queue_track(t) {
dropped.qconnect_unresolvable += 1;
return false;
}
true
})
.collect();
if dropped.blacklisted > 0 {
log::info!(
"[qbz-qt] blacklist: dropped {} track(s) from an enqueue",
dropped.blacklisted
);
}
if dropped.unavailable > 0 {
log::info!(
"[qbz-qt] unavailable: dropped {} pulled track(s) from an enqueue",
dropped.unavailable
);
}
if dropped.qconnect_unresolvable > 0 {
log::info!(
"[qbz-qt] qconnect admission: dropped {} unresolvable track(s) from an enqueue",
dropped.qconnect_unresolvable
);
}
// Idempotent with the seam below it: `myqbz_play_qt` stamps and THEN calls
// `set_queue_stamped`, whose second pass finds nothing left to drop and so
// raises no second toast. That module's existing "both passes are
// idempotent" comment stays true.
toast_unavailable_dropped(dropped.unavailable);
crate::qconnect_qt::toast_unresolvable_tracks(dropped.qconnect_unresolvable);
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "enqueue");
tracks
}
// ---------------------------------------------------------------------------
// QConnect routing helpers (contract §6.3/§7 — Slint playback.rs:4082-4111
// plus the routed arms on every Qobuz-castable add path)
// ---------------------------------------------------------------------------
/// QConnect sync-on-add predicate (#442, playback.rs:4082-4096): true when
/// every surviving row uses a Qobuz-resolvable provenance. The shared enqueue
/// seam normally guarantees this while QConnect is enabled; this remains a
/// defensive gate for callers running while it is disabled or starting up.
pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool {
tracks
.iter()
.all(crate::qconnect_qt::is_qconnect_queue_track)
}
/// QConnect sync-on-add (#442, playback.rs:4098-4111): after a local queue
/// ADD (play-next / play-later / add-to-queue), push the updated queue to the
/// session immediately so a connected controller sees the new item AT its
/// position — previously it only re-synced on the next track transition.
/// `sync_local_queue_if_changed` self-gates (no-op when not connected, when a
/// peer owns playback, or when the queue is unchanged), so this is cheap in
/// every non-renderer situation.
pub(crate) async fn sync_qconnect_after_add(added_castable: bool) {
if !added_castable {
return;
}
let Some(_owner_action) = begin_owner_action() else {
return;
};
if let Some(svc) = crate::qconnect_qt::service() {
svc.sync_local_queue_if_changed().await;
}
}
/// QConnect CONTROLLER-mode enqueue routing (contract §7 — the Slint add-path
/// arms, e.g. playback.rs:3012-3023, 4116-4128): when a PEER renderer owns
/// playback, route the add to the peer's queue instead of mutating only the
/// LOCAL queue (the peer never sees a local-only enqueue; the cloud echoes a
/// QueueUpdated that `materialize_remote_queue` applies). Mode-matched to the
/// caller's LOCAL placement: "next" inserts after the peer's current track,
/// "later" lands on the peer's manual-block tail, anything else appends.
/// Returns `true` when handled remotely — the caller MUST NOT enqueue
/// locally, and its sync tail does not run (the peer owns the queue now).
pub(crate) async fn route_enqueue_to_peer(tracks: &[QueueTrack], mode: &str) -> bool {
let Some(_owner_action) = begin_owner_action() else {
return true;
};
let Some(svc) = crate::qconnect_qt::service() else {
return false;
};
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
match mode {
"next" => svc.play_next_batch_on_peer_if_active(&routed).await,
"later" => svc.play_later_batch_on_peer_if_active(&routed).await,
_ => svc.add_to_queue_batch_on_peer_if_active(&routed).await,
}
}
/// Single-track variant of [`route_enqueue_to_peer`] (Slint
/// playback.rs:4120-4128's single-track arm).
pub(crate) async fn route_track_to_peer(track: &QueueTrack, mode: &str) -> bool {
let Some(_owner_action) = begin_owner_action() else {
return true;
};
let Some(svc) = crate::qconnect_qt::service() else {
return false;
};
match mode {
"next" => {
svc.play_next_on_peer_if_active(track.id, track.source.as_deref())
.await
}
"later" => {
svc.play_later_on_peer_if_active(track.id, track.source.as_deref())
.await
}
_ => {
svc.add_to_queue_on_peer_if_active(track.id, track.source.as_deref())
.await
}
}
}
/// The one funnel every local play goes through. It OWNS four decisions so
/// no caller has to remember them:
///
/// 1. the request tier — `local_playback_quality()`, which is where the
/// detected device cap (#638 fix 3) applies. It used to be a PARAMETER,
/// which is why seven callers pasted the same `current_quality()` and four
/// others bypassed the funnel entirely;
/// 2. the ACTIVE offline cache handle, and
/// 3. the row-status sink (Slint passes both through `play_track_resolved`;
/// this port used to pass `None, None`, which made the offline tier
/// unreachable — downloaded tracks always streamed);
/// 4. session resume — and note this one is NOT gated on the offline cache:
/// a caller passing 0 gets the restore stash consumed if it is armed for
/// this exact track (once only). That matches the reference, whose audible
/// play calls `take_resume_for` unconditionally.
///
/// KNOWN LIMIT, and it is the honest reading of "the funnel owns the tier":
/// it owns the tier we REQUEST. An offline copy already on disk is served
/// verbatim — `qbz-core` ignores the quality argument on that branch, because
/// downloads are always taken at the top tier. So a capped device still gets
/// the full-rate bytes of a track the user downloaded. Same on the reference;
/// fixing it means transcoding or a second download, neither of which this
/// row is allowed to decide.
///
/// NOT its job: `route_play_to_peer`. Two call-site shapes forbid hoisting it
/// here — `play_queue_track` needs the peer route BEFORE its local-file
/// branch, and the MyQBZ collection play deliberately does not early-return on
/// a routed play (its `touch_play` bookkeeping runs either way).
pub(crate) async fn play_resolved_offline_aware(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
start_position_secs: u64,
) -> Result<(), String> {
let Some(_owner_action) = begin_owner_action() else {
log::debug!(
"[play] local resolved start for track {track_id} refused by delegated authority"
);
return Ok(());
};
let play_generation = begin_resolved_play();
// TRIPWIRE for the funnel: this is the LOCAL Qobuz/offline step and must
// never run while a renderer owns playback. A hit here means an entry
// point skipped `route_play_remote` — it is a bug, and this line is how
// the next one gets caught in a log instead of in a smoke.
if crate::cast_qt::is_casting().await {
log::warn!("[play] LOCAL PLAY WHILE CASTING (bug): track {track_id} reached play_resolved_offline_aware");
}
// SOURCE FIRST (design 02 §9 stage 3). Everything below this block is the
// QOBUZ entry: the tier walk, the offline cache, the quality request. A row
// a different source owns has no business in it, and until now it went
// there anyway — `myqbz_play_qt`'s own header called this out as the reason
// a MyQBZ collection's local or Plex first track "still fails AT THE
// PLAYER even though it now resolves".
//
// It was also unsafe, not merely wrong: the offline cache is keyed on the
// raw `u64`, and a `local_tracks` rowid or a Plex rating key lives in the
// same small-integer space as older Qobuz ids. A collision played THE
// WRONG TRACK rather than failing cleanly (#638 fix 3's sharp edge).
// Claiming the row first removes the id-space overlap: a claimed non-Qobuz
// row never reaches the cache lookup at all.
//
// The cursor test is deliberate. Every caller sets the queue and then plays
// its anchor, so the core is already sitting on this track; if it is NOT,
// this function does exactly what it did before rather than guessing which
// row was meant.
let logical_track = runtime
.core()
.current_track()
.await
.filter(|t| t.id == track_id);
if let Some(qt) = logical_track.as_ref() {
let raw = qbz_source::RawRef::from_queue_track(&qt);
// OFFLINE rows stay on THIS path on purpose — see
// `local_playback::play_current_if_local` for the log line that proves
// why. The offline tier below is the only thing that can read a
// CMAF-cached download, and the row carries the Qobuz catalog id so it
// can.
let is_offline = raw.badge == qbz_source::SourceBadge::Offline;
if let (false, Ok(item)) = (is_offline, qbz_source::registry().claim(&raw)) {
if item.source() != qbz_source::SourceId::QOBUZ {
if !resolved_play_is_current(play_generation) {
return Ok(());
}
return match crate::audible_qt::play_queue_track(runtime, qt).await {
Ok(true) => Ok(()),
// Its own source owned it and could not play it. Phrased so
// `is_terminal_unavailable` routes it into the SAME bounded
// skip walk a pulled Qobuz track takes — to the user the two
// failures are the same one.
Ok(false) => Err(format!("local file no longer available (track {track_id})")),
Err(e) => Err(e.to_string()),
};
}
}
}
let quality = local_playback_quality().0;
let off = crate::offline_qt::get().await;
let sink = off.as_ref().map(|_| crate::offline_cache_qt::row_sink());
if !resolved_play_is_current(play_generation) {
return Ok(());
}
// Session resume: if this is the track restored at launch, start it at the
// saved position (consumed once). Only when the caller did not ask for a
// position itself — an explicit seek-then-play must win over the restore.
let start_position_secs = if start_position_secs == 0 {
qbz_app::session_persist::take_resume_for(track_id)
} else {
start_position_secs
};
if !resolved_play_is_current(play_generation) {
return Ok(());
}
// The queue row retains its Qobuz identity, so explicitly forget the
// previous byte source before resolving this start. A purchase success
// records its exact format below; Qobuz mode and every fallback leave the
// row clear and therefore restore the catalog badge in the NPB.
crate::purchase_playback_qt::clear_materialized_track(track_id);
if let Some(track) = logical_track.as_ref() {
match try_play_preferred_purchase(runtime, track, start_position_secs, play_generation)
.await
{
PreferredPurchaseStart::Played | PreferredPurchaseStart::Superseded => return Ok(()),
PreferredPurchaseStart::Fallback => {}
}
}
if !resolved_play_is_current(play_generation) {
return Ok(());
}
runtime
.core()
.play_track_resolved(
track_id,
quality,
off.as_deref(),
sink.as_ref(),
start_position_secs,
)
.await
}
/// Materialize one Qobuz queue row from the user's exact album preference.
/// Every candidate was already proven complete and healthy by the blocking
/// resolver. A decoder/open failure advances to another complete physical
/// copy of the same format before returning control to the Qobuz tier walk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreferredPurchaseStart {
Played,
Fallback,
Superseded,
}
async fn try_play_preferred_purchase(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track: &QueueTrack,
start_position_secs: u64,
play_generation: u64,
) -> PreferredPurchaseStart {
let Some(resolution) =
crate::purchase_playback_qt::resolve_preferred_copies(track.id, track.album_id.as_deref())
.await
else {
return if resolved_play_is_current(play_generation) {
PreferredPurchaseStart::Fallback
} else {
PreferredPurchaseStart::Superseded
};
};
for copy in &resolution.copies {
if !resolved_play_is_current(play_generation) {
log::debug!(
"[qbz-qt] purchase: discarded stale resolution for track {}",
track.id
);
return PreferredPurchaseStart::Superseded;
}
let accepted = if copy.is_dsd() {
crate::audible_qt::play_ticket(runtime, copy.ticket(track.id, 0)).await
} else {
// The generic File ticket performs its read and then immediately
// calls `play_data`. Keep those phases apart here so a newer play
// can supersede a slow NAS read before the old bytes touch the
// player. Whole-file PCM is already the established File-ticket
// policy; DSD remains streaming-by-path above.
let path = copy.path.clone();
let bytes = tokio::task::spawn_blocking(move || std::fs::read(path))
.await
.ok()
.and_then(Result::ok);
if !resolved_play_is_current(play_generation) {
return PreferredPurchaseStart::Superseded;
}
match bytes {
Some(bytes) => {
crate::audible_qt::play_ticket(
runtime,
qbz_source::PlaybackTicket::Bytes {
bytes,
play_id: track.id,
},
)
.await
}
None => false,
}
};
if !accepted {
log::warn!(
"[qbz-qt] purchase: copy {} of track {} (format {}) was refused; trying the next exact copy",
copy.copy_id,
track.id,
copy.format_id
);
continue;
}
// DSD tickets are file-only, but the current direct DSD engines accept
// the ordinary player seek. Apply every cold-resume/takeback offset
// after the play command, generation-guarded. This keeps DSD local and
// avoids the generic File ticket's unguarded post-read delay for PCM.
if start_position_secs > 0 {
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
if !resolved_play_is_current(play_generation) {
return PreferredPurchaseStart::Superseded;
}
if let Err(error) = runtime.core().player().seek(start_position_secs) {
log::warn!(
"[qbz-qt] purchase: local seek for track {} to {}s failed: {error}; using Qobuz",
track.id,
start_position_secs
);
let _ = runtime.core().player().stop();
continue;
}
}
if !resolved_play_is_current(play_generation) {
return PreferredPurchaseStart::Superseded;
}
crate::purchase_playback_qt::remember_materialized_copy(track.id, &resolution, copy);
log::info!(
"[qbz-qt] purchase: playing track {} from copy {} (format {})",
track.id,
copy.copy_id,
copy.format_id
);
return PreferredPurchaseStart::Played;
}
log::warn!(
"[qbz-qt] purchase: all complete copies of track {} (format {}) failed; falling back to Qobuz",
track.id,
resolution.format_id
);
if resolved_play_is_current(play_generation) {
PreferredPurchaseStart::Fallback
} else {
PreferredPurchaseStart::Superseded
}
}
/// Prepare a purchase successor without allowing a slow file read or DSD→PCM
/// conversion to append after its queue edge has changed. Native/DoP DSD
/// retains the existing path ticket because that append is immediate; every
/// byte materialization returns here first and is revalidated by the caller.
async fn prepare_purchase_gapless_ticket(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
copy: &crate::purchase_playback_qt::PurchasedCopy,
track_id: u64,
) -> Result<qbz_source::PlaybackTicket, String> {
if copy.is_dsd() && runtime.core().player().is_dsd_direct_active() {
return Ok(copy.ticket(track_id, 0));
}
let path = copy.path.clone();
let is_dsd = copy.is_dsd();
let bytes = tokio::task::spawn_blocking(move || {
if is_dsd {
qbz_player::Player::prepare_dsd_gapless_wav(&path)
} else {
std::fs::read(path).map_err(|error| error.to_string())
}
})
.await
.map_err(|error| format!("purchase gapless preparation task failed: {error}"))??;
Ok(qbz_source::PlaybackTicket::Bytes {
bytes,
play_id: track_id,
})
}
/// Restore the signed-in owner's exact current track after delegated playback.
///
/// The delegation host calls this while owning its transition gate and closed
/// owner-action fence (the offline path uses the same exclusive handoff
/// boundary). This helper therefore does not acquire authority again and
/// deliberately skips both Cast and QConnect-peer routing: it restores audio
/// on this process from the queue snapshot the host already installed.
/// `position_secs` is always explicit, including zero, so the process-start
/// session-resume stash can never override the handoff snapshot.
pub(crate) async fn restore_owner_playback(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
position_secs: u64,
was_playing: bool,
) -> Result<(), String> {
let track = runtime
.core()
.current_track()
.await
.filter(|track| track.id == track_id)
.ok_or_else(|| "owner restore cursor no longer matches its snapshot".to_string())?;
// A startup resume token for the same track is stale once this stronger
// authority snapshot exists. Consume and discard it even when the exact
// handoff position is zero.
let _ = qbz_app::session_persist::take_resume_for(track_id);
let play_generation = begin_resolved_play();
let raw = qbz_source::RawRef::from_queue_track(&track);
let is_offline = raw.badge == qbz_source::SourceBadge::Offline;
let source_item = if is_offline {
None
} else {
match qbz_source::registry().claim(&raw) {
Ok(item) => Some(item),
Err(error) if track.is_local => {
return Err(format!(
"owner source restore could not claim its row: {error}"
));
}
// Legacy Qobuz queue snapshots may predate the explicit `source`
// field. Preserve their catalog fallback when `is_local` is false.
Err(_) => None,
}
};
if source_item
.as_ref()
.is_some_and(|item| item.source() != qbz_source::SourceId::QOBUZ)
{
match crate::audible_qt::play_queue_track(runtime, &track).await {
Ok(true) => {
// Source tickets start at their own zero (or CUE base). Apply a
// non-zero relative owner position after the source is live;
// zero remains an explicit fresh start and never consults the
// process-start resume stash above.
if position_secs > 0 {
runtime
.core()
.seek(position_secs)
.map_err(|error| format!("owner source restore seek failed: {error}"))?;
}
}
Ok(false) => return Err("owner source restore was refused by the player".to_string()),
Err(error) => return Err(format!("owner source restore failed: {error}")),
}
} else {
match try_play_preferred_purchase(runtime, &track, position_secs, play_generation).await {
PreferredPurchaseStart::Played => {}
PreferredPurchaseStart::Superseded => return Ok(()),
PreferredPurchaseStart::Fallback => {
if !resolved_play_is_current(play_generation) {
return Ok(());
}
let quality = local_playback_quality().0;
let offline = crate::offline_qt::get().await;
let sink = offline
.as_ref()
.map(|_| crate::offline_cache_qt::row_sink());
runtime
.core()
.play_track_resolved(
track_id,
quality,
offline.as_deref(),
sink.as_ref(),
position_secs,
)
.await?;
}
}
}
if !was_playing {
runtime
.core()
.pause()
.map_err(|error| format!("owner restore pause failed: {error}"))?;
}
Ok(())
}
/// QConnect CONTROLLER-mode play routing (contract §7's decided arm position —
/// Slint playback.rs:638-651): when a PEER renderer owns playback, route the
/// play to the peer instead of running the local audible step. Call AFTER
/// `set_queue_stamped` (the arm pushes the CURRENT core queue — placed before
/// the funnel it would push the STALE one) and BEFORE `play_track_resolved`.
/// On handled, refreshes the bar + queue from the core cursor (the Slint
/// `after_track_change` meta refresh + sidebar) and returns `true` — the
/// caller early-returns and MUST NOT play locally. The Slint's `clear_loading`
/// after `play_on_peer_if_active` (playback.rs:643-650) maps to
/// `now_playing::clear_loading()` here: the poll loop's peer branch normally
/// clears the spinner via its position push (`has_audio = true`) on the next
/// tick, but a REFUSED routed play (mixed queue) never primes the peer — a
/// paused+idle peer would leave the spinner latched (sign-off F1).
/// THE remote-renderer gate — the one place every audible start passes
/// before any local backend is touched: CAST first (a connected renderer
/// owns playback), then the QConnect PEER (controller mode), else `false`
/// = play locally. Same order as the queue funnel always had.
///
/// Why one seam: the cast gate lived ONLY in `play_queue_track`, while the
/// peer gate had been added to every other entry (`play_single_track`,
/// `play_album*`, `play_artist`, `play_track_list_in`, playlist, MyQBZ, the
/// Local Library funnel) as `route_play_to_peer` — so a track CLICK while
/// casting started the local backend beside the renderer (2026-08-29 smoke,
/// twice). Nothing may call `route_play_to_peer` directly any more; `entry`
/// names the caller in the log so the next missing gate is visible.
pub(crate) async fn route_play_remote(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
entry: &'static str,
) -> bool {
// This play decision supersedes any local purchase bytes previously
// associated with the catalog id. A local purchase resolution records the
// format again after the player accepts it; Cast/QConnect/local-source
// routes leave it clear so the NPB never inherits a stale purchase badge.
crate::purchase_playback_qt::clear_materialized_track(track_id);
let Some(_owner_action) = begin_owner_action() else {
log::debug!("[play] entry={entry} track={track_id} -> refused by delegated authority");
crate::now_playing::clear_loading();
return true;
};
if crate::cast_qt::play_current_if_cast(runtime).await {
log::info!("[play] entry={entry} track={track_id} -> cast renderer");
crate::now_playing::clear_loading();
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
return true;
}
if route_play_to_peer(runtime, track_id).await {
log::info!("[play] entry={entry} track={track_id} -> qconnect peer");
return true;
}
log::debug!("[play] entry={entry} track={track_id} -> local");
false
}
/// `route_play_remote` for a funnel that holds the TRACK before the queue
/// cursor moves (the Local Library stages its queue after the audible step):
/// the renderer gets this track, not whatever the old queue pointed at.
pub(crate) async fn route_play_remote_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track: &QueueTrack,
entry: &'static str,
) -> bool {
crate::purchase_playback_qt::clear_materialized_track(track.id);
let Some(_owner_action) = begin_owner_action() else {
log::debug!(
"[play] entry={entry} track={} -> refused by delegated authority",
track.id
);
crate::now_playing::clear_loading();
return true;
};
if crate::cast_qt::play_track_if_cast(track).await {
log::info!("[play] entry={entry} track={} -> cast renderer", track.id);
crate::now_playing::clear_loading();
return true;
}
if route_play_to_peer(runtime, track.id).await {
log::info!("[play] entry={entry} track={} -> qconnect peer", track.id);
return true;
}
log::debug!("[play] entry={entry} track={} -> local", track.id);
false
}
async fn route_play_to_peer(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) -> bool {
let Some(svc) = crate::qconnect_qt::service() else {
return false;
};
if !svc.play_on_peer_if_active(track_id).await {
return false;
}
crate::now_playing::clear_loading();
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
true
}
/// Tripwire: a track with NEITHER a container origin NOR an album id publishes
/// `ctx-id == ""` (refresh_now_playing's fallback has nothing to fall back to),
/// which renders the song-card layers glyph as a dead control. Nothing in the
/// current producer set can hit this — the point is that the NEXT producer
/// someone adds says so in the log instead of shipping an inert button.
fn warn_dead_context(tracks: &[QueueTrack], site: &str) {
let dead = tracks
.iter()
.filter(|t| {
t.context_id.as_deref().unwrap_or("").is_empty()
&& t.album_id.as_deref().unwrap_or("").is_empty()
})
.count();
if dead > 0 {
log::warn!(
"[qbz-qt] playback context: {dead}/{} track(s) reached {site} with no context AND no \
album id — their 'playing from' glyph will be inert",
tracks.len(),
);
}
}
// ---------------------------------------------------------------------------
// Play an album (album-card click on Home)
// ---------------------------------------------------------------------------
/// Fetch the album, build the queue (playback.rs `make_queue_track` port),
/// set it starting at track 1, and play through the core's resolved path.
/// `pub(crate)` so `library_bulk` can build ONE concatenated queue out of a
/// multi-album selection and hand it to `enqueue_track_list_mode` — going
/// through `enqueue_album` per id would publish the queue N times and, for
/// "later", append instead of landing in the manual block's tail.
pub(crate) async fn fetch_album_queue(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<Vec<QueueTrack>, String> {
let album = match runtime.core().get_album(album_id).await {
Ok(album) => album,
Err(catalog_error) => {
// An offline album card already has every playable byte and enough
// metadata in index.db/library.db to build a queue. Requiring
// /album/get before consulting that index made its overlay Play a
// network button in disguise; AlbumView appeared to work only
// because its document had already materialized the tracks.
match offline_album_queue(album_id).await {
Ok(tracks) if !tracks.is_empty() => {
log::info!(
"[qbz-qt] play_album {album_id}: catalog unavailable; using {} offline row(s)",
tracks.len()
);
return Ok(tracks);
}
Ok(_) => {}
Err(e) => {
log::debug!("[qbz-qt] play_album {album_id}: offline fallback unavailable: {e}")
}
}
return Err(format!("get_album {album_id} failed: {catalog_error}"));
}
};
let album_title = album.title.clone();
let album_artist = album.artist.name.clone();
// Queue rows / the np bar feed render <= 150px but take the full
// variant (best()) — the thumbnail down-tier was reverted after the
// 2026-08-15 owner smoke ("mala calidad" on the big tiles). The full
// variant set is registered so the immersive large-art feed can
// re-resolve per slot size (D2).
let album_artwork = album.image.best().cloned().unwrap_or_default();
crate::artwork_qt::note_np_variants(album_id, &album.image);
let raw_tracks = album
.tracks
.as_ref()
.map(|container| container.items.as_slice())
.unwrap_or_default();
if raw_tracks.is_empty() {
return Err(format!("album {album_id} has no playable tracks"));
}
let tracks: Vec<QueueTrack> = raw_tracks
.iter()
.map(|track| QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| album_artist.clone()),
album: album_title.clone(),
album_version: album
.version
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork.clone())
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: Some(album.id.clone()),
artist_id: track.performer.as_ref().map(|p| p.id),
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: Some(album.id.clone()),
context_kind: Some("album".to_string()),
context_id: Some(album.id.clone()),
isrc: track.isrc.clone(),
recording_mbid: None,
})
.collect();
Ok(tracks)
}
/// Build a Qobuz album queue entirely from the user's offline index.
///
/// The encrypted CMAF bundle remains owned by `play_resolved_offline_aware`;
/// this function only reconstructs queue metadata. `library.db` enriches the
/// index with disc/track order when available. Pre-migration rows that have no
/// library twin still play in a deterministic created/id order rather than
/// making the whole album unavailable.
async fn offline_album_queue(album_id: &str) -> Result<Vec<QueueTrack>, String> {
use qbz_offline_cache::OfflineCacheStatus;
use std::collections::HashMap;
let off = crate::offline_qt::get()
.await
.ok_or_else(|| "offline cache is not active".to_string())?;
let cache_path = off.get_cache_path();
let mut cached = {
let db = off.db.lock().await;
db.as_ref()
.ok_or_else(|| "offline index is not open".to_string())?
.get_album_tracks(album_id)?
};
cached.retain(|track| track.status == OfflineCacheStatus::Ready);
if cached.is_empty() {
return Err("album has no ready offline tracks".to_string());
}
let library_rows: HashMap<u64, qbz_library::LocalTrack> = {
let db = off.library_db.lock().await;
db.as_ref()
.and_then(|db| db.get_qobuz_download_tracks().ok())
.unwrap_or_default()
.into_iter()
.filter_map(|row| row.qobuz_track_id.map(|id| (id as u64, row)))
.collect()
};
cached.sort_by_key(|track| {
let local = library_rows.get(&track.track_id);
(
local.and_then(|row| row.disc_number).unwrap_or(u32::MAX),
local.and_then(|row| row.track_number).unwrap_or(u32::MAX),
track.created_at.clone(),
track.track_id,
)
});
Ok(cached
.into_iter()
.map(|cached| {
let local = library_rows.get(&cached.track_id);
let artwork = local
.and_then(|row| row.artwork_path.clone())
.or_else(|| cached.resolve_cover_path(&cache_path))
.map(|path| qbz_models::fs_url::file_url(&path));
let album = cached.album.clone().unwrap_or_default();
let sample_rate = cached
.sample_rate
.or_else(|| local.map(|row| row.sample_rate).filter(|rate| *rate > 0.0));
let bit_depth = cached
.bit_depth
.or_else(|| local.and_then(|row| row.bit_depth));
QueueTrack {
id: cached.track_id,
title: cached.title,
version: None,
artist: cached.artist,
album,
album_version: None,
duration_secs: cached.duration_secs,
artwork_url: artwork,
hires: bit_depth.is_some_and(|bits| bits >= 24),
bit_depth,
sample_rate,
// This row bypasses the Qobuz network tier but is explicitly
// excluded from the file-source resolver by its Offline badge.
is_local: true,
album_id: Some(album_id.to_string()),
artist_id: None,
streamable: true,
source: Some("qobuz_download".to_string()),
parental_warning: false,
source_item_id_hint: local.map(|row| row.id.to_string()),
context_kind: Some("album".to_string()),
context_id: Some(album_id.to_string()),
isrc: None,
recording_mbid: None,
}
})
.collect())
}
/// The /artist/page top-tracks -> QueueTrack mapping shared by `play_artist`
/// and the immersive search's (artist, next|queue) dispatch (contract
/// 2026-08-02-immersive-port §3.4: `enqueue_artist_top_by_id` = get_artist_page
/// -> top_tracks mapped like play_artist -> enqueue_track_list_mode).
/// make_top_track_queue semantics: /artist/page tracks carry a thinner
/// audio_info than /album/get tracks; fields fall back.
fn artist_top_queue_tracks(
top_tracks: &[PageArtistTrack],
artist_id: &str,
artist_name: &str,
) -> Vec<QueueTrack> {
top_tracks
.iter()
.map(|track| {
let audio = track.audio_info.as_ref();
let album = track.album.as_ref();
let album_id = album.map(|a| a.id.clone());
// Queue rows / the np bar feed render <= 150px but take the
// full variant (best()) — the thumbnail down-tier was reverted
// after the 2026-08-15 owner smoke; register the full set so the
// immersive large-art feed can re-resolve (D2).
if let (Some(id), Some(img)) =
(album_id.as_deref(), album.and_then(|a| a.image.as_ref()))
{
crate::artwork_qt::note_np_variants(id, img);
}
QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.artist
.as_ref()
.map(|a| a.name.display.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| artist_name.to_string()),
album: album.map(|a| a.title.clone()).unwrap_or_default(),
album_version: None,
duration_secs: track.duration.unwrap_or(0) as u64,
artwork_url: album
.and_then(|a| a.image.as_ref())
.and_then(|img| img.best().cloned()),
hires: audio
.and_then(|a| a.maximum_bit_depth)
.map(|b| b > 16)
.unwrap_or(false),
bit_depth: audio.and_then(|a| a.maximum_bit_depth),
sample_rate: audio.and_then(|a| a.maximum_sampling_rate),
is_local: false,
album_id: album_id.clone(),
artist_id: track.artist.as_ref().map(|a| a.id),
streamable: track
.rights
.as_ref()
.and_then(|r| r.streamable)
.unwrap_or(true),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning.unwrap_or(false),
source_item_id_hint: album_id,
context_kind: Some("artist".to_string()),
context_id: Some(artist_id.to_string()),
isrc: None,
recording_mbid: None,
}
})
.collect()
}
/// Artist-card play (ArtistGridCard overlay / menu, phase 16) — playback.rs
/// `play_artist` 1:1: fetch the artist page ONCE, play the Popular tracks;
/// when the artist has none, fall back to the STUDIO discography (release
/// buckets album/epSingle/ep/single in page order, deduped), concatenating
/// each album's tracks and skipping albums that fail (a bulk play must not
/// abort on one unavailable album).
pub async fn play_artist(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<(), String> {
const STUDIO_TYPES: &[&str] = &["album", "epSingle", "ep", "single"];
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let page = runtime
.core()
.get_artist_page(id, None)
.await
.map_err(|e| format!("get_artist_page {artist_id} failed: {e}"))?;
let artist_name = page.name.display.clone();
// 1) Popular tracks — the primary behavior.
let top: Vec<QueueTrack> = artist_top_queue_tracks(
page.top_tracks.as_deref().unwrap_or(&[]),
artist_id,
&artist_name,
);
if !top.is_empty() {
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// F1: the anchor comes back from the seam, never from `top` — the
// filter may have dropped `top[0]`.
let Some(anchor) =
set_queue_stamped(runtime, top, Some(0), PlayContext::artist(artist_id)).await
else {
// Every popular track was filtered. The seam already toasted the
// count; falling through to the discography below is not possible
// (`top` is consumed by the seam) and is not worth restructuring
// for a case that means the artist's whole top list is dead.
log::info!("[qbz-qt] play_artist {artist_id}: every popular track was filtered");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): a peer owns playback — route the play
// to it AFTER the funnel, never run the local audible step.
if route_play_remote(runtime, first_id, "play_artist").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
return Ok(());
}
// 2) Fallback — the studio discography (deduped album ids in the page's
// section order; compilation/live/other omitted — studio releases only).
let mut album_ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for group in page.releases.unwrap_or_default() {
if STUDIO_TYPES.contains(&group.release_type.as_str()) {
for item in group.items {
if seen.insert(item.id.clone()) {
album_ids.push(item.id);
}
}
}
}
if album_ids.is_empty() {
return Err(format!(
"artist {artist_id} has no top tracks and no studio releases"
));
}
let mut queue: Vec<QueueTrack> = Vec::new();
for aid in &album_ids {
match fetch_album_queue(runtime, aid).await {
Ok(tracks) => queue.extend(tracks),
Err(e) => log::warn!("[qbz-qt] artist-play: album {aid} skipped: {e}"),
}
}
if queue.is_empty() {
return Err(format!(
"artist {artist_id} studio discography produced no playable tracks"
));
}
log::info!(
"[qbz-qt] artist-play {artist_id}: discography fallback, {} tracks",
queue.len()
);
// The discography queue arrives pre-stamped per ALBUM (fetch_album_queue);
// the artist origin is what the user launched, so it wins here — the
// explicit context overrides the per-album stamp for the whole queue.
for track in queue.iter_mut() {
track.context_kind = None;
track.context_id = None;
}
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// F1: the anchor is read off the queue the core received.
let Some(anchor) =
set_queue_stamped(runtime, queue, Some(0), PlayContext::artist(artist_id)).await
else {
return Err(format!(
"artist {artist_id} discography resolved to no playable tracks"
));
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_remote(runtime, first_id, "play_artist").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// LOCAL/Plex album keys must NEVER reach `fetch_album_queue` — `get_album`
/// 404s on a group key, the play returns Err and the card does nothing.
/// Mirrors the Slint guard inside `("album","play")` (qbz/src/main.rs:11871,
/// helper `is_local_album_key` at :2630-2632), which exists precisely because
/// Home "Recently played" / Pinned / Most-Played rails mix local rows into a
/// Qobuz card. Every album entry point below tests this FIRST.
fn is_local_album(album_id: &str) -> bool {
crate::library_qt::is_local_album_key(album_id)
}
/// "Play next" / "Play later" / "Add to queue" for an album (AlbumCard ⋯
/// menu): resolve the album's tracks and insert them after the current track
/// (mode "next"), land them on the manual block's tail (mode "later", #442),
/// or append them (anything else).
pub async fn enqueue_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
mode: &str,
) -> Result<(), String> {
if is_local_album(album_id) {
log::info!("[qbz-qt] enqueue_album {album_id}: local/Plex group key -> local path");
crate::local_playback::enqueue(
runtime,
"album".to_string(),
album_id.to_string(),
mode.to_string(),
)
.await;
return Ok(());
}
let tracks = stamped(
fetch_album_queue(runtime, album_id).await?,
PlayContext::album(album_id),
);
// Empty after the seam: nothing to route, nothing to insert. The seam has
// already toasted how many rows it dropped.
if tracks.is_empty() {
log::info!("[qbz-qt] enqueue_album {album_id}: every track was filtered");
return Ok(());
}
log::info!(
"[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks",
tracks.len()
);
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode. The mode is normalized to what
// THIS fn's local match does: "next" inserts at the cursor, "later"
// lands on the manual block's tail, anything else appends.
let routed_mode = match mode {
"next" => "next",
"later" => "later",
_ => "queue",
};
if route_enqueue_to_peer(&tracks, routed_mode).await {
return Ok(());
}
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
let added_castable = batch_all_qconnect_castable(&tracks);
match mode {
"next" => {
// add_track_next inserts directly after the current track — feed
// in reverse so the album's first track lands first.
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
}
"later" => {
// #442 block tail — same arm the track menus and
// enqueue_track_list_mode use, in album order.
for track in tracks {
runtime.core().add_track_later(track).await;
}
}
_ => runtime.core().add_tracks(tracks).await,
}
// QConnect sync-on-add (#442): push the updated queue to the session so a
// connected controller sees the surviving items (self-gating when a peer
// owns playback).
sync_qconnect_after_add(added_castable).await;
publish_queue(runtime).await;
Ok(())
}
pub async fn play_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
play_album_from(runtime, album_id, 0).await
}
/// An album SECTION's "Play all" / "Play selected" (artist page split
/// button): every album's tracks, in the order given, as ONE queue that
/// replaces the current one — track 1 of the first album starts. Each album
/// resolves through `fetch_album_queue` like a single-album play, so a
/// purchase preference or a withdrawn row is treated identically; an album
/// that fails to resolve is logged and skipped rather than sinking the whole
/// section. Local / Plex group keys are not a catalog section and are
/// skipped.
pub async fn play_albums(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_ids: &[String],
) -> Result<(), String> {
let mut queue: Vec<QueueTrack> = Vec::new();
for album_id in album_ids {
if is_local_album(album_id) {
log::info!("[qbz-qt] play_albums: skipping local group key {album_id}");
continue;
}
match fetch_album_queue(runtime, album_id).await {
Ok(mut tracks) => queue.append(&mut tracks),
Err(e) => log::warn!("[qbz-qt] play_albums: {album_id} skipped: {e}"),
}
}
if queue.is_empty() {
return Err(format!(
"none of the {} album(s) resolved to playable tracks",
album_ids.len()
));
}
log::info!(
"[qbz-qt] play_albums: queueing {} track(s) from {} album(s)",
queue.len(),
album_ids.len()
);
play_track_list(runtime, queue, 0, false).await
}
/// Play an album starting at track `start_index` (AlbumView row play —
/// Slint's play_album_from semantics: the queue keeps the whole album,
/// the cursor starts at the clicked track).
pub async fn play_album_from(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
start_index: usize,
) -> Result<(), String> {
if is_local_album(album_id) {
// The local path owns its own queue building + audible step. The start
// INDEX is not expressible there (it takes a row id) — every caller
// that reaches here does so with 0 (`play_album`), and the row-anchored
// variant is `play_album_from_track` below, which does pass the id.
log::info!("[qbz-qt] play_album {album_id}: local/Plex group key -> local path");
crate::local_playback::play_album(runtime, album_id.to_string(), None, false).await;
return Ok(());
}
log::info!("[qbz-qt] play_album: resolving {album_id} (start {start_index})");
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = start_index.min(tracks.len() - 1);
let count = tracks.len();
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// F1: the id to play is whatever SURVIVED at the remapped index. Reading
// `tracks[start].id` here and playing it after the seam is the defect the
// owner's acceptance test 7 catches — "play the album and skip the dead
// track" used to do nothing at all, silently, because the play call was
// handed an id the core no longer held.
let Some(anchor) =
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await
else {
log::info!("[qbz-qt] play_album {album_id}: every track was filtered, queue untouched");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_remote(runtime, first_id, "play_album_from").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
log::info!("[qbz-qt] play_album: play_track_resolved started for {first_id}");
refresh_now_playing(runtime).await;
Ok(())
}
/// AlbumView row play: play the album starting AT the clicked track
/// (Slint play_album_from: the queue keeps the whole album).
pub async fn play_album_from_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
) -> Result<(), String> {
if is_local_album(album_id) {
log::info!("[qbz-qt] play_album_from_track {album_id}: local/Plex group key -> local path");
crate::local_playback::play_album(
runtime,
album_id.to_string(),
Some(track_id as i64),
false,
)
.await;
return Ok(());
}
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// F1: the anchor comes from the seam. Clicking the album's DEAD row lands
// on the next survivor rather than playing an id the core dropped.
let Some(anchor) =
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await
else {
log::info!(
"[qbz-qt] play_album_from_track {album_id}: every track was filtered, queue untouched"
);
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_remote(runtime, first_id, "play_album_from_track").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
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.
///
/// `pub(crate)` so every Shuffle entry point can reorder its OWN list instead
/// of only raising the global shuffle MODE. Owner ruling 2026-08-01: "todos los
/// shuffles deben ser totalmente random"; a path that raises the flag and then
/// plays the list's #1 track is not shuffled, it is the original order with a
/// toggle lit — an artifact of a very early Tauri experiment.
pub(crate) 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)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PlaybackRecoveryCause {
SourceFailure,
EngineEmpty,
SampledNaturalEnd,
}
/// Resolve one queue-recovery edge. Explicit player generations win over the
/// legacy sampled predicate: a 100 ms late-gapless resume/empty pulse can fit
/// entirely between this frontend's 1 s polls, while the generations remain
/// observable until the next tick. Track-id checks reject a stale feeder/end
/// edge after a manual play has already moved the queue.
fn playback_recovery_cause(
event: &PlaybackEvent,
queue_track_id: u64,
seen_engine_empty_generation: u64,
seen_source_failure_generation: u64,
sampled_track_end: bool,
) -> Option<PlaybackRecoveryCause> {
if event.source_failure_generation != seen_source_failure_generation
&& event.source_failure_track_id != 0
&& event.source_failure_track_id == queue_track_id
{
return Some(PlaybackRecoveryCause::SourceFailure);
}
if event.engine_empty_generation != seen_engine_empty_generation
&& event.engine_empty_track_id != 0
&& event.engine_empty_track_id == queue_track_id
&& !event.is_playing
&& event.gapless_next_track_id == 0
{
return Some(PlaybackRecoveryCause::EngineEmpty);
}
sampled_track_end.then_some(PlaybackRecoveryCause::SampledNaturalEnd)
}
/// Play a pre-built queue starting at `start` (ArtistView Popular Tracks:
/// the visible list becomes the queue, anchored at the clicked track).
///
/// The origin is DERIVED from the queue when the caller has none (see
/// `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>,
start: usize,
shuffle: bool,
) -> Result<(), String> {
play_track_list_in(runtime, tracks, start, shuffle, None).await
}
/// `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>,
start: usize,
shuffle: bool,
context: Option<PlayContext>,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
// 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 Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// F1: the anchor comes from the seam. `Err` and not `Ok` when nothing
// survives, deliberately: `try_infinite_refill` reads this Result to decide
// whether a radio actually started, and an `Ok` over an empty publish would
// make it report a chain that never played.
let Some(anchor) = set_queue_stamped(runtime, tracks, Some(start), context).await else {
return Err("every track in the list was filtered (blacklist / unavailable)".to_string());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_remote(runtime, first_id, "play_track_list_in").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// When the queue is exhausted and `InfiniteRadio` autoplay is on, build a
/// smart artist radio seeded by the just-finished track and start it, replacing
/// the spent queue. Returns `true` when a radio actually started.
///
/// Port of `playback.rs::try_infinite_refill`, including the reason it does not
/// mirror Tauri literally: Tauri appends the radio and retries `next()`, which
/// cannot work here because `QueueManager::next()` has already nulled
/// `current_index` at the queue edge — the retry would replay the OLD queue
/// from index 1 instead of the radio. Starting the radio fresh reaches the
/// intended behaviour and chains: each radio's end re-seeds the next one, which
/// is what makes it infinite rather than one extra radio.
///
/// `play_track_list` runs the queue through `filter_unplayable_queue`, so a
/// radio made entirely of blocked artists — or of tracks Qobuz has pulled —
/// cannot come back as playback: the seam publishes nothing and
/// `play_track_list_in` returns `Err`, which this fn reports as "no chain".
async fn try_infinite_refill(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
seed_track_id: u64,
) -> bool {
if !crate::queue_qt::is_infinite_play() || seed_track_id == 0 {
return false;
}
let artist_id = match runtime.core().get_track(seed_track_id).await {
Ok(track) => match track.performer.as_ref().map(|p| p.id) {
Some(id) => id,
None => {
log::warn!("[qbz-qt] infinite radio: seed track {seed_track_id} has no performer");
return false;
}
},
Err(e) => {
log::warn!("[qbz-qt] infinite radio: get_track {seed_track_id} failed: {e}");
return false;
}
};
match runtime.core().create_smart_artist_radio(artist_id).await {
Ok(tracks) if !tracks.is_empty() => {
let queue: Vec<QueueTrack> = tracks
.iter()
.map(crate::foryou_qt::to_queue_track)
.collect();
match play_track_list(runtime, queue, 0, false).await {
Ok(()) => {
log::info!(
"[qbz-qt] infinite radio: chained {} track(s) from artist {artist_id}",
tracks.len()
);
true
}
Err(e) => {
log::warn!("[qbz-qt] infinite radio: play failed: {e}");
false
}
}
}
Ok(_) => false,
Err(e) => {
log::warn!("[qbz-qt] infinite radio: build failed: {e}");
false
}
}
}
/// The three insertion modes the row/card menus use,
/// mirroring `enqueue_album`: "next" inserts at the cursor (fed REVERSED so a
/// multi-track insert keeps its order), "later" appends to the manual block's
/// tail, anything else appends.
///
/// Exists because ArtistView's ⋯ menu has BOTH "Play all next" and "Add all to
/// queue" and the port wired them to the same append (the Slint arm for
/// `next-all` is `enqueue_artist_top_selected(.., next: true)`, main.rs:15301).
/// Its append-only wrapper `enqueue_track_list` is gone with that miswiring —
/// "queue" through this funnel IS the append, and a second name for it was
/// what let the mode-less call site look correct.
pub async fn enqueue_track_list_mode(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
mode: &str,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
let tracks = stamped(tracks, None);
// The seam can empty a batch that was non-empty on entry (the check above
// ran BEFORE the filter). Stop here rather than routing an empty add to a
// peer and calling `add_tracks(vec![])`: the seam already said why.
if tracks.is_empty() {
log::info!("[qbz-qt] enqueue_track_list_mode: every track was filtered");
return Ok(());
}
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
if route_enqueue_to_peer(&tracks, mode).await {
return Ok(());
}
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
let added_castable = batch_all_qconnect_castable(&tracks);
match mode {
"next" => {
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
}
"later" => {
for track in tracks {
runtime.core().add_track_later(track).await;
}
}
_ => runtime.core().add_tracks(tracks).await,
}
// QConnect sync-on-add (#442): push the updated surviving queue to the
// session.
sync_qconnect_after_add(added_castable).await;
publish_queue(runtime).await;
Ok(())
}
/// ArtistView ⋯ "Play all next" / "Add all to queue" — the whole body, so the
/// invokable in `player_bridge.rs` can call it without a `main.rs` shim (see
/// GLUE in the report).
///
/// The QConnect routed arm + sync tail live in the delegate
/// (`enqueue_track_list_mode`) — arming here too would double-fire the routed
/// send whenever a peer is active.
pub async fn enqueue_artist_top_mode(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
mode: &str,
) -> Result<(), String> {
let (queue, _) = crate::artist_qt::top_queue(None);
enqueue_track_list_mode(runtime, queue, mode).await
}
/// Immersive search (artist, next|queue) dispatch (contract
/// 2026-08-02-immersive-port §3.4, round-2-verified mapping): fetch the
/// artist page BY ID and enqueue its top tracks through the shared mode
/// funnel. NEW additive helper — the existing `enqueue_artist_top_mode`
/// reads the ArtistView stash (`artist_qt::top_queue`) and is BANNED from
/// immersive: there is no artist view under the overlay, so the stash would
/// be stale or empty. Unlike `play_artist` there is no discography fallback
/// — the Slint arm (`main.rs:10732-10749`) also enqueues top tracks ONLY.
pub async fn enqueue_artist_top_by_id(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
mode: &str,
) -> Result<(), String> {
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let page = runtime
.core()
.get_artist_page(id, None)
.await
.map_err(|e| format!("get_artist_page {artist_id} failed: {e}"))?;
let tracks = artist_top_queue_tracks(
page.top_tracks.as_deref().unwrap_or(&[]),
artist_id,
&page.name.display,
);
if tracks.is_empty() {
return Err(format!("artist {artist_id} has no top tracks"));
}
enqueue_track_list_mode(runtime, tracks, mode).await
}
/// One track from an album context into the queue ("Play next" / "Add to
/// queue" on an AlbumView row).
pub async fn enqueue_album_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
mode: &str,
) -> Result<(), String> {
if is_local_album(album_id) {
// One LOCAL row: the local enqueue resolves it by row id (the cached
// Tracks page / open detail / library.db), so the group key is only
// used here to decide the route.
log::info!("[qbz-qt] enqueue_album_track {album_id}/{track_id}: local/Plex -> local path");
crate::local_playback::enqueue(
runtime,
"track".to_string(),
track_id.to_string(),
mode.to_string(),
)
.await;
return Ok(());
}
let tracks = fetch_album_queue(runtime, album_id).await?;
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
return Err(format!("track {track_id} not in album {album_id}"));
};
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled.
//
// "later" now travels VERBATIM. It used to be collapsed into "queue" here,
// to match a local match below that had no block-tail arm — so on the peer
// as well as locally, "Play later" and "Add to queue" did the same thing.
// `route_track_to_peer` has had a real "later" arm all along
// (`play_later_on_peer_if_active`); this was the line hiding it.
let routed_mode = match mode {
"next" => "next",
"later" => "later",
_ => "queue",
};
if route_track_to_peer(&track, routed_mode).await {
return Ok(());
}
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
let added_castable = batch_all_qconnect_castable(std::slice::from_ref(&track));
// THREE modes, like `enqueue_track_list_mode`. This match had only two, and
// that is why the album page hid its "Play later": with "later" falling
// into the same plain append as "Add to queue", offering both would have
// been two menu entries doing one thing — the rendered-and-inert defect
// this tree refuses on principle. `add_track_later` lands on the manual
// block's TAIL, which is the whole distinction.
match mode {
"next" => runtime.core().add_track_next(track).await,
"later" => runtime.core().add_track_later(track).await,
_ => runtime.core().add_track(track).await,
}
// QConnect sync-on-add (#442): push the updated queue to the session;
// skipped silently for a non-castable add.
sync_qconnect_after_add(added_castable).await;
publish_queue(runtime).await;
Ok(())
}
/// Shuffle-play an album (AlbumView header Shuffle): mix the album's own list
/// and start at the top of the mixed order.
///
/// The MODE alone was not a shuffle. `set_shuffle(true)` only randomises what
/// comes NEXT, so `play_album` still started on track 1 — pressing Shuffle on
/// an album played its opener every single time. Owner ruling 2026-08-01:
/// every shuffle must be genuinely random; the flag-and-play-from-the-top
/// shape is an artifact of a very early Tauri experiment. The mode is still
/// raised so playback stays shuffled past this album.
///
/// A LOCAL album keeps going through the local path, which owns its own queue
/// building and does the same mix inside `local_playback::play_rows`.
pub async fn play_album_shuffled(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
if is_local_album(album_id) {
crate::local_playback::play_album(runtime, album_id.to_string(), None, true).await;
return Ok(());
}
let mut tracks = fetch_album_queue(runtime, album_id).await?;
xorshift_shuffle(&mut tracks);
play_track_list_in(runtime, tracks, 0, false, PlayContext::album(album_id)).await
}
/// Build the queue meta for a Library-feed track (shared by
/// play_single_track / enqueue_single_track).
fn feed_queue_track(track_id: u64) -> Result<QueueTrack, String> {
let id = track_id.to_string();
let item = crate::library_qt::with_library(|d| {
d.feed
.iter()
.find(|i| i.kind == "track" && i.id == id)
.cloned()
})
.flatten()
.ok_or_else(|| format!("track {track_id} not in the library feed"))?;
// A local / Plex feed row is a LOCAL queue track — file path, Plex rating
// key, real source — and `library_qt` owns the raw-row cache that knows
// them. Falling through would rebuild it below as a Qobuz track wearing
// the same numeric id, which is somebody else's song (see
// `library_qt::feed_track_to_queue`, the twin of this function).
if item.source == "local" || item.source == "plex" {
return crate::library_qt::feed_track_to_queue(&item)
.ok_or_else(|| format!("local track {track_id} has no raw row cached"));
}
let duration_secs = {
let mut parts = item.duration.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)
};
Ok(QueueTrack {
id: track_id,
title: item.title.clone(),
version: None,
artist: item.artist.clone(),
album: item.album.clone(),
album_version: None,
duration_secs,
artwork_url: if item.image_url.is_empty() {
None
} else {
Some(item.image_url.clone())
},
// playback.rs `make_queue_track` (:2426): the CATALOG max travels with
// the queue track. `None` here zeroed `quality_state`'s `TRACK_MAX_*`
// seed, so a Library-feed track play drew a bare tier on the NPB
// AudioStamp with no "24-bit / 96 kHz" line. `library_qt::FeedItem`
// carries the raw numbers alongside the formatted `quality_detail`.
hires: item.quality_tier == "hires",
bit_depth: item.bit_depth,
sample_rate: item.sample_rate,
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(),
// D5: the feed row's own answer. Twin of
// `library_qt::feed_track_to_queue` over the SAME `FeedItem` — the two
// must not disagree about what a row is, which is the whole reason both
// read the same field instead of hardcoding a yes.
streamable: !item.not_streamable,
source: Some("qobuz".to_string()),
parental_warning: item.explicit,
source_item_id_hint: None,
context_kind: None,
context_id: None,
isrc: None,
recording_mbid: None,
})
}
/// Catalog fallback for a track the Library feed does NOT hold — Search rows
/// and the search hero, Home's "Recently played tracks" rail, anything reached
/// by id from a view that keeps no full `Track`. This is the Qt port of the
/// Slint's last resort, `playback.rs::play_track_now` (:3242-3283): fetch the
/// track and build its one-row queue from the response. Context is left
/// unstamped exactly like the Slint — a bare track play falls back to its own
/// album in `refresh_now_playing`.
async fn catalog_queue_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<QueueTrack, String> {
let track = runtime
.core()
.get_track(track_id)
.await
.map_err(|e| format!("get_track {track_id} failed: {e}"))?;
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
// Queue row / np bar feed: full variant (best()) — the
// thumbnail down-tier was reverted after the 2026-08-15 owner
// smoke (contract 04 §3);
// the full set is registered for the immersive large feed (D2).
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
if let Some(album) = track.album.as_ref() {
if !album.id.is_empty() {
crate::artwork_qt::note_np_variants(&album.id, &album.image);
}
}
let album_key = if album_id.is_empty() {
None
} else {
Some(album_id.clone())
};
Ok(QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: album_title,
album_version: None,
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork)
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
context_kind: None,
context_id: None,
isrc: track.isrc.clone(),
recording_mbid: None,
})
}
/// One queue row for a bare track id: the Library feed first (free, already
/// resolved), the catalog second. Before the fallback existed, every entry
/// point outside the Library feed — search rows, the search hero, Home's
/// Recently-Played-Tracks rail — returned Err here and rendered a control that
/// did nothing.
/// `pub(crate)` since 2026-08-10: `queue_qt::insert_dragged_track` resolves a
/// dragged track id through this same path, so a drop into the queue and an
/// "Add to queue" menu item cannot disagree about what a row is.
pub(crate) async fn queue_track_for(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<QueueTrack, String> {
match feed_queue_track(track_id) {
Ok(qt) => Ok(qt),
Err(feed_miss) => {
log::debug!("[qbz-qt] {feed_miss}; resolving from the catalog");
catalog_queue_track(runtime, track_id).await
}
}
}
/// Play a single track as a one-element queue (Library track rows, Search
/// rows/hero, Home's Recently-Played-Tracks rail). The queue meta comes from
/// the Library feed row when it has one and from `get_track` otherwise; the
/// audio resolves by id through the same `play_track_resolved` path.
pub async fn play_single_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<(), String> {
let qt = queue_track_for(runtime, track_id).await?;
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
// Bare single-track play: no container origin, so the derive falls to the
// track's own album — same landing spot as the Slint fallback.
// F1 in its smallest form: when the seam drops this one row there is
// nothing to play, and the previous queue keeps going. `track_id` is the id
// the CALLER asked for; the anchor is the id the core actually holds.
let Some(anchor) = set_queue_stamped(runtime, vec![qt], Some(0), None).await else {
log::info!("[qbz-qt] play_single_track: {track_id} was filtered, queue untouched");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_single_track: playing {first_id}");
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_remote(runtime, first_id, "play_single_track").await {
return Ok(());
}
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// One Library-feed track into the EXISTING queue (the TrackCard / list-row
/// context menus): "next" -> add_track_next, "later" -> add_track_later
/// (#442 block tail), "queue" -> add_track (append).
pub async fn enqueue_single_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
mode: &str,
) -> Result<(), String> {
// NOT `.expect("one track in, one track out")` any more: since the enqueue
// seam filters (F2 + D5), one row in can legitimately be ZERO rows out — a
// blocked artist, or a track Qobuz pulled that we hold no download for. The
// `expect` would have turned that into a PANIC on a menu click. The seam has
// already toasted the reason, so this is a quiet Ok.
let Some(qt) = stamped(vec![queue_track_for(runtime, track_id).await?], None).pop() else {
log::info!("[qbz-qt] enqueue_single_track: {track_id} was filtered out");
return Ok(());
};
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
if route_track_to_peer(&qt, mode).await {
return Ok(());
}
let Some(_owner_action) = begin_owner_action() else {
return Ok(());
};
let added_castable = batch_all_qconnect_castable(std::slice::from_ref(&qt));
match mode {
"next" => runtime.core().add_track_next(qt).await,
"later" => runtime.core().add_track_later(qt).await,
_ => runtime.core().add_track(qt).await,
}
// QConnect sync-on-add (#442): push the updated queue to the session;
// skipped silently for a non-castable add.
sync_qconnect_after_add(added_castable).await;
publish_queue(runtime).await;
Ok(())
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
// Repeated Play must not cancel a pending start; Pause must cancel it and
// must never enter the cold-start/resume branch of the transport ladder.
fn ignore_playing_request(requested: Option<bool>, playing: bool, pending: bool) -> bool {
match requested {
Some(true) => playing || pending,
Some(false) => !playing && !pending,
None => false,
}
}
pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
request_playing(runtime, None).await;
}
pub(crate) async fn request_playing(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
requested: Option<bool>,
) {
let Some(_transport_action) = begin_transport_action() else {
return;
};
// A connected renderer owns transport — the local player is stopped.
match crate::cast_qt::service().request_playing_if_cast(requested).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][Cast] toggle-play failed: {e}");
return;
}
}
// QConnect CONTROLLER mode (dispatch priority cast → QConnect → local,
// Slint main.rs:14238-14247): a peer renderer owns playback — toggle IT.
// `true` = handled remotely; the local path MUST NOT run. On error there
// is no local fallback (a peer owns audio; a local resume would
// double-play).
if let Some(svc) = crate::qconnect_qt::service() {
match svc.request_remote_renderer_playback_if_active(requested).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] toggle-play handoff: {e}");
return;
}
}
}
let event = runtime.core().player().get_playback_event();
let was_playing = event.is_playing;
let pending = PENDING_PLAY_ID.load(Ordering::Relaxed);
if ignore_playing_request(requested, was_playing, pending != 0) {
return;
}
// COLD CURSOR: not playing, and the engine holds no loaded stream.
//
// `resume()` only means "un-pause a stream that is already decoded". The
// audio thread answers a resume with no data by logging
// "cannot resume - no audio data available" and RETURNING
// (`qbz-player/src/player/mod.rs` around :3184-3205) — it does not error,
// so the `Err` arm below never fires and the user just sees a dead Play
// button. Reachable states:
//
// - **A restored session.** `session_persist::restore` rebuilds the queue
// and the cursor, and the NPB paints the track, but nothing is loaded
// until something plays. This is the owner-reported bug: queue restored,
// bar correct, Play does nothing.
// - A QConnect renderer queue whose cursor sits on a never-loaded track.
// - A cold cursor after the queue ended.
//
// The reference solves it the same way and names the same log line
// (`crates/qbz/src/playback.rs:4781-4791`, `:4826-4841`): load and play the
// CURRENT queue track instead of resuming. A normal pause leaves the stream
// loaded, so `has_loaded_audio()` stays true and the ordinary pause/resume
// path is untouched.
// A cold start is already in flight: this press is a CANCEL, not a second
// start. Without this arm the window below is re-entrant — see
// PENDING_PLAY_ID. Ordered before the `has_loaded_audio` test because
// during that window the engine answers false to both.
if !was_playing && pending != 0 {
let Some(_owner_action) = begin_owner_action() else {
return;
};
log::info!("[qbz-qt] toggle-play: cancelling the in-flight cold start of track {pending}");
let _ = begin_resolved_play();
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
crate::now_playing::clear_loading();
if let Err(e) = runtime.core().stop() {
log::warn!("[qbz-qt] toggle-play: stop-during-load failed: {e}");
}
return;
}
if !was_playing {
let current = runtime.core().current_track().await;
// IDLE LIST: Clear while paused intentionally removes the cursor and
// NPB, but Add to queue / Play next / Play later deliberately do not
// create a new current row. The paused engine can still hold the old
// decoded stream here, so checking `has_loaded_audio()` first would
// resume the track the user just cleared. Promote the first live queue
// occurrence before considering resume, with the expected id checked
// under the queue lock so a concurrent mutation cannot start a
// different row.
if current.is_none() {
let Some(_owner_action) = begin_owner_action() else {
return;
};
let state = runtime.core().get_queue_state_full().await;
let Some(candidate) = state.upcoming.first().cloned() else {
log::info!("[qbz-qt] toggle-play ignored: empty queue");
return;
};
if crate::local_playback::preflight_queue_track(&candidate)
.await
.is_err()
{
return;
}
let Some(track) = runtime
.core()
.play_upcoming_at_preserving_timeline(0, candidate.id)
.await
else {
log::warn!("[qbz-qt] toggle-play: idle queue changed before activation");
return;
};
log::info!(
"[qbz-qt] toggle-play: idle queue -> starting first track {}",
track.id
);
PENDING_PLAY_ID.store(track.id, Ordering::Relaxed);
play_queue_track(runtime, track.id, 0).await;
PENDING_PLAY_ID
.compare_exchange(track.id, 0, Ordering::Relaxed, Ordering::Relaxed)
.ok();
return;
}
if !runtime.core().player().has_loaded_audio() {
let Some(_owner_action) = begin_owner_action() else {
return;
};
if let Some(track) = current {
log::info!(
"[qbz-qt] toggle-play: no loaded audio -> cold-starting current track {}",
track.id
);
PENDING_PLAY_ID.store(track.id, Ordering::Relaxed);
// Goes through the normal play path, so the persisted resume
// position is consumed by the same `take_resume_for` handshake
// a fresh play uses — a bare `resume()` could never have
// applied it, because there was no stream to seek.
play_queue_track(runtime, track.id, 0).await;
// Cleared unconditionally: `play_queue_track` swallows its own
// errors, so this is the only place a FAILED cold start can
// release the latch. The poll loop's track-change edge clears
// it too — whichever runs first wins, and both are idempotent.
// Leaving it set on failure is precisely the Tauri scar.
PENDING_PLAY_ID
.compare_exchange(track.id, 0, Ordering::Relaxed, Ordering::Relaxed)
.ok();
}
return;
}
}
let result = if was_playing {
runtime.core().pause()
} else {
runtime.core().resume()
};
if let Err(e) = result {
log::warn!("[qbz-qt] toggle-play failed: {e}");
return;
}
if was_playing {
// Persist the paused position: pausing is the strongest signal the user
// is done for now, and the ~5s poll flush can be up to 5s stale.
// No-op unless `persist_session` is on.
if let Some(_owner_action) = begin_owner_action() {
qbz_app::session_persist::capture_and_save(runtime).await;
}
}
}
pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let Some(_owner_action) = begin_owner_action() else {
return;
};
// QConnect CONTROLLER mode (Slint main.rs:14268-14277): a peer renderer
// owns playback — skip on IT and return; the local cursor stays put (the
// cloud echo re-materializes the queue). NOTE: no cast branch here, 1:1
// with the reference (main.rs:14261-14267) — while casting, the local
// next() flow runs and the play arm casts the new current track.
if let Some(svc) = crate::qconnect_qt::service() {
match svc.skip_next_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] next handoff: {e}");
return;
}
}
}
// Prove a local file still exists before advancing the cursor. The player
// keeps the old stream alive until a replacement starts, so advancing
// first would make queue, NPB and MPRIS lie about what is audible.
if let Some(candidate) = runtime.core().peek_upcoming(1).await.into_iter().next() {
if crate::local_playback::preflight_queue_track(&candidate)
.await
.is_err()
{
return;
}
}
if let Some(track) = runtime.core().next_track().await {
play_queue_track(runtime, track.id, 0).await;
}
}
pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let Some(_owner_action) = begin_owner_action() else {
return;
};
// QConnect CONTROLLER mode (Slint main.rs:14293-14302) — see next() above.
if let Some(svc) = crate::qconnect_qt::service() {
match svc.skip_previous_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] previous handoff: {e}");
return;
}
}
}
// `previous_track` consumes history and publishes QueueUpdated. Validate
// a physical local target first so a missing/moved file cannot leave the
// cursor, NPB and MPRIS one row behind the audio the user still hears.
if let Some(candidate) = runtime.core().peek_previous_track().await {
if crate::local_playback::preflight_queue_track(&candidate)
.await
.is_err()
{
return;
}
}
if let Some(track) = runtime.core().previous_track().await {
play_queue_track(runtime, track.id, 0).await;
}
}
pub(crate) async fn play_queue_track_public(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) {
play_queue_track(runtime, track_id, 0).await;
}
/// Play a queue track from the top of the source ladder (cast → peer → local
/// → Qobuz).
///
/// `start_position_secs` is the position to START at, and 0 means "the normal
/// start", NOT "second zero": `play_resolved_offline_aware` treats 0 as the
/// signal to consult `session_persist::take_resume_for`, so a restored track
/// resumes where it was left. A non-zero value is an EXPLICIT request and wins
/// over the restore stash — which is what lets a seek on a cold cursor
/// cold-start directly at the dragged position.
async fn play_queue_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
start_position_secs: u64,
) {
// Supersede preference/filesystem work that has not reached the player
// yet. Qobuz starts below bump once more when it enters the resolved
// funnel; local/remote source starts still need this first fence.
let _ = begin_resolved_play();
// A connected renderer (cast, then QConnect peer) owns playback: route
// the new track to it and never start the local backend, or both play.
// Same seam as every other entry point — see `route_play_remote`.
if route_play_remote(runtime, track_id, "play_queue_track").await {
return;
}
let Some(_owner_action) = begin_owner_action() else {
return;
};
// Source-aware audible step: a LOCAL file plays from disk through the
// player's play_data seam. The Qobuz tier-walk below needs a client and
// would fail with "No Qobuz client available" on every local advance.
// ONE seam for both kinds of "cannot play". A local/Plex row that fails
// must NOT fall through to the Qobuz walk below: it would fail there with
// an unrelated error, `auto_skip_unavailable` would decline it as
// non-terminal, and playback would stop dead on one unreachable file. To
// the user a withdrawn Qobuz track and a file on a share that went away
// are the same event, so they take the same bounded skip walk.
match crate::local_library_qt::play_current_if_local(runtime, track_id).await {
crate::local_playback::LocalPlay::Played => {
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
return;
}
crate::local_playback::LocalPlay::Unavailable(reason) => {
log::warn!("[qbz-qt] playback: {reason} — skipping");
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
auto_skip_unavailable(runtime, reason, Some(track_id)).await;
return;
}
crate::local_playback::LocalPlay::NotLocal => {}
}
if let Err(e) = play_resolved_offline_aware(runtime, track_id, start_position_secs).await {
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
// The bar must not keep showing the PREVIOUS track while the cursor has
// already moved. The old early `return` skipped both refreshes, which is
// how a failed advance left a stale card over a silent player.
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
auto_skip_unavailable(runtime, e, Some(track_id)).await;
return;
}
// A track actually started: the consecutive-skip budget is spent per RUN of
// bad tracks, not per session (playback.rs UNAVAILABLE_SKIPS reset).
UNAVAILABLE_SKIPS.store(0, Ordering::SeqCst);
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
}
/// Re-resolve an already audible album track after AlbumView changes its
/// persisted playback source. The queue/cursor stay intact; only the bytes are
/// replaced. The player's current position is a cheap atomic snapshot, so the
/// ordinary resolved funnel can resume there instead of making the user wait
/// for the next track (or restarting unnecessarily).
pub(crate) async fn rematerialize_current_album_source(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) {
let Some(owner_action) = begin_owner_action() else {
return;
};
if crate::cast_qt::is_casting().await {
return;
}
let Some(track) = runtime.core().current_track().await else {
return;
};
let event = runtime.core().player().get_playback_event();
if !source_change_targets_current(album_id, &track, &event) {
return;
}
let position = event.position;
if let Err(error) = runtime.core().player().stop() {
log::warn!(
"[qbz-qt] purchase: could not stop track {} for source change: {error}",
track.id
);
return;
}
drop(owner_action);
log::info!(
"[qbz-qt] purchase: rematerializing track {} at {}s after source change",
track.id,
position
);
play_queue_track(runtime, track.id, position).await;
}
fn source_change_targets_current(
album_id: &str,
track: &QueueTrack,
event: &PlaybackEvent,
) -> bool {
event.is_playing
&& event.track_id == track.id
&& track
.album_id
.as_deref()
.is_some_and(|current_album| current_album == album_id)
}
/// True when a stringified play error means the track cannot play now or ever
/// at any quality, as opposed to a transient network/server failure (those are
/// already retried with backoff inside the client and must NOT cost the user a
/// good track). Verbatim from playback.rs:327-332 — the `ApiError` Display texts
/// survive the `Result<(), String>` flattening, so a substring match is the same
/// pragmatic contract the Tauri frontend used.
fn is_terminal_unavailable(e: &str) -> bool {
e.contains("no longer available") // ApiError::TrackUnavailable
|| e.contains("not streamable") // ApiError::NonStreamable
|| e.contains("No valid quality available") // ApiError::NoQualityAvailable
}
/// A Qobuz 403 / the client-side 403 back-off (issue #637). NOT the track's
/// fault — every track 403s the same way — so it must not count as an
/// "unavailable" skip. Pre-fix in the Slint this burned through five good
/// tracks while showing a misleading "no longer available"
/// (playback.rs:334-343).
fn is_forbidden_backoff(e: &str) -> bool {
e.contains("Access forbidden by Qobuz") // ApiError::Forbidden
|| e.contains("backing off after repeated 403s") // ApiError::ForbiddenCircuitOpen
}
/// Consecutive auto-skips over tracks whose play failed TERMINALLY (Tauri #467
/// parity: `MAX_CONSECUTIVE_SKIPS = 5`). Reset the moment any track starts.
static UNAVAILABLE_SKIPS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
const MAX_UNAVAILABLE_SKIPS: u32 = 5;
/// One unavailable track must not wedge the whole queue (PARITY-DEBT #2 —
/// Tauri #467, re-introduced by this port). Port of `auto_skip_unavailable`
/// (playback.rs:766-818).
///
/// The signature RETURNS a boxed `dyn Future + Send` rather than being an
/// `async fn`, for the reason the reference documents: the recursion makes the
/// future's Send-ness self-referential and an inferred `impl Future` type sends
/// the compiler into a query cycle. Declaring the concrete boxed type is what
/// cuts it.
fn auto_skip_unavailable<'a>(
runtime: &'a Arc<AppRuntime<LoggingAdapter>>,
error: String,
failed_track_id: Option<u64>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
// A 403 / back-off is not this track's fault: stop cleanly and say so
// instead of eating five good tracks looking for one that works.
if is_forbidden_backoff(&error) {
crate::toast_qt::error(qbz_i18n::t("Access forbidden by Qobuz"));
crate::now_playing::set_playing(false);
return;
}
// Anything that is not TERMINALLY unavailable is transient — the client
// already retried it. Do not skip a good track over a network blip.
if !is_terminal_unavailable(&error) {
return;
}
let Some(_owner_action) = begin_owner_action() else {
return;
};
// §5.1 clause 2 (D3): remember it for the rest of the run, so the queue
// filter stops offering it to the player. The API flag covers the
// tracks Qobuz already admits are gone; THIS covers the ones it still
// advertises as streamable and then refuses to serve — which is exactly
// what the owner's test track does at the stream-url step.
if let Some(id) = failed_track_id {
crate::track_replace_qt::session_unavailable::mark(id);
}
crate::toast_qt::warning(qbz_i18n::t("This track is no longer available"));
let skips = UNAVAILABLE_SKIPS.fetch_add(1, Ordering::SeqCst) + 1;
if skips > MAX_UNAVAILABLE_SKIPS {
log::warn!(
"[qbz-qt] playback: {MAX_UNAVAILABLE_SKIPS} consecutive unavailable tracks — stopping the skip walk"
);
crate::toast_qt::warning(qbz_i18n::t("No available tracks to play"));
crate::now_playing::set_playing(false);
UNAVAILABLE_SKIPS.store(0, Ordering::SeqCst);
// Nowhere left to skip TO, so clean rather than leave the user a
// queue of rows that each stop playback when reached.
crate::queue_qt::purge_unavailable_upcoming(runtime).await;
return;
}
let Some(track) = runtime.core().next_track().await else {
// Queue edge reached while skipping — stop quietly, the warning
// above already told the user why. Same clean-up as the budget
// arm: the rows behind us cannot play either.
crate::now_playing::set_playing(false);
UNAVAILABLE_SKIPS.store(0, Ordering::SeqCst);
crate::queue_qt::purge_unavailable_upcoming(runtime).await;
return;
};
log::info!(
"[qbz-qt] advance: skipping unavailable track, trying {} ({skips}/{MAX_UNAVAILABLE_SKIPS})",
track.id
);
play_queue_track(runtime, track.id, 0).await;
})
}
/// The LOCAL tail of `seek_frac` (no cast / QConnect routing): the A-B loop's
/// jump, which is only ever armed for local playback.
pub(crate) async fn seek_local_secs(runtime: &Arc<AppRuntime<LoggingAdapter>>, target: u64) {
let Some(_transport_action) = begin_transport_action() else {
return;
};
if let Err(e) = runtime.core().seek(target) {
log::warn!("[qbz-qt] seek failed: {e}");
return;
}
crate::media_controls_qt::push_seeked(target);
}
pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
let Some(_transport_action) = begin_transport_action() else {
return;
};
match crate::cast_qt::service()
.seek_fraction_if_cast(frac as f64)
.await
{
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][Cast] seek failed: {e}");
return;
}
}
// The peer route resolves the current remote track's duration. The local
// player can still retain the duration from before takeover (or zero).
if let Some(svc) = crate::qconnect_qt::service() {
match svc.seek_fraction_if_remote(frac).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] seek handoff: {e}");
return;
}
}
}
let event = runtime.core().player().get_playback_event();
// COLD CURSOR: the bar is draggable but the engine holds no stream.
//
// Two ways in, and BOTH were silently wrong before this branch:
//
// (a) A restored session. `event.duration` is 0 because nothing ever
// loaded, so the guard below returned and the drag did nothing at
// all. The bar still looked seekable, because the NPB's duration
// comes from the restored track's META, not from the engine. The
// keyboard seek was worse: it derives its fraction from the MODEL
// position, which is non-zero, so it built a perfectly valid
// fraction and handed it to a function that discarded it.
// (b) A cold cursor after a Stop (cast/QConnect handoff, or a queue that
// ended). `SharedState::duration` is never reset by the Stop arm, so
// it still holds the PREVIOUS track's length — the guard passed,
// `seek()` returned Ok because it only posts a command, and we then
// told D-Bus we had jumped. The audio thread meanwhile bailed with
// "cannot seek - no audio data available". The desktop widget was
// being told a position the player was not at and could not reach.
//
// Cold-starting AT the requested position is the honest answer to a drag,
// and it composes for free: a non-zero `start_position_secs` is treated as
// an explicit request and wins over the restore stash
// (`play_resolved_offline_aware`), so dragging before pressing Play does
// what the user asked instead of jumping back to the saved position.
if !runtime.core().player().has_loaded_audio() {
let Some(track) = runtime.core().current_track().await else {
return;
};
let duration = track.duration_secs;
if duration == 0 {
return;
}
let target = (frac.clamp(0.0, 1.0) * duration as f32) as u64;
log::info!(
"[qbz-qt] seek on a cold cursor -> cold-starting track {} at {target}s",
track.id
);
play_queue_track(runtime, track.id, target).await;
return;
}
if event.duration == 0 {
return;
}
let target = (frac.clamp(0.0, 1.0) * event.duration as f32) as u64;
crate::ab_loop_qt::on_manual_seek(target);
if let Err(e) = runtime.core().seek(target) {
log::warn!("[qbz-qt] seek failed: {e}");
return;
}
// Tell the desktop we JUMPED. The 1 Hz `push_position` keeps the stored
// value fresh for a client that opens later; `Seeked` is the only thing
// that reaches one already open and extrapolating from what it read then.
crate::media_controls_qt::push_seeked(target);
}
pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32) {
let Some(_transport_action) = begin_transport_action() else {
return;
};
if matches!(
crate::cast_qt::service().set_volume_if_cast(volume).await,
Ok(true)
) {
return;
}
// QConnect CONTROLLER mode (Slint main.rs:14378-14389): set the REMOTE
// renderer's volume. The remote API wants 0..100; the bar gives a 0..1
// fraction. TRAP (§12.30): a volume-DISALLOWING peer returns Ok(true) —
// handled NO-OP — so a drag on a volume-locked peer never falls through
// to change the LOCAL volume.
if let Some(svc) = crate::qconnect_qt::service() {
let remote_volume = (volume.clamp(0.0, 1.0) * 100.0).round() as i32;
match svc.set_volume_if_remote(remote_volume).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] set-volume handoff: {e}");
return;
}
}
}
let _ = runtime.core().set_volume(volume.clamp(0.0, 1.0));
MUTED.store(
volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0,
Ordering::Relaxed,
);
}
pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let Some(_transport_action) = begin_transport_action() else {
return;
};
// The peer route toggles the peer's reported/optimistic mute state; the
// local MUTED flag belongs to the owner and must survive handoff intact.
if let Some(svc) = crate::qconnect_qt::service() {
match svc.toggle_mute_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] toggle-mute handoff: {e}");
return;
}
}
}
if MUTED.swap(false, Ordering::Relaxed) {
// Unmute — restore the stored level.
let restored = f32::from_bits(PREMUTE_VOLUME.load(Ordering::Relaxed) as u32);
let restored = if restored > 0.0 { restored } else { 0.7 };
let _ = runtime.core().set_volume(restored);
crate::now_playing::set_muted(false);
} else {
// Mute — stash the current level, then drop to zero.
let current = runtime.core().player().get_playback_event().volume;
let current = if current > 0.0 { current } else { 0.7 };
PREMUTE_VOLUME.store(current.to_bits() as u64, Ordering::Relaxed);
MUTED.store(true, Ordering::Relaxed);
let _ = runtime.core().set_volume(0.0);
crate::now_playing::set_muted(true);
}
}
pub async fn toggle_shuffle(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let Some(owner_action) = begin_owner_action() else {
return;
};
// QConnect (Slint main.rs:14492-14501 — no cast arm in the reference):
// the CLOUD owns shuffle order while connected (gated on
// transport_connected inside the service — §12.14, no extra gate here);
// QBZ applies only the echo. `true` = handled remotely — a local reorder
// would diverge from the cloud's order.
if let Some(svc) = crate::qconnect_qt::service() {
match svc.toggle_shuffle_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] toggle-shuffle handoff: {e}");
return;
}
}
}
let event_before_reorder = runtime.core().player().get_playback_event();
let enabled = runtime.core().toggle_shuffle().await;
QUEUE_ORDER_GENERATION.fetch_add(1, Ordering::SeqCst);
crate::now_playing::set_shuffle(enabled);
publish_queue(runtime).await;
// A successor may already be appended to the audio engine during the
// final gapless window. Reordering only the QueueManager would then let
// that stale edge play anyway; the next poll would sync the cursor past
// the canonical rows we just preserved. Re-materialize the same current
// occurrence only when the armed successor no longer matches the new
// first upcoming row. Ordinary toggles do not touch playback.
let upcoming_id = runtime
.core()
.peek_upcoming(1)
.await
.first()
.map(|track| track.id);
if gapless_successor_changed(&event_before_reorder, upcoming_id) {
let track_id = event_before_reorder.track_id;
let position = event_before_reorder.position;
let was_playing = event_before_reorder.is_playing;
if let Err(error) = runtime.core().player().stop() {
log::warn!(
"[qbz-qt] shuffle: could not discard stale gapless successor for track {track_id}: {error}"
);
return;
}
drop(owner_action);
log::info!(
"[qbz-qt] shuffle: re-materializing track {track_id} at {position}s after its armed successor changed"
);
play_queue_track(runtime, track_id, position).await;
if !was_playing {
if let Err(error) = runtime.core().pause() {
log::warn!(
"[qbz-qt] shuffle: could not restore paused state for track {track_id}: {error}"
);
}
}
}
}
fn gapless_successor_changed(event: &PlaybackEvent, upcoming_id: Option<u64>) -> bool {
event.track_id != 0
&& event.gapless_next_track_id != 0
&& Some(event.gapless_next_track_id) != upcoming_id
}
pub async fn cycle_repeat(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let Some(_owner_action) = begin_owner_action() else {
return;
};
// QConnect (Slint main.rs:14517-14526 — no cast arm in the reference):
// gated on transport_connected inside the service (§12.14).
if let Some(svc) = crate::qconnect_qt::service() {
match svc.cycle_repeat_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[qbz-qt][QConnect] cycle-repeat handoff: {e}");
return;
}
}
}
let next_mode = match crate::now_playing::repeat_mode() {
0 => RepeatMode::All,
1 => RepeatMode::One,
_ => RepeatMode::Off,
};
runtime.core().set_repeat_mode(next_mode).await;
let value = match next_mode {
RepeatMode::Off => 0,
RepeatMode::All => 1,
RepeatMode::One => 2,
};
crate::now_playing::set_repeat_mode(value);
}
// ---------------------------------------------------------------------------
// Meta + queue publishing
// ---------------------------------------------------------------------------
/// Push the queue's current-track meta into the NowPlayingModel (title /
/// artist / quality / artwork via the phase-3 pipeline).
pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
crate::now_playing::clear_track();
crate::player_bridge::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(""));
b.as_mut().set_np_local_track_id(QString::from(""));
});
crate::lyrics_qt::publish_idle();
// Tray tooltip: the no-track arm. 1:1 with the reference, where
// `clear_track()` (crates/qbz/src/playback.rs:1918) and `set_track(…)`
// (`:2160`) are the two arms of ONE publisher,
// `refresh_now_playing_meta` (`:1895`) — this function is its Qt
// analogue, and BOTH pushes live here. Silent no-op with no tray.
// Clearing also drops `is_playing`, so the middle-click hint cannot go
// stale.
if let Some(t) = crate::tray_qt::handle() {
t.clear_track();
}
// OS media controls: the no-track arm, a SIBLING of the tray clear
// above and in the reference's order — dedupe reset, tray, MPRIS
// (crates/qbz/src/playback.rs:1913-1922). Tells the Plasma/GNOME widget
// we stopped and resets the metadata dedupe, so replaying the SAME
// track after a stop pushes its metadata again. Silent no-op when the
// integration never started.
crate::media_controls_qt::clear_now_playing();
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
let track_id_str = track.id.to_string();
// Active-row ALIAS for an offline row (Slint playback.rs:1978-1986). Its
// queue id is the Qobuz catalog id — that is what the offline tier is
// keyed on — but every Local Library list draws the row with its
// `library.db` id, so `np_track_id` alone never matched the row the user
// just played and the playing affordance stayed dark there.
//
// Restricted to Offline on purpose, and the restriction is the whole
// safety of it: `source_item_id_hint` is a shared slot that carries a
// Plex ALBUM key and a media server's own item id for those sources
// (`local_queue_track`). Publishing it unconditionally would hand a row
// list a number from another id space and light the wrong row.
let local_track_id_str =
if qbz_source::RawRef::from_queue_track(&track).badge == qbz_source::SourceBadge::Offline {
track.source_item_id_hint.clone().unwrap_or_default()
} else {
String::new()
};
crate::player_bridge::ui(move |mut b| {
b.as_mut()
.set_np_track_id(QString::from(track_id_str.as_str()));
b.as_mut()
.set_np_local_track_id(QString::from(local_track_id_str.as_str()));
});
log::info!(
"[qbz-qt] now playing: '{title}' — {} ({}s)",
track.artist,
track.duration_secs,
);
// Tray tooltip: the has-track arm (§5.6). Edge-triggered — this publisher
// runs on a track change, never per tick (§7-M12).
//
// The three strings are exactly the reference's
// (crates/qbz/src/playback.rs:2163-2165): the VERSIONED title, the artist,
// and the CLEAN album — `track.album`, NOT the release-variant
// `album_display` built below, which `playback.rs:1937` / `:1941-1948`
// deliberately keeps as a separate value.
//
// It goes in THIS function and not in the poll loop's de-duped track edge:
// `refresh_now_playing` has 23 call sites across nine modules, so binding
// the tooltip to the poll loop would leave it stale on every QConnect
// handoff, cast event and click-to-play (§15 trap 34).
if let Some(t) = crate::tray_qt::handle() {
t.set_track(title.clone(), track.artist.clone(), track.album.clone());
}
let purchase_format = (!track.is_local)
.then(|| crate::purchase_playback_qt::materialized_format(track.id))
.flatten();
let (tier, label) = match purchase_format.and_then(purchase_quality_badge) {
Some((tier, label, _, _)) => (tier.to_string(), label.to_string()),
None => quality_badge(&track).await,
};
let album_id = track.album_id.clone().unwrap_or_default();
// Album with its release variant appended ("Octavarium (2009 Remaster)"),
// 1:1 with the Slint `album_display` (playback.rs:1941-1949).
let album_display = match track
.album_version
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(v) => format!("{} ({v})", track.album),
None => track.album.clone(),
};
// OS media controls: the has-track arm, the second SIBLING of the tray push
// above (crates/qbz/src/playback.rs:2208-2223). This is what the KDE Plasma
// now-playing widget, GNOME's media section and playerctl read.
//
// It sits HERE, after `album_display` is built and BEFORE `title` is moved
// into the NowPlayingModel meta below, because MPRIS wants the pair the
// reference sends: the VERSIONED title and the release-variant album — not
// the clean `track.album` the tray tooltip takes (`:2213` vs `:2164`).
//
// Metadata is de-duped on (track id, resolved art) INSIDE the callee: this
// publisher re-runs on resume/seek/republish from 23 call sites, so an
// unconditional push would re-emit PropertiesChanged for identical values.
// The playback push is unconditional and optimistic (Playing at 0) exactly
// as in the reference — the poll loop's play/pause edge corrects it if the
// stream never opens.
crate::media_controls_qt::push_now_playing(&track, &title, &album_display);
// "Playing from" origin for the song-card layers glyph, re-derived from the
// CURRENT track's own stamp on EVERY change (never a stale global) —
// playback.rs:1953-1965. A track with no container origin falls back to its
// own album, exactly like the Slint.
let (context_kind, context_id) = match (
track.context_kind.as_deref().filter(|s| !s.is_empty()),
track.context_id.as_deref().filter(|s| !s.is_empty()),
) {
(Some(kind), Some(id)) => (kind.to_string(), id.to_string()),
_ => ("album".to_string(), album_id.clone()),
};
crate::now_playing::set_track(crate::now_playing::TrackMeta {
title: title.clone(),
artist: track.artist.clone(),
album: album_display.clone(),
album_id,
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
track_id: track.id,
source: track.source.clone().unwrap_or_default(),
context_kind,
context_id,
duration_secs: track.duration_secs as i32,
quality_tier: tier,
quality_label: label,
artwork_url: track.artwork_url.clone().unwrap_or_default(),
shuffle: state.shuffle,
repeat_mode: match state.repeat {
RepeatMode::Off => 0,
RepeatMode::All => 1,
RepeatMode::One => 2,
},
});
// The two output LEDs (+ the volume-lock flag) are decided HERE, not when
// the Settings page republishes: a stream is about to open and the
// backend/mode it will use is whatever the audio settings say right now.
// Publishing on the track edge is what makes the stamp live from the first
// note instead of updating only when the user changes page. Cheap: one WAL
// read + one Qt hop per track — no poll, and NOT publish_snapshot (that
// rebuilds the entire Settings document).
crate::output_labels::publish_current();
// Catalog max + whether the streaming-quality pref governs this request:
// the downgrade arrow compares DELIVERED against this (quality_state.rs).
// Local and Plex sources are not governed — nothing downgrades them.
let (source_depth, source_rate) = purchase_format
.and_then(purchase_quality_badge)
.map(|(_, _, depth, rate)| (depth, rate))
.unwrap_or((track.bit_depth, track.sample_rate));
let governed = !track.is_local && purchase_format.is_none();
crate::now_playing::set_catalog_quality(source_depth, source_rate, governed);
// Artwork through the same cache pipeline as Home (attach + background
// download + republish — single url here).
crate::artwork_qt::attach_now_playing(&track, &title, &album_display);
// Ambient background triad (phase 14): recompute from the new track's
// cover (no-op until the cover lands on disk — the previous palette
// stays, like the Slint's default-until-resolved).
crate::ambient_qt::update_for_artwork(&track.artwork_url.clone().unwrap_or_default());
// Lyrics for the new track (loading state, then the doc).
crate::lyrics_qt::publish_loading();
let runtime = runtime.clone();
let track = track.clone();
crate::spawn(async move {
crate::lyrics_qt::load_for_track(&runtime, &track).await;
});
}
/// Quality tier + exact label from a queue track's bit depth / sample rate.
///
/// 1:1 with `playback.rs:2025-2042` (`refresh_now_playing_meta`), which this
/// had forked from in three ways, each of them visible on the NPB AudioStamp:
///
/// - **DSD.** `bit_depth == Some(1)` marks a DSD stream (1-bit, `sample_rate`
/// = the DSD bit rate). It tiers "dsd" (its own mark since 2026-09-02; the
/// reference said "hires") and labels it through `dsd_multiple_label`
/// ("DSD64"); the forked version tiered it
/// "cd" and printed "1-bit / 2822.4 kHz".
/// - **Hz vs kHz.** `quality_state::detail` normalizes either unit (the
/// `>= 1000` guard) exactly like the track rows do, so the stamp and the
/// row can never disagree. The forked `format!` printed a raw Hz value as
/// "44100 kHz".
/// - **The empty-detail gate.** The reference blanks the line only when the
/// TIER is unknown or BOTH params are missing; the fork blanked it whenever
/// either one was missing, so a track with a known rate and no depth lost
/// its whole detail line instead of taking the formatter's depth default.
async fn quality_badge(track: &QueueTrack) -> (String, String) {
// Server-backed rows already retain their codec/container in the source
// cache, but QueueTrack predates that vocabulary. Ask the owning source
// once on the track edge instead of guessing from decoded PCM: an MP3
// decodes to 16-bit samples too, and calling that "CD" is a lie.
let source_hint = match qbz_source::SourceBadge::from_word(track.source.as_deref()) {
qbz_source::SourceBadge::Plex
| qbz_source::SourceBadge::Jellyfin
| qbz_source::SourceBadge::Subsonic => {
let raw = qbz_source::RawRef::from_queue_track(track);
match qbz_source::registry().claim(&raw) {
Ok(item) => qbz_source::registry()
.meta(&item)
.await
.ok()
.map(|meta| meta.quality),
Err(_) => None,
}
}
_ => None,
};
quality_badge_from(track, source_hint)
}
fn quality_badge_from(
track: &QueueTrack,
source_hint: Option<qbz_source::QualityHint>,
) -> (String, String) {
let bit_depth = track
.bit_depth
.or_else(|| source_hint.and_then(|hint| hint.bit_depth));
let sample_rate = track
.sample_rate
.or_else(|| source_hint.and_then(|hint| hint.sample_rate_khz));
let is_mp3 = source_hint.is_some_and(|hint| hint.format.eq_ignore_ascii_case("mp3"));
if is_mp3 {
let label = sample_rate
.filter(|rate| *rate > 0.0)
.map(|rate| {
let khz = if rate >= 1000.0 { rate / 1000.0 } else { rate };
format!("{} kHz · MP3", crate::home_qt::format_rate(khz))
})
.unwrap_or_else(|| "MP3".to_string());
return ("mp3".to_string(), label);
}
let tier = match bit_depth {
// 1-bit is DSD, and it wears its own mark: the Hi-Res logo on a DSD
// master described a 24/352.8 stream Qobuz never serves.
Some(1) => "dsd",
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if sample_rate.is_some_and(|rate| rate > 0.0) => "cd",
None if track.hires => "hires",
None => "",
}
.to_string();
let label = if tier.is_empty() || (bit_depth.is_none() && sample_rate.is_none()) {
String::new()
} else if bit_depth == Some(1) {
crate::quality_state::dsd_multiple_label(sample_rate)
} else {
crate::quality_state::detail(bit_depth, sample_rate)
};
(tier, label)
}
fn purchase_quality_badge(
format_id: u32,
) -> Option<(&'static str, &'static str, Option<u32>, Option<f64>)> {
match format_id {
56 => Some(("dsd", "DSD128", Some(1), Some(5_644_800.0))),
55 => Some(("dsd", "DSD64", Some(1), Some(2_822_400.0))),
27 => Some(("hires", "24-bit / 192 kHz", Some(24), Some(192.0))),
7 => Some(("hires", "24-bit / 96 kHz", Some(24), Some(96.0))),
6 => Some(("cd", "16-bit / 44.1 kHz", Some(16), Some(44.1))),
5 => Some(("mp3", "320 kbps · MP3", None, None)),
_ => None,
}
}
/// Publish the queue panel document (queue_qt.rs — the full QueueState
/// port: sections, history, pagination, search).
pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
crate::queue_qt::publish(runtime).await;
}
// ---------------------------------------------------------------------------
// 1 Hz state pump (playback.rs `start_poll_loop`, minimized)
// ---------------------------------------------------------------------------
static POLL_STARTED: AtomicBool = AtomicBool::new(false);
/// Bumped when the local Shuffle control changes the queue timeline. The poll
/// owns the gapless task/one-shot guard, so this is its cheap invalidation
/// channel for an in-flight successor that was derived from the old order.
static QUEUE_ORDER_GENERATION: AtomicU64 = AtomicU64::new(0);
/// Wall-clock now in ms — the peer-position extrapolation clock (the Slint
/// `now_ms`, playback.rs:5162).
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
/// Start the 1 Hz pump on shell entry (idempotent). Publishes position /
/// playing / cache into the NowPlayingModel, detects track changes (meta +
/// queue refresh), and advances the queue at end-of-track through the core.
pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
if POLL_STARTED.swap(true, Ordering::SeqCst) {
return;
}
crate::spawn(async move {
let mut last_track_id: u64 = 0;
// The id of a track that just ENDED, while the poll has advanced past
// it and the successor's stream has not started yet. The player keeps
// reporting the ended id through that window (CMAF session + init
// segment, or a DSD→PCM conversion: 1-2 s), and with `last_track_id`
// reset by the recovery, the edge block below read it as a NEW track:
// `sync_current_to_id` rewound the queue cursor to the previous row,
// now-playing/MPRIS/notifications flip-flopped old → new → old (three
// notifications per track change, the middle one a ghost), and a
// gapless stage that sampled `peek_upcoming` in that window was handed
// the track already playing (smoke 2026-09-02: Hangar 18 twice).
let mut stale_after_advance: u64 = 0;
// Exact owner generation that produced the last local snapshot. Keep
// this separate from `last_track_id`: an owner restored after guest
// playback may legitimately resume the same numeric track id, and
// still owes owner-only edges/integrations from its fresh snapshot.
let mut last_owner_token: Option<OwnerActionToken> = None;
let mut was_playing = false;
// Idle L1 trim: the in-memory audio cache (400 MB budget) keeps every
// track played until another evicts it, so an app left idle after a
// listening session sat ~200-400 MB above its baseline (measured
// 2026-09-02). After IDLE_L1_TRIM_SECS with nothing audible — local,
// remote or cast — the L1 entries move to the L2 disk cache, which
// still serves a replay without a download. Trimmed once per idle
// stretch; any audible tick re-arms it.
let mut last_audible = std::time::Instant::now();
let mut l1_trimmed = false;
let mut seen_position: u64 = 0;
// Divider for the ~5s position flush (see the save_position call).
let mut save_pos_tick: u64 = 0;
// Track id we have already fired a gapless prefetch for, so this poll
// does not re-request it on every tick while the download is in flight
// (playback.rs:5049-5051).
let mut gapless_requested_for: u64 = 0;
let mut seen_queue_order_generation = QUEUE_ORDER_GENERATION.load(Ordering::SeqCst);
// Own the frontend's gapless fetch so an engine-empty recovery can
// abort a segment body that is still monopolizing the link. The
// player's incremental successor feeder has its own cancellation slot;
// this handle covers the outer tier-walk task.
let mut gapless_fetch_task: Option<tokio::task::JoinHandle<()>> = None;
// Durable player edges consumed by this poller. Both begin at zero;
// the player generations are monotonic for the process lifetime.
let mut seen_engine_empty_generation: u64 = 0;
let mut seen_source_failure_generation: u64 = 0;
// Immediate full-cache warming has a distinct edge because it follows
// both the local engine and the cast cursor. Unlike the gapless guard,
// it names the NEW current track whose two successors are being warmed.
let mut prefetch_track_edge = PrefetchTrackEdge::default();
let mut seen_owner_playback_task_generation = owner_playback_task_generation();
// QConnect renderer-report throttle (contract §6.2): the official
// client reports RndrSrvrStateUpdated ~every 2s while playing PLUS
// immediately on a transition (track / play-state change). The Slint
// ticks at 450ms and reports every 4 ticks ≈ 1.8s
// (playback.rs:5053-5059); this loop ticks at 1 Hz, so the wall-clock
// equivalent is every 2 ticks — tick-count throttles do NOT translate
// 1:1, translate by wall time.
let mut last_reported_track_id: u64 = 0;
let mut last_reported_playing = false;
let mut last_reported_buffer_state = qbz_player::player::PlaybackBufferState::Idle;
let mut report_tick: u64 = 0;
const QCONNECT_REPORT_EVERY_N_TICKS: u64 = 2;
// QConnect CONTROLLER mode (contract §6.1): the peer's last-seen
// current track id. When the peer advances a track on its own, this
// edge-detects the change so the bar/queue meta refresh from the core
// cursor (which the sink already aligned to the peer's track). Reset
// to 0 when the peer branch is not taken so re-entering controller
// mode refreshes meta (playback.rs:5061-5066).
let mut last_peer_track_id: u64 = 0;
// Dirty-guard for the per-tick peer UI pushes (playback.rs:5194-5204):
// (track_id, position_ms, duration_secs, playing, volume f32 bits,
// muted, shuffle, repeat_mode). Re-pushing identical values every second
// dirties bindings and repaints even when fully idle. `track_id` is
// load-bearing: it guarantees the push after a peer track change (the
// meta refresh just reset the bar's position to 0). Reset to None
// whenever another owner (local/cast poll) may have written the bar.
let mut last_remote_ui_push: Option<(u64, u64, u64, bool, u32, bool, bool, i32)> = None;
// Physical ALSA knob events update the Player's shared volume atomics.
// Mirror only hardware-volume edges onto QML; ordinary local slider
// writes already publish immediately at their command boundary.
let mut last_hardware_volume: Option<(bool, u32)> = None;
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
ticker.tick().await;
let task_generation = owner_playback_task_generation();
if task_generation != seen_owner_playback_task_generation {
// A transition fence cancelled every admitted background
// download. Rearm both one-shot edges so a failed candidate can
// resume owner warming; a committed guest remains gated by its
// delegated observation and cannot start replacement work.
seen_owner_playback_task_generation = task_generation;
gapless_requested_for = 0;
prefetch_track_edge.observe(0);
}
let queue_order_generation = QUEUE_ORDER_GENERATION.load(Ordering::SeqCst);
if queue_order_generation != seen_queue_order_generation {
seen_queue_order_generation = queue_order_generation;
gapless_requested_for = 0;
if let Some(task) = gapless_fetch_task.take() {
if !task.is_finished() {
log::info!(
"[qbz-qt] [GAPLESS] aborting successor fetch after queue order changed"
);
task.abort();
}
}
}
if gapless_fetch_task
.as_ref()
.is_some_and(|task| task.is_finished())
{
if let Some(task) = gapless_fetch_task.take() {
if let Err(error) = task.await {
if !error.is_cancelled() {
log::warn!("[qbz-qt] gapless fetch task failed: {error}");
}
}
}
}
// --- 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.
{
// The error slot is itself a playback snapshot. Admit before
// draining it so a guest error cannot be applied to an owner
// row after a guest -> owner promotion.
let (error_owner_snapshot, stream_error) =
match capture_owner_scoped_snapshot(observe_owner_action, || {
runtime.core().player().state.take_stream_error_message()
}) {
OwnerScopedSnapshot::Captured {
owner_action,
snapshot,
} => (owner_action, snapshot),
// Do not drain an owner error or perturb playback edge
// bookkeeping merely because a candidate is still inside
// its fallible, pre-commit fence.
OwnerScopedSnapshot::Fenced => continue,
};
if let Some(msg) = stream_error {
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));
// Listen log: only the owner observation may close its row.
// Guest playback is closed explicitly as `handoff` at the
// authority boundary and is never folded into this store.
if let Some(owner_snapshot) = error_owner_snapshot.as_ref() {
crate::listen_log_qt::on_error(owner_snapshot).await;
}
}
}
// --- QConnect CONTROLLER mode: peer-state reflection ----------
// (contract §6.1 — Slint playback.rs:5117-5244, inserted BEFORE
// the cast branch exactly like the reference.) When QBZ is
// CONTROLLING a peer renderer, the event sink stops the LOCAL
// player, so `get_playback_event()` reports track_id == 0 / not
// playing and the seek bar would freeze. While a peer owns
// playback, drive the bar from the peer's renderer snapshot
// instead: title / artist / art come from the materialized local
// core queue (the sink aligns the core cursor to the peer's
// track), only position / playing / duration come from the peer.
// Returns None in every NON-controller situation (disconnected,
// renderer mode where active == local, no active renderer), so
// the local path below runs byte-unchanged.
//
// UNITS (contract §6): `RemoteNowPlaying.position_ms` /
// `updated_at_ms` are MILLISECONDS; `now_playing::set_position`
// takes SECONDS. Convert at the publish boundary.
if let Some(remote) = match crate::qconnect_qt::service() {
Some(svc) => svc.remote_now_playing().await,
None => None,
} {
// Queue metadata below belongs to this controller-side owner
// observation. A concurrent delegation invalidates it before
// any queue read or integration task can be admitted.
let Some(remote_owner_snapshot) = begin_owner_action() else {
last_peer_track_id = 0;
last_remote_ui_push = None;
continue;
};
// The peer changed track on its own → refresh the bar/queue
// meta from the core cursor (the event sink already aligned
// it to the peer's track via sync_active_renderer_projection).
// This resets position to 0, which is correct on a track
// change; the per-tick position push below immediately
// re-applies the peer's real position. Done BEFORE the
// per-tick push so peer values win.
if remote.track_id != last_peer_track_id {
refresh_now_playing(&runtime).await;
publish_queue(&runtime).await;
// Discord follows the peer (§11.2): the peer track edge
// pushes Discord but NEVER scrobbles — 1:1 with the
// Slint, where this edge reaches the UNGUARDED push
// inside refresh_now_playing_meta (playback.rs:5137-5141
// -> :2236-2239). The scrobble half of the LOCAL edge is
// peer-guarded inside `on_track_change_edge` instead.
crate::integrations_qt::discord_track_change_edge(
&runtime,
remote_owner_snapshot.token(),
remote.track_id,
);
last_peer_track_id = remote.track_id;
}
// Lyrics follow the peer (Q7, §11.1): publish the RAW
// renderer anchor on EVERY peer-branch tick — the lyrics
// sync invokables extrapolate between poll ticks exactly
// like the position extrapolation below
// (playback.rs:5142-5149). Outside the track edge AND the
// dirty guard on purpose: re-anchoring a peer pause promptly
// beats a skipped UI push.
crate::lyrics_qt::publish_remote_anchor(
remote.position_ms,
remote.updated_at_ms,
remote.playing,
);
// Duration from the core queue's current track (aligned to the
// peer's track by the sink). Zero when unknown — the clamp is
// skipped then; without it the seekbar is wrong.
let duration_secs = runtime
.core()
.current_track()
.await
.map(|track| track.duration_secs)
.unwrap_or(0);
let duration_ms = duration_secs.saturating_mul(1000);
// Extrapolate position while playing; clamp to the track length.
let mut position_ms = remote.position_ms;
if remote.playing && remote.updated_at_ms > 0 {
position_ms =
position_ms.saturating_add(now_ms().saturating_sub(remote.updated_at_ms));
}
if duration_ms > 0 && position_ms > duration_ms {
position_ms = duration_ms;
}
// ms → s at the publish boundary (see UNITS above).
let position_secs = position_ms / 1000;
let playing = remote.playing;
// Reflect the PEER's actual volume on the bar so a drag starts
// from a safe level (never QBZ's local 100). When the peer
// hasn't reported a volume, clamp to 50% — the AVR-nuke
// safety default (playback.rs:5174-5180, §12.13).
let remote_volume = remote
.volume
.map(|v| (v as f32 / 100.0).clamp(0.0, 1.0))
.unwrap_or(0.5);
// Reflect the PEER's shuffle/repeat state on the bar buttons.
// Pure UI reflection of the cloud's reported state — no local
// order is generated (WS-authoritative for shuffle order).
let shuffle_on = remote.shuffle_mode;
let repeat_mode = remote.repeat_mode;
// Skip the UI hop when nothing the push depends on changed
// since the last push (see the dirty-guard comment at the
// trackers). While playing, `position_ms` extrapolates every
// tick, so pushes proceed; paused, the bar stops repainting.
let remote_snapshot = (
remote.track_id,
position_ms,
duration_secs,
playing,
remote_volume.to_bits(),
remote.muted,
shuffle_on,
repeat_mode,
);
if last_remote_ui_push != Some(remote_snapshot) {
last_remote_ui_push = Some(remote_snapshot);
// Keep the visualizer producer in step with the same flag
// the drain gate reads (a paused PEER parks it too;
// playback.rs:5205-5209).
crate::viz_qt::set_paused(!playing);
// The peer branch `continue`s past the local body that
// re-sets the seek lock every tick — force it open here
// (playback.rs:5219) or a mid-stream takeover would latch
// the last buffer fraction and refuse routed seeks.
crate::now_playing::set_seekable_max(1.0);
// `has_audio = true` clears the fetch spinner: a peer owns
// audio, so there is no local fetch wait and the bar's
// spinner must never linger here (the Slint force-clears
// PENDING_PLAY_ID on the peer edge, playback.rs:5228-5233
// — Qt has no PENDING_PLAY_ID/clear_loading; this push is
// guaranteed on the edge because track_id is in the
// dirty-guard tuple). Cache overlay 0.0 = "not streaming",
// same value the cast poll publishes (cast_qt.rs:1000).
crate::now_playing::set_position(
position_secs as i32,
duration_secs as i32,
playing,
0.0,
true,
);
crate::now_playing::set_volume(remote_volume);
crate::now_playing::set_muted(remote.muted);
crate::now_playing::set_shuffle(shuffle_on);
crate::now_playing::set_repeat_mode(repeat_mode);
}
// Reset the LOCAL edge trackers so when control returns to QBZ
// the end-of-track / gapless / transition logic re-detects
// from a clean slate (the local player was stopped while
// peer-active).
last_track_id = 0;
was_playing = false;
seen_position = 0;
// A peer renderer owns its own playback/cache. Rearm only so a
// later hand-back to local/cast can warm from that real edge.
prefetch_track_edge.observe(0);
continue;
}
// CAST: the cast service runs its own 1 s poll and the local player is
// stopped, so this path would push position 0 / not-playing every
// second and fight it.
if crate::cast_qt::is_casting().await {
let Some(cast_owner_snapshot) = begin_owner_action() else {
last_track_id = 0;
was_playing = false;
seen_position = 0;
prefetch_track_edge.observe(0);
continue;
};
// The local engine is stopped while casting, so its PlaybackEvent can
// never surface a track edge. The cast service advances the shared
// core cursor; observe that cursor here and warm once per new current
// track at the renderer-effective tier.
let cast_track_id = runtime
.core()
.current_track()
.await
.map(|track| track.id)
.unwrap_or(0);
if prefetch_track_edge.observe(cast_track_id) {
kick_prefetch(&runtime, &cast_owner_snapshot).await;
}
last_track_id = 0;
was_playing = false;
seen_position = 0;
// Same invariant for the PEER edge trackers (playback.rs:5268-5269):
// the remote branch above did not run, so without this reset a
// re-taken peer whose snapshot happens to match the last controller
// push would be skipped, leaving the cast renderer's values on the
// bar (mirrors the fall-through reset below).
last_peer_track_id = 0;
last_remote_ui_push = None;
// 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;
}
// Not in controller mode (no peer / returned to local): reset the
// peer-track edge var so re-entering the peer state refreshes meta
// (playback.rs:5274-5277).
if last_remote_ui_push.is_some() {
if let Some(_owner_action) = begin_owner_action() {
// The peer's mute was UI-only; returning to local restores
// the owner's retained toggle without changing its volume.
crate::now_playing::set_muted(MUTED.load(Ordering::Relaxed));
}
}
last_peer_track_id = 0;
last_remote_ui_push = None;
// §11.1: deliberately NO lyrics anchor clear here (the Slint clears
// per-tick at playback.rs:5279) — the Qt anchor clears from the single
// `now_playing::set_remote(false)` choke point, which unlike this
// fallthrough also runs while the cast branch owns the loop. See the
// note in `now_playing::set_remote`.
// Freeze owner authority before reading any local playback or queue
// snapshot. `None` is a valid delegated observation: UI/QConnect
// reporting remain live, but owner-only consumers must use this
// exact result and may not obtain a fresh permit later in the tick.
let (owner_snapshot, event) =
match capture_owner_scoped_snapshot(observe_owner_action, || {
runtime.core().player().get_playback_event()
}) {
OwnerScopedSnapshot::Captured {
owner_action,
snapshot,
} => (owner_action, snapshot),
// Preserve last_track_id / last_owner_token and every owner
// integration across a candidate that may still fail. Only an
// actually installed guest is an authority edge.
OwnerScopedSnapshot::Fenced => continue,
};
let track_id = event.track_id;
let position = event.position;
let duration = event.duration;
let is_playing = event.is_playing;
if is_playing || crate::now_playing::remote_or_cast_active() {
last_audible = std::time::Instant::now();
l1_trimmed = false;
} else if !l1_trimmed && last_audible.elapsed().as_secs() >= IDLE_L1_TRIM_SECS {
l1_trimmed = true;
log::info!(
"[qbz-qt] idle for {IDLE_L1_TRIM_SECS}s — evicting the L1 audio cache to disk"
);
runtime.core().player().evict_l1_audio_cache();
}
let cache = event.buffer_progress.unwrap_or(0.0);
let hardware_volume = (event.hardware_volume_active, event.volume.to_bits());
if last_hardware_volume != Some(hardware_volume) {
if event.hardware_volume_active
|| last_hardware_volume.is_some_and(|(active, _)| active)
{
crate::now_playing::set_volume(event.volume);
}
last_hardware_volume = Some(hardware_volume);
}
// 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);
// This observation is cheap and happens each tick; the work does
// not. Only a NEW non-zero engine track — including a gapless
// handoff — enters `kick_prefetch`. A stop rearms replaying the
// same id later.
let immediate_prefetch_edge = if owner_snapshot.is_some() {
prefetch_track_edge.observe(track_id)
} else {
// Do not let a guest id consume the owner's one-shot edge. A
// restored owner whose track id happens to match must warm its
// own queue from a fresh owner snapshot.
prefetch_track_edge.observe(0);
false
};
// --- Track-change edge (new current track surfaced) ----------
let observed_owner_token = owner_snapshot.as_ref().map(OwnerActionLease::token);
// Compare the COMPLETE owner identity, not only None -> Some. A
// short owner -> guest -> owner cycle can fit between two poll
// ticks; its restored owner token is new even when neither the
// numeric track id nor the sampled Option-ness changed. The old
// delayed integrations were invalidated by that authority cycle,
// so this fresh snapshot must re-arm their edge work.
let owner_changed = owner_generation_changed(observed_owner_token, last_owner_token);
// The ended track, still reported while its successor is loading,
// is NOT an edge (see `stale_after_advance`). Only while nothing
// plays: the moment audio runs, the reported id is the truth again
// — including a deliberate replay of that same track.
if stale_after_advance != 0 {
if track_id == stale_after_advance && !is_playing {
seen_position = position;
was_playing = is_playing;
last_owner_token = observed_owner_token;
continue;
}
stale_after_advance = 0;
}
if track_id != 0 && (track_id != last_track_id || owner_changed) {
if owner_snapshot.is_some() {
// A cold OWNER start that reached audio is no longer
// pending. Guest playback must not consume this latch.
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
}
// Reconcile the queue pointer (a real advance moves it; a
// manual play already did). A delegated renderer installs its
// cursor through the stamped QConnect engine, so this owner
// poller must never realign it.
if owner_snapshot.is_some() {
let _ = runtime.core().sync_gapless_successor(track_id).await;
}
// UI reflection is authority-neutral: the local renderer must
// still paint and report guest playback.
refresh_now_playing(&runtime).await;
publish_queue(&runtime).await;
if let Some(owner_snapshot) = owner_snapshot.as_ref() {
// Integrations: scrobble now-playing + arm the delayed
// scrobble, and refresh the Discord presence. This is the
// DE-DUPED track edge on purpose — firing it from
// refresh_now_playing would re-arm the timer on every republish.
crate::integrations_qt::on_track_change_edge(
&runtime,
owner_snapshot.token(),
track_id,
);
// Local play history — Recently Played and Most Played read the
// same file the other frontends write; without this the Qt build
// shows their history and never adds to it. Same de-duped edge
// as the scrobblers, never from refresh_now_playing.
//
// EPHEMERAL (owner rule): an ephemeral folder "solo se reproduce
// y ya" and "no deja rastros en las bibliotecas" — it may be
// added to the queue and to nothing else, so a play of it must
// NOT land in Recently Played / Most Played. The player carries
// the synthetic id verbatim (`local_ephemeral::play_file` hands
// `row_id` to the engine), so the poll's own `track_id` is the
// cheapest place to decide, before the queue-state fetch.
// `record_queue_track` re-checks the same predicate, so the rule
// holds even if another call site appears.
if !crate::local_ephemeral::is_ephemeral_id(track_id as i64) {
if let Some(track) = runtime.core().get_queue_state().await.current_track {
crate::recently_qt::record_queue_track(&track);
crate::home_qt::note_recent_store_changed();
// Listen log: open the row for this track (and close
// the previous one as a skip if it is still open).
// Runs BESIDE the old stores, never instead of them.
crate::listen_log_qt::on_track_edge(owner_snapshot, &track, &event)
.await;
}
}
// Persist the session (queue + current track + position) so a
// restart can restore it. Hooked on the DE-DUPED track edge and
// not at each play call site: `play_queue_track` alone has five
// early returns (cast, peer, local, plex, Qobuz) and the album /
// single / shuffle entries add more, so a per-call-site hook is
// a list that silently goes stale. This edge sees them all.
// No-op unless `persist_session` is on.
qbz_app::session_persist::capture_and_save(&runtime).await;
}
last_track_id = track_id;
crate::ab_loop_qt::on_track_changed(track_id);
// The engine may have reached this track through a GAPLESS
// hand-off, in which case the arming guard still names the
// track that just ended. Clear it so the NEW current track can
// arm its own prefetch (playback.rs:5353).
if let Some(task) = gapless_fetch_task.take() {
if !task.is_finished() {
log::info!(
"[qbz-qt] [GAPLESS] aborting stale fetch after track changed to {track_id}"
);
task.abort();
}
}
gapless_requested_for = 0;
}
// Separate from the UI edge above on purpose: a 0-id stop rearms
// this tracker, so replaying the same queue id warms again even if
// another local edge tracker has not yet been cleared by its
// end-of-track arm.
if immediate_prefetch_edge {
if let Some(owner_snapshot) = owner_snapshot.as_ref() {
kick_prefetch(&runtime, owner_snapshot).await;
}
}
if track_id != 0 {
log::debug!("[qbz-qt] poll: id={track_id} pos={position}/{duration} playing={is_playing} cache={cache:.2}");
}
// Listen log accumulator: credits audible seconds only (playing,
// monotonic, <= 5 s per tick); a run of empty ticks is the stop.
if let Some(owner_snapshot) = owner_snapshot.as_ref() {
crate::listen_log_qt::on_tick(owner_snapshot, track_id, position, is_playing).await;
}
// --- 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::publish_seek_waveform(track_id);
crate::now_playing::set_position(
position as i32,
duration as i32,
is_playing,
cache,
track_id != 0,
);
// The desktop's copy of the same number. Read-on-demand, so it has
// to be kept fresh rather than signalled — see push_position.
crate::media_controls_qt::push_position(position);
// DELIVERED stream params -> the downgrade arrow + tooltip cause.
// Deduped inside, so a steady stream costs no Qt-thread hop.
// The mode the STREAM negotiated, not the one the settings asked
// for. `output_labels.rs` already derives the LED from settings;
// this is the only value that can contradict it, which is exactly
// what makes it worth publishing.
let bit_perfect = match event.bit_perfect_mode {
Some(qbz_audio::BitPerfectMode::DirectHardware) => "direct",
Some(qbz_audio::BitPerfectMode::PluginFallback) => "plugin",
Some(qbz_audio::BitPerfectMode::Disabled) => "disabled",
None => "unknown",
};
crate::now_playing::set_effective_stream(
event.sample_rate.unwrap_or(0),
event.bit_depth.unwrap_or(0),
bit_perfect,
);
// --- Gapless prefetch trigger (playback.rs:5362-5476) ---------
// The audio engine ASKS for the next track ~10s before the end of
// the current one ("Gapless: approaching end of track", player/
// mod.rs:3927) by raising `gapless_ready`. Nothing answered it in
// this port, so every advance went through the end-of-track edge
// below instead: fetch AFTER silence, ~1s of nothing plus the
// initial-buffer wait. The engine, the setting and the CMAF path
// were all working — this seam simply was not wired.
//
// Answering means handing the engine the next track's BYTES while
// the current one still plays, via `Player::play_next`, which
// appends to the same rodio sink and is what makes the transition
// sample-accurate.
// The stop-after guard is last and short-circuited. It is LIVE now
// (the queue row menu can place the marker): without it the engine
// would hand the next track's bytes over seamlessly and the marker
// would never fire, which is exactly what playback.rs:5372-5377
// warns about.
if owner_snapshot.is_some()
&& event.gapless_ready
&& event.gapless_next_track_id == 0
&& track_id != 0
&& gapless_requested_for != track_id
&& runtime.core().get_stop_after().await != Some(track_id)
{
if let Some(owner_snapshot) = owner_snapshot.as_ref() {
let owner_token = owner_snapshot.token();
let upcoming = runtime.core().peek_upcoming(1).await;
if let Some(next) = upcoming.into_iter().next() {
// Never queue the current track as its own successor —
// by the poll's id AND by the player's own: the two
// disagree exactly when the queue cursor is off by one
// (the stale-edge rewind above), and a successor equal
// to what is playing plays the track twice.
let playing_now = runtime.core().player().state.current_track_id();
if next.id == playing_now {
log::warn!(
"[qbz-qt] [GAPLESS] upcoming {} is the track already playing; cursor off by one, successor skipped",
next.id
);
} else if next.id != track_id
&& gapless_source(&next) == GaplessSource::Download
{
// Local Library downloads carry a catalog play ID
// and a local row hint, but their path is an encrypted
// bundle. Use the same offline resolver as normal
// playback, before the generic local-file branch.
gapless_requested_for = track_id;
let runtime = runtime.clone();
let next_id = next.id;
let task = spawn_owner_playback_task(async move {
let Some(_owner_action) =
begin_owner_action_exact(owner_token).await
else {
return;
};
let Some(offline) = crate::offline_qt::get().await else {
return;
};
let sink = crate::offline_cache_qt::row_sink();
let Some(bytes) = qbz_core::offline_resolve::resolve_offline_bytes(
next_id,
&offline,
Some(&sink),
)
.await
else {
return;
};
if !gapless_edge_is_current(&runtime, track_id, next_id).await {
return;
}
if let Err(error) =
runtime.core().player().play_next(bytes, next_id)
{
log::warn!(
"[qbz-qt] [GAPLESS] offline successor {next_id}: {error}"
);
}
});
if let Some(stale) = gapless_fetch_task.replace(task) {
stale.abort();
}
} else if next.id != track_id
&& gapless_source(&next) == GaplessSource::Catalog
{
gapless_requested_for = track_id;
let runtime = runtime.clone();
let next_id = next.id;
let next_album_id = next.album_id.clone();
let task = spawn_owner_playback_task(async move {
let Some(_owner_action) =
begin_owner_action_exact(owner_token).await
else {
return;
};
// A purchased copy of the successor on disk is
// queued from the file — DSD through the
// player's DSD append, PCM as bytes — before
// the streaming/tier-walk arms get a look, so
// an album bought as DSD stays DSD across the
// gapless edge instead of flipping to the
// stream on track two.
if let Some(resolution) =
crate::purchase_playback_qt::resolve_preferred_copies(
next_id,
next_album_id.as_deref(),
)
.await
{
for copy in &resolution.copies {
let ticket = match prepare_purchase_gapless_ticket(
&runtime, copy, next_id,
)
.await
{
Ok(ticket) => ticket,
Err(error) => {
log::warn!(
"[qbz-qt] [GAPLESS] purchase copy {} of {next_id} could not be prepared: {error}; trying the next exact copy",
copy.copy_id
);
continue;
}
};
if !gapless_edge_is_current(&runtime, track_id, next_id)
.await
{
log::info!(
"[qbz-qt] [GAPLESS] discarded stale purchase resolution for track {next_id}"
);
return;
}
match crate::audible_qt::queue_gapless_ticket(
&runtime,
track_id,
next_id,
ticket,
)
.await
{
Ok(true) => {
crate::purchase_playback_qt::remember_materialized_copy(
next_id,
&resolution,
copy,
);
log::info!(
"[qbz-qt] [GAPLESS] queued track {next_id} from purchase copy {} (format {})",
copy.copy_id,
copy.format_id
);
return;
}
Ok(false) => {
log::info!(
"[qbz-qt] [GAPLESS] purchase copy of {next_id} not queued (predecessor moved or a successor is already set)"
);
return;
}
Err(error) => log::warn!(
"[qbz-qt] [GAPLESS] purchase copy {} of {next_id} failed: {error}; trying the next exact copy",
copy.copy_id
),
}
}
log::warn!(
"[qbz-qt] [GAPLESS] all complete purchase copies of {next_id} (format {}) failed; falling back to Qobuz",
resolution.format_id
);
}
crate::purchase_playback_qt::clear_materialized_track(next_id);
let quality = local_playback_quality().0;
let streaming_only =
crate::settings_qt::audio_settings().streaming_only;
let player_cached =
runtime.core().player().is_track_cached(next_id);
let offline_cached = crate::offline_qt::is_cached_id(next_id);
if !offline_cached {
// L1/L2 and cold catalog successors all use the
// bounded streaming/file path. Offline downloads
// retain the existing offline-aware resolver below.
let throttle_cap = qbz_audio::network_throttle::state()
.current_prefetch_cap(
qbz_audio::network_throttle::playback_mbps_for_quality(prefetch_quality_tag(quality)), 1);
if !should_stream_gapless_successor(streaming_only, player_cached, offline_cached, throttle_cap) {
log::info!("[qbz-qt] [GAPLESS] successor {next_id} skipped: network throttle cap 0");
return;
}
match runtime.core().queue_gapless_streaming(next_id, quality).await {
Ok(()) => log::info!("[qbz-qt] [GAPLESS] queued streaming track {next_id}"),
Err(error) => log::warn!("[qbz-qt] [GAPLESS] streaming setup for {next_id} failed: {error}"),
}
return;
}
// Shared tier-walk: L1/L2 (player cache) -> OFFLINE
// -> network, then hand the bytes to play_next. The
// offline handle and the row sink are the same two
// `play_resolved_offline_aware` supplies; passing
// `None, None` here (under a comment claiming this
// port opens no offline index — the funnel above
// does) made a DOWNLOADED next track re-download for
// gapless, and fail outright with no network.
let off = crate::offline_qt::get().await;
let sink =
off.as_ref().map(|_| crate::offline_cache_qt::row_sink());
if let Some(data) = runtime
.core()
.fetch_for_gapless_resolved(
next_id,
// The gapless next track is LOCAL playback,
// so it resolves against the same capped
// tier as the audible play (Slint twin:
// playback.rs:5404). Prefetching at a
// higher tier than the funnel would request
// is exactly how a quality-blind cache
// leaks an uncapped stream to the DAC.
quality,
off.as_deref(),
sink.as_ref(),
)
.await
{
if !gapless_edge_is_current(&runtime, track_id, next_id).await {
log::info!(
"[qbz-qt] [GAPLESS] discarded stale track {next_id} after predecessor {track_id} moved or its queue edge changed"
);
return;
}
let player = runtime.core().player();
match player.play_next(data, next_id) {
Ok(()) => log::info!(
"[qbz-qt] [GAPLESS] queued track {next_id} for gapless"
),
Err(e) => log::warn!(
"[qbz-qt] [GAPLESS] play_next {next_id} failed: {e}"
),
}
}
});
if let Some(stale) = gapless_fetch_task.replace(task) {
stale.abort();
}
} else if next.id != track_id
&& gapless_source(&next) == GaplessSource::Local
{
// Source-owned gapless. `is_local` means "does not use
// Qobuz's tier walk", NOT "has a filesystem path": it
// also covers Plex/Jellyfin/Subsonic streams. Resolve
// the same PlaybackTicket normal playback consumes and
// let the shared exhaustive matcher turn files, bytes,
// DSD and remote streams into `play_next`.
gapless_requested_for = track_id;
let runtime = runtime.clone();
let task = spawn_owner_playback_task(async move {
let Some(_owner_action) =
begin_owner_action_exact(owner_token).await
else {
return;
};
let next_id = next.id;
match crate::audible_qt::queue_gapless_successor(
&runtime, track_id, &next,
)
.await
{
Ok(true) => log::info!(
"[qbz-qt] [GAPLESS] queued source track {next_id} for gapless"
),
Ok(false) => log::debug!(
"[qbz-qt] [GAPLESS] source pre-queue {next_id} not applicable"
),
Err(e) => log::warn!(
"[qbz-qt] [GAPLESS] source pre-queue {next_id} failed: {e}"
),
}
});
if let Some(stale) = gapless_fetch_task.replace(task) {
stale.abort();
}
}
}
}
}
// --- QConnect: outbound renderer state report -----------------
// (contract §6.2 — Slint playback.rs:5676-5710, placed after the
// position push / before the end-of-track edge exactly like the
// reference.) When QBZ is the ACTIVE LOCAL renderer (controlled by
// a remote controller like the iOS app), report our playback state
// so the controller's seek bar + current-track follow. Mirrors the
// official client: position/duration in MILLISECONDS (the Qt poll
// position is SECONDS — ×1000 at the call), ~2s periodic while
// playing + an immediate report on every transition. The service
// self-gates on is_local_renderer_active (no-op when we're a peer
// controller or not connected) and resolves the queue_item_ids —
// no extra gates.
report_tick = report_tick.wrapping_add(1);
let report_track_id = qconnect_app::qconnect_report_track_id(&event);
if report_track_id != 0 {
let transition = report_track_id != last_reported_track_id
|| is_playing != last_reported_playing
|| event.buffer_state != last_reported_buffer_state;
let buffer_active = matches!(
event.buffer_state,
qbz_player::player::PlaybackBufferState::InitialBuffering
| qbz_player::player::PlaybackBufferState::Underrun
);
let periodic = (is_playing || buffer_active)
&& report_tick % QCONNECT_REPORT_EVERY_N_TICKS == 0;
if transition || periodic {
if let Some(svc) = crate::qconnect_qt::service() {
let playing_state =
qconnect_app::renderer_playing_state(is_playing, event.buffer_state);
let position_ms = (position as i64) * 1000;
let duration_ms = (duration as i64) * 1000;
svc.report_playback_state(
playing_state,
position_ms,
duration_ms,
report_track_id,
event.buffer_state,
)
.await;
// On a track change, also reconcile the session queue:
// if the user started a new album/playlist on QBZ,
// push it so the controller follows. Self-gates +
// echo-suppresses.
if transition {
svc.sync_local_queue_if_changed().await;
}
}
last_reported_track_id = report_track_id;
last_reported_playing = is_playing;
last_reported_buffer_state = event.buffer_state;
}
}
// --- End-of-track / source-failure recovery -------------------
// Keep the sampled predicate for source types that do not publish
// a stronger signal. CMAF and engine-empty recovery use durable
// generations, so a playing pulse shorter than this 1 s poll is
// still observed exactly once.
let sampled_track_end = was_playing
&& !is_playing
&& last_track_id != 0
&& (track_id == 0 || track_id == last_track_id)
&& duration > 0
&& seen_position + 2 >= duration;
let explicit_recovery_edge = event.engine_empty_generation
!= seen_engine_empty_generation
|| event.source_failure_generation != seen_source_failure_generation;
let queue_track_id =
if owner_snapshot.is_some() && (explicit_recovery_edge || sampled_track_end) {
runtime
.core()
.current_track()
.await
.map(|t| t.id)
.unwrap_or(0)
} else {
0
};
let recovery_cause = playback_recovery_cause(
&event,
queue_track_id,
seen_engine_empty_generation,
seen_source_failure_generation,
sampled_track_end,
);
// Consume every observed generation, including a stale edge whose
// track id no longer matches after a manual play. It must not wake
// up again if that id appears later in the session.
seen_engine_empty_generation = event.engine_empty_generation;
seen_source_failure_generation = event.source_failure_generation;
if let Some(recovery_cause) = recovery_cause {
let Some(owner_snapshot) = owner_snapshot.as_ref() else {
// The stamped delegated engine owns guest advancement.
// Consume the sampled edge locally without logging,
// mutating the cursor, or retrying it on every poll tick.
seen_position = position;
was_playing = is_playing;
last_owner_token = observed_owner_token;
continue;
};
match recovery_cause {
PlaybackRecoveryCause::SourceFailure => {
log::warn!(
"[qbz-qt] recovery: source retries exhausted for track {queue_track_id}; continuing the queue"
);
crate::listen_log_qt::on_error(owner_snapshot).await;
}
PlaybackRecoveryCause::EngineEmpty => {
log::info!(
"[qbz-qt] recovery: engine empty for track {queue_track_id} with no gapless successor; continuing the queue"
);
crate::listen_log_qt::on_natural_end(owner_snapshot).await;
}
PlaybackRecoveryCause::SampledNaturalEnd => {
crate::listen_log_qt::on_natural_end(owner_snapshot).await;
}
}
if let Some(task) = gapless_fetch_task.take() {
if !task.is_finished() {
log::info!(
"[qbz-qt] [GAPLESS] aborting unfinished successor fetch during recovery"
);
task.abort();
}
}
// Seed for InfiniteRadio, read BEFORE anything moves the
// cursor: the track that just ended is still the current one.
let ended_track_id = queue_track_id;
// Stop-after-this-song: if the track that just ended carries
// the marker, HALT here — do not advance, do not refill. The
// queue stays intact and the finished track stays parked in
// now-playing, so pressing play resumes from where the user
// left off. `consume_stop_after_if` is one-shot (it clears the
// marker), and this runs AHEAD of repeat/shuffle exactly as
// the reference does (playback.rs:5749-5772) — behind them the
// marker would lose to a repeat-one and never fire.
if ended_track_id != 0 && runtime.core().consume_stop_after_if(ended_track_id).await
{
if let Err(e) = runtime.core().pause() {
log::warn!("[qbz-qt] stop-after: pause failed: {e}");
}
last_track_id = 0;
seen_position = 0;
gapless_requested_for = 0;
crate::now_playing::set_playing(false);
// Republish so the row drops its CircleStop in the same
// frame the marker is consumed.
crate::queue_qt::publish(&runtime).await;
// The tail-of-loop bookkeeping, inlined because this arm
// returns to the top early and must NOT run
// `seen_position = position` (the reset above is the
// point). Dropping the Discord push here would leave the
// presence saying "playing" after the timer stopped us. The
// tray tooltip's middle-click hint has the identical
// problem — it would keep saying "Middle-click to pause"
// after the stop-after timer fired — so its push is a
// sibling here, exactly as in the reference, where tray
// (playback.rs:5719-5722) and Discord (`:5734`) share one
// `if`. THIS IS ONE OF TWO `is_playing != was_playing`
// BLOCKS; a push in only one is the silent half-port
// (§5.6, AC-27).
if is_playing != was_playing {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
// OS media controls, inserted between the tray and
// Discord exactly as in the reference, whose single
// block is tray (playback.rs:5720-5722), MPRIS
// (`:5723-5730`), Discord (`:5734`). Carries the live
// position, so this transition is also the only thing
// that moves the widget's Position — nothing per tick.
// SIBLING OF THE BLOCK AT THE LOOP TAIL: a push in only
// one of the two is the silent half-port.
crate::media_controls_qt::push_playback_state(is_playing, position);
crate::integrations_qt::discord_push_observed(
&runtime,
owner_snapshot.token(),
track_id,
);
}
was_playing = is_playing;
last_owner_token = observed_owner_token;
continue;
}
last_track_id = 0;
// The skip-unavailable walk 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;
// Repeat-one advances to the same id; there the ended id IS
// the next one and nothing can be told apart, so the old
// behaviour stands.
stale_after_advance = if next_id != ended_track_id {
ended_track_id
} else {
0
};
log::info!("[qbz-qt] poll: advancing to {next_id}");
play_queue_track(&runtime, next_id, 0).await;
} else if try_infinite_refill(&runtime, ended_track_id).await {
// InfiniteRadio started a fresh smart radio; it replaced
// the queue and published on its way in.
} else {
log::info!("[qbz-qt] poll: queue finished");
crate::now_playing::set_playing(false);
}
}
seen_position = position;
// Persist the live position every ~5s while playing so a crash keeps
// a near-current resume point. The reference ticks at 450ms and uses
// `% 11`; this loop ticks at 1 Hz, so the WALL-CLOCK equivalent is
// `% 5` — tick-count throttles do not translate 1:1.
save_pos_tick = save_pos_tick.wrapping_add(1);
if is_playing && track_id != 0 && save_pos_tick % 5 == 0 {
if owner_snapshot.is_some() {
qbz_app::session_persist::save_position(position);
}
}
// Reflect play/pause into the tray tooltip on the TRANSITION only,
// so the "Middle-click to pause/play" hint stays correct without
// spamming the updater channel every tick (§7-M12). The second of
// the two `is_playing != was_playing` blocks — see the sibling in
// the stop-after arm above.
if is_playing != was_playing {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
// OS media controls — the SECOND of the two blocks, see the
// sibling in the stop-after arm above. Same order as the
// reference: tray, MPRIS, Discord.
crate::media_controls_qt::push_playback_state(is_playing, position);
if let Some(owner_snapshot) = owner_snapshot.as_ref() {
crate::integrations_qt::discord_push_observed(
&runtime,
owner_snapshot.token(),
track_id,
);
}
}
was_playing = is_playing;
last_owner_token = observed_owner_token;
}
});
}
// ---------------------------------------------------------------------------
// Tests (no Qt, engine, audio device or session)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
#[test]
fn explicit_pause_never_starts_a_paused_or_empty_player() {
for playing in [false, true] {
for pending in [false, true] {
let ignored = super::ignore_playing_request(Some(false), playing, pending);
assert_eq!(ignored, !playing && !pending);
// A non-ignored Pause must either pause audio or cancel a load.
assert!(ignored || playing || pending);
}
}
// Repeating Play while the first request is loading must not cancel it.
assert!(super::ignore_playing_request(Some(true), false, true));
assert!(super::ignore_playing_request(Some(true), true, false));
assert!(!super::ignore_playing_request(Some(true), false, false));
for playing in [false, true] {
for pending in [false, true] {
assert!(!super::ignore_playing_request(None, playing, pending));
}
}
}
use super::{
begin_resolved_play, cancel_owner_playback_tasks, capture_owner_scoped_snapshot,
filter_queue_with, gapless_edge_matches, gapless_successor_changed,
owner_generation_changed, playback_recovery_cause, prefetch_track_ids,
purchase_quality_badge, quality_badge_from, reconcile_device_cap,
resolved_play_is_current, should_stream_gapless_successor, source_change_targets_current,
spawn_owner_playback_task, stream_error_text, xorshift_shuffle_seeded,
OwnerActionObservation, OwnerActionToken, OwnerScopedSnapshot, PlaybackRecoveryCause,
PrefetchCandidate, PrefetchTrackEdge,
};
use qbz_models::{Quality, QualityLimit, QueueTrack};
use qbz_player::PlaybackEvent;
use qbz_source::QualityHint;
use std::sync::Arc;
fn remote(id: u64) -> PrefetchCandidate {
PrefetchCandidate {
id,
is_local: false,
}
}
fn local(id: u64) -> PrefetchCandidate {
PrefetchCandidate { id, is_local: true }
}
#[test]
fn a_new_resolved_start_supersedes_older_policy_work() {
let old = begin_resolved_play();
assert!(resolved_play_is_current(old));
let new = begin_resolved_play();
assert!(!resolved_play_is_current(old));
assert!(resolved_play_is_current(new));
}
fn playback_event(track_id: u64, is_playing: bool) -> PlaybackEvent {
PlaybackEvent {
is_playing,
position: 0,
duration: 0,
track_id,
volume: 1.0,
hardware_volume_active: false,
sample_rate: None,
bit_depth: None,
shuffle: None,
repeat: None,
normalization_gain: None,
gapless_ready: false,
gapless_next_track_id: 0,
bit_perfect_mode: None,
buffer_progress: None,
buffer_state: qbz_player::player::PlaybackBufferState::Idle,
buffer_track_id: 0,
engine_empty_generation: 0,
engine_empty_track_id: 0,
source_failure_generation: 0,
source_failure_track_id: 0,
}
}
#[test]
fn owner_observation_precedes_snapshot_and_fence_does_not_consume_it() {
use std::cell::RefCell;
let order = RefCell::new(Vec::new());
let captured = capture_owner_scoped_snapshot(
|| {
order.borrow_mut().push("observe");
OwnerActionObservation::Delegated
},
|| {
order.borrow_mut().push("read");
41_u64
},
);
assert!(matches!(
captured,
OwnerScopedSnapshot::Captured {
owner_action: None,
snapshot: 41
}
));
assert_eq!(*order.borrow(), ["observe", "read"]);
order.borrow_mut().clear();
let fenced = capture_owner_scoped_snapshot(
|| {
order.borrow_mut().push("observe");
OwnerActionObservation::Fenced
},
|| {
order.borrow_mut().push("read");
99_u64
},
);
assert!(matches!(fenced, OwnerScopedSnapshot::Fenced));
assert_eq!(*order.borrow(), ["observe"]);
let first = OwnerActionToken::BeforeService;
assert!(owner_generation_changed(Some(first), None));
assert!(!owner_generation_changed(Some(first), Some(first)));
assert!(!owner_generation_changed(None, Some(first)));
}
#[tokio::test(flavor = "current_thread")]
async fn owner_background_task_is_cancelable_before_it_can_start_io() {
use std::sync::atomic::{AtomicBool, Ordering};
cancel_owner_playback_tasks();
let ran = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&ran);
let task = spawn_owner_playback_task(async move {
observed.store(true, Ordering::Release);
});
cancel_owner_playback_tasks();
let _ = task.await;
assert!(!ran.load(Ordering::Acquire));
let completed = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&completed);
spawn_owner_playback_task(async move {
observed.store(true, Ordering::Release);
})
.await
.unwrap();
assert!(completed.load(Ordering::Acquire));
}
#[test]
fn engine_empty_edge_survives_a_missed_playing_pulse() {
let mut event = playback_event(41, false);
event.engine_empty_generation = 1;
event.engine_empty_track_id = 41;
assert_eq!(
playback_recovery_cause(&event, 41, 0, 0, false),
Some(PlaybackRecoveryCause::EngineEmpty)
);
}
#[test]
fn source_failure_recovers_a_track_that_never_started() {
let mut event = playback_event(0, false);
event.source_failure_generation = 1;
event.source_failure_track_id = 41;
assert_eq!(
playback_recovery_cause(&event, 41, 0, 0, false),
Some(PlaybackRecoveryCause::SourceFailure)
);
}
#[test]
fn stale_recovery_edge_cannot_skip_a_new_queue_track() {
let mut event = playback_event(42, false);
event.source_failure_generation = 3;
event.source_failure_track_id = 41;
event.engine_empty_generation = 2;
event.engine_empty_track_id = 41;
assert_eq!(playback_recovery_cause(&event, 42, 1, 2, false), None);
}
#[test]
fn late_gapless_rescue_consumes_engine_edge_without_advancing() {
let mut event = playback_event(41, true);
event.engine_empty_generation = 1;
event.engine_empty_track_id = 41;
event.gapless_next_track_id = 42;
assert_eq!(playback_recovery_cause(&event, 41, 0, 0, false), None);
}
fn queue_track() -> QueueTrack {
QueueTrack {
id: 1,
title: String::new(),
version: None,
artist: String::new(),
album: String::new(),
album_version: None,
duration_secs: 0,
artwork_url: None,
hires: false,
bit_depth: None,
sample_rate: None,
is_local: true,
album_id: None,
artist_id: None,
streamable: true,
source: Some("navidrome".to_string()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
isrc: None,
recording_mbid: None,
}
}
// --- Source-backed NPB quality --------------------------------------
#[test]
fn mp3_source_hint_is_not_mislabeled_as_cd() {
let badge = quality_badge_from(
&queue_track(),
Some(QualityHint {
bit_depth: None,
sample_rate_khz: Some(44.1),
format: "MP3",
}),
);
assert_eq!(badge, ("mp3".to_string(), "44.1 kHz · MP3".to_string()));
}
#[test]
fn lossless_source_rate_can_fill_thin_queue_metadata() {
let badge = quality_badge_from(
&queue_track(),
Some(QualityHint {
bit_depth: None,
sample_rate_khz: Some(48.0),
format: "FLAC",
}),
);
assert_eq!(badge, ("cd".to_string(), "16-bit / 48 kHz".to_string()));
}
#[test]
fn queue_metadata_wins_over_the_source_fallback() {
let mut row = queue_track();
row.bit_depth = Some(24);
row.sample_rate = Some(96.0);
let badge = quality_badge_from(
&row,
Some(QualityHint {
bit_depth: Some(16),
sample_rate_khz: Some(44.1),
format: "FLAC",
}),
);
assert_eq!(badge, ("hires".to_string(), "24-bit / 96 kHz".to_string()));
}
#[test]
fn purchase_format_is_the_npb_badge_source() {
assert_eq!(
purchase_quality_badge(56),
Some(("dsd", "DSD128", Some(1), Some(5_644_800.0)))
);
assert_eq!(
purchase_quality_badge(7),
Some(("hires", "24-bit / 96 kHz", Some(24), Some(96.0)))
);
assert_eq!(
purchase_quality_badge(5),
Some(("mp3", "320 kbps · MP3", None, None))
);
assert_eq!(purchase_quality_badge(999), None);
}
#[test]
fn source_change_only_targets_the_audible_track_in_that_album() {
let mut track = queue_track();
track.id = 41;
track.album_id = Some("album-a".to_string());
let mut event = playback_event(41, true);
event.position = 73;
assert!(source_change_targets_current("album-a", &track, &event));
assert!(!source_change_targets_current("album-b", &track, &event));
event.track_id = 42;
assert!(!source_change_targets_current("album-a", &track, &event));
event.track_id = 41;
event.is_playing = false;
assert!(!source_change_targets_current("album-a", &track, &event));
}
// --- #688: immediate two-successor L1/L2 warming ---------------------
#[test]
fn streaming_only_on_plans_zero_prefetches() {
assert!(prefetch_track_ids(true, false, 2, &[remote(2), remote(3)]).is_empty());
}
#[test]
fn streaming_only_off_plans_at_most_two_successors() {
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(2), remote(3), remote(4)]),
vec![2, 3]
);
assert_eq!(prefetch_track_ids(false, false, 2, &[remote(9)]), vec![9]);
}
#[test]
fn local_successors_are_never_prefetched() {
// The policy inspects the queue's immediate next-two snapshot. It does
// not skip across the local row and turn the third row into a hidden
// third-successor warmup.
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(2), local(3), remote(4)]),
vec![2]
);
}
#[test]
fn a_repeated_track_edge_does_not_trigger_twice() {
let mut edge = PrefetchTrackEdge::default();
assert!(edge.observe(41));
assert!(!edge.observe(41));
assert!(!edge.observe(41));
}
#[test]
fn a_gapless_new_current_edge_prefetches_that_tracks_successors() {
let mut edge = PrefetchTrackEdge::default();
assert!(edge.observe(41), "initial audible track must be an edge");
assert!(edge.observe(42), "gapless handoff must surface a new edge");
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(43), remote(44)]),
vec![43, 44]
);
assert!(!edge.observe(42), "republishing the handoff must dedupe");
}
#[test]
fn throttle_cap_zero_plans_zero_downloads() {
assert!(prefetch_track_ids(false, false, 0, &[remote(2), remote(3)]).is_empty());
}
#[test]
fn streaming_only_cold_successor_uses_incremental_gapless() {
assert!(should_stream_gapless_successor(true, false, false, 1));
}
#[test]
fn cached_successor_uses_file_handoff_and_offline_keeps_its_resolver() {
assert!(should_stream_gapless_successor(true, true, false, 1));
assert!(should_stream_gapless_successor(false, true, false, 0));
assert!(should_stream_gapless_successor(false, false, false, 1));
assert!(!should_stream_gapless_successor(true, false, true, 1));
}
#[test]
fn adaptive_cap_zero_blocks_incremental_gapless_download() {
assert!(!should_stream_gapless_successor(true, false, false, 0));
}
#[test]
fn completed_gapless_fetch_only_matches_its_live_queue_edge() {
assert!(gapless_edge_matches(1, 2, 1, 0, None, Some(2)));
assert!(!gapless_edge_matches(1, 2, 2, 0, None, Some(2)));
assert!(!gapless_edge_matches(1, 2, 1, 9, None, Some(2)));
assert!(!gapless_edge_matches(1, 2, 1, 0, Some(1), Some(2)));
assert!(!gapless_edge_matches(1, 2, 1, 0, None, Some(3)));
assert!(!gapless_edge_matches(1, 1, 1, 0, None, Some(1)));
}
#[test]
fn shuffle_restarts_only_when_an_armed_successor_became_stale() {
let mut event = playback_event(1, true);
assert!(!gapless_successor_changed(&event, Some(2)));
event.gapless_next_track_id = 2;
assert!(!gapless_successor_changed(&event, Some(2)));
assert!(gapless_successor_changed(&event, Some(3)));
assert!(gapless_successor_changed(&event, None));
event.track_id = 0;
assert!(!gapless_successor_changed(&event, Some(3)));
}
// --- #638 fix 3: the request tier reconciles preference against the
// detected local device cap. -------------------------------------------
#[test]
fn with_no_cap_the_preference_governs_and_names_itself() {
assert_eq!(
reconcile_device_cap(Quality::UltraHiRes, None),
(Quality::UltraHiRes, QualityLimit::None)
);
// Below Hi-Res+ with no cap, the cause is the preference — that is
// what the badge tooltip says when nothing else is limiting.
assert_eq!(
reconcile_device_cap(Quality::HiRes, None),
(Quality::HiRes, QualityLimit::Preference)
);
}
#[test]
fn a_cap_below_the_preference_clamps_and_blames_the_device() {
assert_eq!(
reconcile_device_cap(Quality::UltraHiRes, Some(Quality::Lossless)),
(Quality::Lossless, QualityLimit::LocalDeviceCap)
);
assert_eq!(
reconcile_device_cap(Quality::UltraHiRes, Some(Quality::HiRes)),
(Quality::HiRes, QualityLimit::LocalDeviceCap)
);
}
/// THE TIE ARM. A 96 kHz DAC with the preference already at Hi-Res
/// requests the same tier either way, so the clamp is invisible — but the
/// CAUSE is not, and the device is the more specific and more surprising
/// of the two. Drop this arm and the tooltip tells the user their
/// preference is the ceiling while the real ceiling is their hardware.
#[test]
fn a_cap_equal_to_the_preference_still_blames_the_device_below_hi_res_plus() {
assert_eq!(
reconcile_device_cap(Quality::HiRes, Some(Quality::HiRes)),
(Quality::HiRes, QualityLimit::LocalDeviceCap)
);
assert_eq!(
reconcile_device_cap(Quality::Lossless, Some(Quality::Lossless)),
(Quality::Lossless, QualityLimit::LocalDeviceCap)
);
}
/// The tie at the TOP is the exception: Hi-Res+ capped at Hi-Res+ is not a
/// limit at all, and claiming the device limits you there would be a lie.
#[test]
fn a_tie_at_hi_res_plus_is_not_a_limit() {
assert_eq!(
reconcile_device_cap(Quality::UltraHiRes, Some(Quality::UltraHiRes)),
(Quality::UltraHiRes, QualityLimit::None)
);
}
/// A cap ABOVE the preference must not raise the request. The user asked
/// for less; a capable DAC is not permission to spend their bandwidth.
#[test]
fn a_cap_above_the_preference_never_raises_it() {
assert_eq!(
reconcile_device_cap(Quality::Lossless, Some(Quality::UltraHiRes)),
(Quality::Lossless, QualityLimit::Preference)
);
}
/// THE #638 TRAP, pinned as a source audit because no runtime test can
/// reach it: `current_quality()` is the UNCAPPED preference, and exactly
/// two places may read it — the capped resolver in this module, and
/// `cast_qt::effective_cast_quality`, which must never see the local cap
/// (the local DAC is not in a cast's signal path; capping it there is
/// bug #638 in reverse — a 48 kHz DAC silently capping a 192 kHz DLNA
/// renderer).
///
/// A new play path that reaches for `current_quality()` instead of the
/// funnel fails here, by name, instead of shipping an uncapped request
/// that no static gate and no smoke of an unrelated view would catch.
#[test]
fn only_the_resolver_and_the_cast_path_read_the_uncapped_preference() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders: Vec<String> = Vec::new();
let mut walk = vec![src];
while let Some(dir) = walk.pop() {
for entry in std::fs::read_dir(&dir).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
walk.push(path);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().to_string();
let body = std::fs::read_to_string(&path).expect("read source");
// Production code only — a test module naming the function
// (this one does, twice) is not a call site.
let body = match body.find("\n#[cfg(test)]") {
Some(cut) => &body[..cut],
None => &body[..],
};
for (i, line) in body.lines().enumerate() {
// Skip the definition, doc comments and ordinary comments —
// this audits CALLS, not prose about them.
let t = line.trim_start();
if t.starts_with("//")
|| t.starts_with("fn ")
|| t.starts_with("pub(crate) fn ")
{
continue;
}
if line.contains("current_quality()") {
offenders.push(format!("{name}:{}", i + 1));
}
}
}
}
// playback_qt.rs: the one read inside `local_playback_quality`.
// cast_qt.rs: `effective_cast_quality`, which MUST stay uncapped.
let allowed = ["playback_qt.rs:", "cast_qt.rs:"];
let unexpected: Vec<&String> = offenders
.iter()
.filter(|o| !allowed.iter().any(|a| o.starts_with(a)))
.collect();
assert!(
unexpected.is_empty(),
"these read the UNCAPPED streaming preference instead of going through the play \
funnel / `local_playback_quality` — see #638 fix 3: {unexpected:?}"
);
// And the two allowed files read it exactly once each: a second read
// in playback_qt.rs means the funnel was bypassed again.
assert_eq!(
offenders.len(),
2,
"expected exactly two reads of `current_quality()` (the resolver + the cast path), \
found: {offenders:?}"
);
}
// --- 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
/// the one the user clicked — silently, and only when something was blocked.
#[test]
fn start_index_follows_the_clicked_track() {
let (kept, start, _) = filter_queue_with(
vec![0, 1, 2, 3, 4],
Some(2),
|n| n % 2 == 1,
|_| false,
|_| false,
);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(1)); // the 2 moved from index 2 to index 1
}
/// Clicking a row that is itself blocked lands on the next survivor.
#[test]
fn a_blocked_clicked_row_falls_forward() {
let (kept, start, _) = filter_queue_with(
vec![0, 1, 2, 3],
Some(1),
|n| n % 2 == 1,
|_| false,
|_| false,
);
assert_eq!(kept, vec![0, 2]);
assert_eq!(start, Some(1)); // the 1 was dropped, so start on the 2
}
/// The whole tail blocked: start on the LAST survivor, never past the end
/// (an out-of-range index is what makes the core start nothing at all).
#[test]
fn a_fully_blocked_tail_clamps_to_the_last_survivor() {
let (kept, start, _) = filter_queue_with(
vec![0, 1, 3, 5],
Some(2),
|n| n % 2 == 1,
|_| false,
|_| false,
);
assert_eq!(kept, vec![0]);
assert_eq!(start, Some(0));
}
/// Everything blocked: an empty queue carries NO start index.
#[test]
fn everything_blocked_yields_no_start() {
let (kept, start, _) =
filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false, |_| false);
assert!(kept.is_empty());
assert_eq!(start, None);
}
/// Nothing blocked: the queue and the index are untouched — the path every
/// user without a blacklist takes.
#[test]
fn nothing_blocked_is_identity() {
let (kept, start, _) =
filter_queue_with(vec![0, 2, 4], Some(2), |_| false, |_| false, |_| false);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(2));
}
/// Acceptance §8-5: a build containing dead tracks drops them AND re-maps
/// the start index to the track the user actually clicked. Same arithmetic
/// as the blacklist, different predicate — which is why the helper took a
/// dedicated closure instead of a rewrite.
#[test]
fn unavailable_rows_drop_and_remap_the_start_index() {
// Rows 1 and 3 are "pulled"; the user clicked row 4.
let (kept, start, dropped) = filter_queue_with(
vec![0, 1, 2, 3, 4],
Some(4),
|_| false,
|n| *n == 1 || *n == 3,
|_| false,
);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(2)); // the 4 moved from index 4 to index 2
assert_eq!(dropped.unavailable, 2);
assert_eq!(dropped.blacklisted, 0);
}
/// The two reasons are counted apart because only ONE of them speaks: the
/// blacklist drop is what the user asked for, the unavailability drop is
/// news. A single counter would force one policy on both.
#[test]
fn the_two_drop_reasons_are_counted_separately() {
let (kept, _, dropped) = filter_queue_with(
vec![0, 1, 2, 3],
Some(0),
|n| *n == 1,
|n| *n == 2,
|_| false,
);
assert_eq!(kept, vec![0, 3]);
assert_eq!(dropped.blacklisted, 1);
assert_eq!(dropped.unavailable, 1);
}
/// A row that is BOTH blocked and pulled counts as blacklisted, so no toast
/// is raised for it: "1 track skipped" would tell the user about exactly the
/// content the blacklist exists to keep off their screen.
#[test]
fn blacklist_wins_the_tie_and_stays_silent() {
let (kept, _, dropped) =
filter_queue_with(vec![0, 1], Some(0), |n| *n == 1, |n| *n == 1, |_| false);
assert_eq!(kept, vec![0]);
assert_eq!(dropped.blacklisted, 1);
assert_eq!(dropped.unavailable, 0);
}
#[test]
fn qconnect_unresolvable_rows_drop_and_remap_the_start_index() {
let (kept, start, dropped) = filter_queue_with(
vec![0, 1, 2, 3, 4],
Some(3),
|_| false,
|_| false,
|n| *n == 1 || *n == 3,
);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(2));
assert_eq!(dropped.qconnect_unresolvable, 2);
assert_eq!(dropped.blacklisted, 0);
assert_eq!(dropped.unavailable, 0);
}
#[test]
fn song_card_near_fit_keeps_the_final_glyphs_instead_of_an_ellipsis() {
// TextMetrics.width is a painted bounding box, while the line layout
// consumes advanceWidth. Budgeting the former exactly turned
// "Metallica" into "Metalli…" in a runway with visible spare space.
let qml = include_str!("../qml/shell/SongCard.qml");
assert!(qml.contains("artistMetrics.advanceWidth"));
assert!(qml.contains("albumMetrics.advanceWidth"));
assert!(qml.contains("a - qa <= near"));
assert!(qml.contains("b - qb <= near"));
}
}
#[cfg(test)]
mod offline_gapless_regressions {
use super::*;
#[tokio::test]
async fn local_library_download_routes_to_offline_bytes_by_catalog_id() {
use qbz_offline_cache::{OfflineCacheState, OfflineCacheStatus, TrackCacheInfo};
let temp = tempfile::tempdir().unwrap();
let offline = OfflineCacheState::new_empty();
offline.init_at(temp.path()).await.unwrap();
let file = temp.path().join("download.flac");
let bytes = b"fLaC-regression-byte-source";
std::fs::write(&file, bytes).unwrap();
let row = crate::local_playback::local_queue_track(&qbz_library::LocalTrack {
id: 4938,
qobuz_track_id: Some(266725026),
source: Some("qobuz_download".into()),
file_path: file.display().to_string(),
..Default::default()
});
assert!(row.is_local);
assert_eq!(row.source_item_id_hint.as_deref(), Some("4938"));
assert_eq!(gapless_source(&row), GaplessSource::Download);
{
let guard = offline.db.lock().await;
let db = guard.as_ref().unwrap();
db.insert_track(
&TrackCacheInfo {
track_id: row.id,
title: "Next".into(),
artist: "Fixture".into(),
album: None,
album_id: None,
duration_secs: 30,
quality: "hires".into(),
bit_depth: Some(24),
sample_rate: Some(192000.0),
},
file.to_str().unwrap(),
)
.unwrap();
db.update_status(row.id, OfflineCacheStatus::Ready, None)
.unwrap();
}
let resolved = qbz_core::offline_resolve::resolve_offline_bytes(row.id, &offline, None)
.await
.unwrap();
assert_eq!(resolved, bytes);
assert!(
qbz_core::offline_resolve::resolve_offline_bytes(4938, &offline, None)
.await
.is_none()
);
std::fs::remove_file(file).unwrap();
assert!(
qbz_core::offline_resolve::resolve_offline_bytes(row.id, &offline, None)
.await
.is_none()
);
}
fn queue_row(source: &str, is_local: bool) -> QueueTrack {
let mut row = crate::local_playback::local_queue_track(&qbz_library::LocalTrack {
id: 42, ..Default::default()
});
row.source = Some(source.into());
row.is_local = is_local;
row
}
#[test]
fn files_purchases_and_servers_keep_their_source_route() {
for source in [
"user",
"local",
"ephemeral",
"qobuz_purchase",
"plex",
"jellyfin",
"subsonic",
"navidrome",
] {
let row = queue_row(source, true);
assert_eq!(gapless_source(&row), GaplessSource::Local, "{source}");
}
for source in ["qobuz_download", "offline"] {
let row = queue_row(source, true);
assert_eq!(gapless_source(&row), GaplessSource::Download, "{source}");
}
assert_eq!(
gapless_source(&queue_row("qobuz", false)),
GaplessSource::Catalog
);
}
}