feat(slint): play-fetching spinner (now-playing bar + track row + album button)

A play can take seconds before audio starts (esp. Plex, whose resolve downloads
the whole file) with no feedback today. Add a source-agnostic loading indicator:

- A loading flag keyed to PENDING_PLAY_ID, raised at the top of play_audible
  (before the Plex/Qobuz/local resolve — single chokepoint, covers auto-advance)
  and cleared by the poll loop when the requested track's audio actually starts
  (position > 0). Cleared on every play-error early-return and force-cleared on
  queue-finished / peer-controller / ephemeral-stop so it never sticks.
- Watchdog: force-clear after 45s if the engine accepted a play that silently
  never advances (undecodable-but-valid file) — otherwise the spinner is eternal.
- Spinner on three surfaces driven by NowPlayingState.loading: now-playing bar
  (cover overlay + play/pause button), the active track row's play cell, and the
  album big play button (gated to the album actually being launched).

Audio backend / playback routing untouched. The spinner only communicates the
wait; the underlying Plex fetch latency is fixed separately (progressive streaming).

cc
This commit is contained in:
vicrodh committed 2026-06-08 12:40:06 -06:00
1 parent 48a78192c0
commit ed7891c697
9 files changed
+213 -13

No files matched your search

+114 -4
View File
@@ -154,10 +154,62 @@ const PLAYBACK_QUALITY: Quality = Quality::UltraHiRes;
/// Convenience alias for the runtime handle threaded through every call.
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// 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<AppWindow>, 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::<NowPlayingState>().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<AppWindow>, 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::<NowPlayingState>().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<AppWindow>, 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<AppWindow>, 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<AppWindow>, 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<AppWindow>, 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<AppWindow>, 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<AppWindow>, 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<AppWindow>,
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<AppWindow>,
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<App
}
let _ = runtime.core().stop();
runtime.core().clear_queue(false).await;
clear_loading(weak, 0);
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NowPlayingState>().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::<NowPlayingState>();
np.set_playing(false);
+14 -2
View File
@@ -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 <image> icon;
in property <bool> primary: false;
// While the album's first track is fetching, the icon swaps for a spinner.
in property <bool> loading: false;
in property <length> 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");
}
@@ -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();
}
@@ -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 <image> icon;
in property <string> tooltip;
in property <bool> 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 <bool> 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 => {
@@ -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 <bool> primary: false;
// Stays "pressed" — same surface as hover, icon tinted accent.
in property <bool> active: false;
// While the album's first track is fetching, the icon swaps for a spinner.
in property <bool> loading: false;
in property <length> diameter: 40px;
// Optional hover tooltip (empty = none), using the global TooltipOverlay.
in property <string> 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 => {
@@ -78,9 +78,14 @@ export component TrackPlayCell inherits Rectangle {
property <bool> is-active:
NowPlayingState.has-track && NowPlayingState.track-id == root.track-id;
property <bool> 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 <bool> is-loading: root.is-active && NowPlayingState.loading;
property <bool> show-overlay: root.row-hovered || root.is-active;
// The animated bars show only while playing and the row is at rest.
property <bool> 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 <bool> 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");
+16
View File
@@ -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;
}
}
}
}
@@ -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");
+6
View File
@@ -2106,6 +2106,12 @@ export global NowPlayingState {
// it has no DB row, so favorite / add-to-playlist / track-info are gated off.
in property <bool> is-ephemeral: false;
in property <bool> 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 <bool> loading: false;
in property <int> position-secs: 0;
in property <int> duration-secs: 0;
in property <float> progress: 0.0; // 0..1