diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 737a3cbeb..dfc1244ad 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -154,10 +154,62 @@ const PLAYBACK_QUALITY: Quality = Quality::UltraHiRes; /// Convenience alias for the runtime handle threaded through every call. type Runtime = Arc>; +/// The track id whose audible fetch/resolve is currently in flight (the +/// "loading" track). Set the instant a play is initiated (top of +/// `play_audible`, before the multi-second Plex/Qobuz/local resolve) and +/// read by the poll loop to clear the spinner once THAT track's audio is +/// actually advancing. A NEW play overwrites it, so a superseded fetch never +/// keeps the spinner up for the wrong track. `0` = nothing loading. +static PENDING_PLAY_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Epoch-millis when the in-flight play was initiated — the poll-loop watchdog +/// force-clears the spinner if audio never starts within `LOADING_WATCHDOG_MS` +/// (a play the engine accepted but that silently never advances — e.g. an +/// undecodable-but-valid-looking file — would otherwise spin forever). +static PENDING_PLAY_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Generous ceiling: a real fetch (even a large hi-res Plex whole-file +/// download on a slow LAN) starts audio well under this; only a silently-stuck +/// play crosses it. +const LOADING_WATCHDOG_MS: u64 = 45_000; + +/// Mark `track_id` as the in-flight play and raise the now-playing "loading" +/// flag (drives the fetch spinner on the bar, the active track row, and the +/// album play button). Source-agnostic — covers Plex (~10s resolve), the +/// Qobuz tier-walk, and slow local reads. +fn set_loading(weak: &slint::Weak, track_id: u64) { + PENDING_PLAY_ID.store(track_id, std::sync::atomic::Ordering::Relaxed); + PENDING_PLAY_AT_MS.store(now_ms(), std::sync::atomic::Ordering::Relaxed); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(true); + }); +} + +/// Clear the loading flag if (and only if) the in-flight play is still +/// `track_id` — so a fetch that has been superseded by a newer play does not +/// wipe the newer play's spinner. Pass `0` to force-clear unconditionally +/// (queue finished / hard stop). +fn clear_loading(weak: &slint::Weak, track_id: u64) { + if track_id != 0 + && PENDING_PLAY_ID.load(std::sync::atomic::Ordering::Relaxed) != track_id + { + return; + } + PENDING_PLAY_ID.store(0, std::sync::atomic::Ordering::Relaxed); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(false); + }); +} + /// Run the audible step for `track_id`: grab the Qobuz client and call /// the player's self-contained `play_track`. Errors are logged, not /// surfaced — the poll loop keeps the UI consistent regardless. async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id: u64) { + // Raise the fetch spinner the instant playback is requested — BEFORE the + // resolve/download/buffer below (the Plex resolve alone is ~10s). The bar + // already adopted the new track meta in `refresh_now_playing_meta`; this + // bridges the silent gap until the poll loop sees the audio advancing. + set_loading(weak, track_id); // QConnect CONTROLLER mode: when a PEER renderer owns playback, route the // new play to the peer instead of playing locally. `play_on_peer_if_active` // returns false in every non-controller situation (disconnected, renderer @@ -165,6 +217,10 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id // runs byte-unchanged and renderer / local playback do not regress. if let Some(svc) = crate::qconnect_service::service() { if svc.play_on_peer_if_active(track_id).await { + // A peer owns audio: there is no local fetch wait, so drop the + // spinner immediately (the peer-state branch in the poll loop owns + // the bar from here). + clear_loading(weak, track_id); return; } } @@ -178,7 +234,7 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id if qt.id == track_id { match qt.source.as_deref() { Some("local") | Some("ephemeral") => { - play_local_file_audible(runtime, track_id).await; + play_local_file_audible(runtime, weak, track_id).await; return; } Some("plex") => { @@ -189,7 +245,7 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id .source_item_id_hint .clone() .unwrap_or_else(|| track_id.to_string()); - play_plex_audible(runtime, track_id, rating_key).await; + play_plex_audible(runtime, weak, track_id, rating_key).await; return; } _ => {} @@ -207,6 +263,9 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id .await { log::error!("[qbz-slint] playback: play_track {track_id} failed: {e}"); + // The fetch failed: no audio will advance, so the poll loop would never + // clear the spinner. Drop it now (only if this play is still current). + clear_loading(weak, track_id); } } @@ -215,7 +274,11 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id /// the PROTECTED device init, untouched here). CUE virtual tracks share one /// file, so seek to the track start. `row_id` is the library row id. Called /// by `play_audible` when the current queue track's source is `"local"`. -async fn play_local_file_audible(runtime: &Runtime, row_id: u64) { +async fn play_local_file_audible( + runtime: &Runtime, + weak: &slint::Weak, + row_id: u64, +) { // Ephemeral tracks (synthetic id >= 2^48) resolve from the in-memory // session, never the DB. Everything downstream (read bytes, play_data, CUE // seek) is identical to a real local file. @@ -257,6 +320,7 @@ async fn play_local_file_audible(runtime: &Runtime, row_id: u64) { }; let Some((path, cue)) = info else { log::error!("[qbz-slint] local play: track {row_id} not found"); + clear_loading(weak, row_id); return; }; let read_path = path.clone(); @@ -266,10 +330,12 @@ async fn play_local_file_audible(runtime: &Runtime, row_id: u64) { .and_then(Result::ok); let Some(bytes) = bytes else { log::error!("[qbz-slint] local play: failed to read {path}"); + clear_loading(weak, row_id); return; }; if let Err(e) = runtime.core().player().play_data(bytes, row_id) { log::error!("[qbz-slint] local play: play_data {row_id} failed: {e}"); + clear_loading(weak, row_id); return; } if let Some(start) = cue { @@ -288,10 +354,16 @@ async fn play_local_file_audible(runtime: &Runtime, row_id: u64) { /// `sampling_rate_hz`/`bit_depth` are display-only and never touched here. /// `play_id` is the queue id (numeric rating key); `rating_key` is the string /// key the resolve needs. -async fn play_plex_audible(runtime: &Runtime, play_id: u64, rating_key: String) { +async fn play_plex_audible( + runtime: &Runtime, + weak: &slint::Weak, + play_id: u64, + rating_key: String, +) { let cfg = crate::plex_settings::get(); if cfg.base_url.is_empty() || cfg.token.is_empty() { log::error!("[qbz-slint] plex play: no Plex credentials configured"); + clear_loading(weak, play_id); return; } let resolved = match qbz_plex::plex_resolve_track_media( @@ -304,11 +376,13 @@ async fn play_plex_audible(runtime: &Runtime, play_id: u64, rating_key: String) Ok(r) => r, Err(e) => { log::error!("[qbz-slint] plex play: resolve {rating_key} failed: {e}"); + clear_loading(weak, play_id); return; } }; if let Err(e) = runtime.core().player().play_data(resolved.bytes, play_id) { log::error!("[qbz-slint] plex play: play_data {play_id} failed: {e}"); + clear_loading(weak, play_id); } } @@ -383,6 +457,7 @@ pub async fn wipe_ephemeral_if_playing(runtime: &Runtime, weak: &slint::Weak().set_has_track(false); }); @@ -2555,6 +2630,12 @@ pub fn start_poll_loop( np.set_shuffle(shuffle_on); np.set_repeat_mode(repeat_mode); }); + // A peer owns audio — there is no local fetch wait, so the bar's + // fetch spinner must never linger here. Force-clear it (only on + // the edge — avoid re-posting set_loading(false) every tick). + if PENDING_PLAY_ID.load(std::sync::atomic::Ordering::Relaxed) != 0 { + clear_loading(&weak, 0); + } // 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). @@ -2714,6 +2795,33 @@ pub fn start_poll_loop( np.set_volume(volume.clamp(0.0, 1.0)); }); + // Clear the fetch spinner once the audio for the in-flight play is + // actually advancing: a non-zero track with the clock moving + // (`position > 0`) is unambiguous proof the requested track started + // (is_playing alone can flip true transiently before the sink emits + // the id). Keyed to PENDING_PLAY_ID so a superseded fetch doesn't + // wipe a newer play's spinner; the keyed clear is a no-op if the + // current audio is a different (already-cleared) id. + if track_id != 0 && is_playing && position > 0 { + clear_loading(&weak, track_id); + } else { + // Watchdog: a play the engine accepted but that never advances + // (undecodable-but-valid-looking file, zero-frame stream) would + // otherwise spin forever. Force-clear after the generous ceiling. + let pending = PENDING_PLAY_ID.load(std::sync::atomic::Ordering::Relaxed); + if pending != 0 + && now_ms().saturating_sub( + PENDING_PLAY_AT_MS.load(std::sync::atomic::Ordering::Relaxed), + ) > LOADING_WATCHDOG_MS + { + log::warn!( + "[qbz-slint] loading watchdog: track {pending} never started after {}ms, clearing spinner", + LOADING_WATCHDOG_MS + ); + clear_loading(&weak, 0); + } + } + // --- QConnect: outbound renderer state report ----------------- // When QBZ is the ACTIVE LOCAL renderer (controlled by a remote // controller like the iOS app), report our playback state so the @@ -2784,6 +2892,8 @@ pub fn start_poll_loop( refresh_sidebar(true); } else { log::info!("[qbz-slint] playback: queue finished"); + // Nothing more will play — force-clear any lingering spinner. + clear_loading(&weak, 0); let _ = weak.upgrade_in_event_loop(|w| { let np = w.global::(); np.set_playing(false); diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint index cf118c40f..e00e636da 100644 --- a/crates/qbz-slint/ui/album/AlbumPageView.slint +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -9,7 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { Spacing } from "../foundation/spacing.slint"; -import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState } from "../state.slint"; +import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState } from "../state.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; @@ -24,6 +24,8 @@ import { NavButtons } from "../shell/NavButtons.slint"; component CircleAction inherits Rectangle { in property icon; in property primary: false; + // While the album's first track is fetching, the icon swaps for a spinner. + in property loading: false; in property diameter: 40px; callback clicked(); @@ -36,7 +38,7 @@ component CircleAction inherits Rectangle { border-width: 1px; border-color: ta.has-hover ? Theme.text-disabled : Theme.border-subtle; - QbzIcon { + if !root.loading: QbzIcon { source: root.icon; width: root.primary ? 19px : 17px; height: root.primary ? 19px : 17px; @@ -45,6 +47,13 @@ component CircleAction inherits Rectangle { tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary; } + if root.loading: LoadingSpinner { + size: root.diameter * 0.45; + tint: Theme.accent; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + ta := TouchArea { mouse-cursor: pointer; clicked => { @@ -330,6 +339,9 @@ export component AlbumPageView inherits Rectangle { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; + // Spin only while THIS album's first track is fetching. + loading: NowPlayingState.loading + && NowPlayingState.album-id == AlbumState.id; clicked => { root.media-action("album", AlbumState.id, "play"); } diff --git a/crates/qbz-slint/ui/album/LocalAlbumView.slint b/crates/qbz-slint/ui/album/LocalAlbumView.slint index b8dd7b576..1fadd9bfe 100644 --- a/crates/qbz-slint/ui/album/LocalAlbumView.slint +++ b/crates/qbz-slint/ui/album/LocalAlbumView.slint @@ -7,7 +7,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; -import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion } from "../state.slint"; +import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState } from "../state.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; @@ -229,6 +229,10 @@ export component LocalAlbumView inherits Rectangle { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; + // Spin only while THIS album's first track is fetching + // (local album-id == the now-playing track's album_id). + loading: NowPlayingState.loading + && NowPlayingState.album-id == LocalAlbumState.id; clicked => { LocalAlbumActions.play-all(); } diff --git a/crates/qbz-slint/ui/primitives/BarControls.slint b/crates/qbz-slint/ui/primitives/BarControls.slint index efc597fe6..8f0a85c76 100644 --- a/crates/qbz-slint/ui/primitives/BarControls.slint +++ b/crates/qbz-slint/ui/primitives/BarControls.slint @@ -5,6 +5,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "QbzIcon.slint"; +import { LoadingSpinner } from "LoadingSpinner.slint"; import { TooltipState } from "../state.slint"; // Square, compact icon button with a hover tooltip. @@ -60,6 +61,9 @@ export component PlayButton inherits Rectangle { in property icon; in property tooltip; in property circle: true; + // While a play is resolving/downloading (no audio yet) the glyph is + // swapped for a spinner so the click reads as "heard, audio is coming". + in property loading: false; callback clicked(); width: root.circle ? 38px : 34px; @@ -69,7 +73,7 @@ export component PlayButton inherits Rectangle { ? (ta.has-hover ? #ffffffd6 : #ffffff) : (ta.has-hover ? Theme.surface-hover : transparent); - QbzIcon { + if !root.loading: QbzIcon { source: root.icon; // 20px glyph in both modes (matches Tauri's size=20 play/pause). width: 20px; @@ -81,6 +85,14 @@ export component PlayButton inherits Rectangle { : (ta.has-hover ? Theme.accent : Theme.text-primary); } + if root.loading: LoadingSpinner { + size: 20px; + // Dark on the solid white disc, accent on the plain-glyph variant. + tint: root.circle ? #000000 : Theme.accent; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + ta := TouchArea { mouse-cursor: pointer; clicked => { diff --git a/crates/qbz-slint/ui/primitives/CircleAction.slint b/crates/qbz-slint/ui/primitives/CircleAction.slint index 493555256..4484dbf81 100644 --- a/crates/qbz-slint/ui/primitives/CircleAction.slint +++ b/crates/qbz-slint/ui/primitives/CircleAction.slint @@ -6,6 +6,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { QbzIcon } from "QbzIcon.slint"; +import { LoadingSpinner } from "LoadingSpinner.slint"; import { TooltipState } from "../state.slint"; export component CircleAction inherits Rectangle { @@ -13,6 +14,8 @@ export component CircleAction inherits Rectangle { in property primary: false; // Stays "pressed" — same surface as hover, icon tinted accent. in property active: false; + // While the album's first track is fetching, the icon swaps for a spinner. + in property loading: false; in property diameter: 40px; // Optional hover tooltip (empty = none), using the global TooltipOverlay. in property tooltip; @@ -29,7 +32,7 @@ export component CircleAction inherits Rectangle { border-width: root.primary ? 0px : 1.5px; border-color: #ffffffcc; - QbzIcon { + if !root.loading: QbzIcon { source: root.icon; width: root.primary ? 19px : 17px; height: root.primary ? 19px : 17px; @@ -40,6 +43,14 @@ export component CircleAction inherits Rectangle { : (root.primary ? #000000 : #ffffff); } + if root.loading: LoadingSpinner { + size: root.diameter * 0.45; + // Dark on the solid white primary disc, white on the ghost variant. + tint: root.primary ? #000000 : #ffffff; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + ta := TouchArea { mouse-cursor: pointer; clicked => { diff --git a/crates/qbz-slint/ui/primitives/TrackPlayCell.slint b/crates/qbz-slint/ui/primitives/TrackPlayCell.slint index 9b50b3e51..6c5840ad1 100644 --- a/crates/qbz-slint/ui/primitives/TrackPlayCell.slint +++ b/crates/qbz-slint/ui/primitives/TrackPlayCell.slint @@ -78,9 +78,14 @@ export component TrackPlayCell inherits Rectangle { property is-active: NowPlayingState.has-track && NowPlayingState.track-id == root.track-id; property is-playing: root.is-active && NowPlayingState.playing; + // This row is the track being fetched/resolved (no audio yet). The global + // loading flag + active-row match means only the one loading row spins, + // and it clears automatically when the flag clears. + property is-loading: root.is-active && NowPlayingState.loading; property show-overlay: root.row-hovered || root.is-active; - // The animated bars show only while playing and the row is at rest. - property show-bars: root.is-playing && !root.row-hovered; + // The animated bars show only while playing, the row is at rest, and the + // track isn't still fetching (the spinner wins during the fetch window). + property show-bars: root.is-playing && !root.row-hovered && !root.is-loading; width: root.show-artwork ? root.art-size : root.number-width; height: root.show-artwork ? root.art-size : 40px; @@ -118,7 +123,19 @@ export component TrackPlayCell inherits Rectangle { x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } - if root.show-overlay && !root.show-bars: QbzIcon { + // Fetch spinner — wins over the play glyph while the active row is + // still resolving/downloading (same rotating loader as the offline + // download states). + if root.is-loading: Image { + width: 20px; + height: 20px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + source: @image-url("../assets/icons/loader-circle.svg"); + colorize: #ffffff; + transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg; + } + if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon { source: (root.is-playing && root.row-hovered) ? @image-url("../assets/icons/pause.svg") : @image-url("../assets/icons/play-fill.svg"); @@ -146,7 +163,18 @@ export component TrackPlayCell inherits Rectangle { x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } - if root.show-overlay && !root.show-bars: QbzIcon { + // Fetch spinner — wins over the play glyph / number while the active + // row is still resolving/downloading. + if root.is-loading: Image { + width: 16px; + height: 16px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + source: @image-url("../assets/icons/loader-circle.svg"); + colorize: Theme.accent; + transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg; + } + if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon { source: (root.is-playing && root.row-hovered) ? @image-url("../assets/icons/pause.svg") : @image-url("../assets/icons/play-fill.svg"); diff --git a/crates/qbz-slint/ui/shell/SongCard.slint b/crates/qbz-slint/ui/shell/SongCard.slint index 41efc8e34..0d7ccdffd 100644 --- a/crates/qbz-slint/ui/shell/SongCard.slint +++ b/crates/qbz-slint/ui/shell/SongCard.slint @@ -11,6 +11,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { NowPlayingState, SettingsState, TooltipState } from "../state.slint"; // Clickable artist/album link. Elides and shares the row width through a @@ -213,6 +214,21 @@ export component SongCard inherits Rectangle { font-size: 8px + self.ph * 4px; font-italic: true; } + + // Fetch overlay: while the requested track is resolving/downloading + // (no audio yet), dim the cover and show the spinner so the click is + // acknowledged. Cleared by the poll loop once audio advances. + if NowPlayingState.has-track && NowPlayingState.loading: Rectangle { + width: 100%; + height: 100%; + background: #000000aa; + LoadingSpinner { + size: root.art-size * 0.5; + tint: #ffffff; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } } } diff --git a/crates/qbz-slint/ui/shell/TransportControls.slint b/crates/qbz-slint/ui/shell/TransportControls.slint index 7f19ebf21..3b2071d31 100644 --- a/crates/qbz-slint/ui/shell/TransportControls.slint +++ b/crates/qbz-slint/ui/shell/TransportControls.slint @@ -70,6 +70,7 @@ export component TransportControls inherits HorizontalLayout { alignment: center; PlayButton { circle: root.play-circle; + loading: NowPlayingState.loading; icon: NowPlayingState.playing ? @image-url("../assets/icons/pause.svg") : @image-url("../assets/icons/play-fill.svg"); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index a4667b527..3a4a05ab9 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -2106,6 +2106,12 @@ export global NowPlayingState { // it has no DB row, so favorite / add-to-playlist / track-info are gated off. in property is-ephemeral: false; in property playing: false; + // Fetch/resolve in flight for the track being started — no audio yet. + // Set the instant a play is requested (before the Plex/Qobuz/local + // resolve) and cleared once the poll loop sees the audio advancing. + // Drives the fetch spinner on the bar, the active track row, and the + // album play button. + in property loading: false; in property position-secs: 0; in property duration-secs: 0; in property progress: 0.0; // 0..1