feat(qbz-slint): reflect peer renderer playback on the now-playing bar

When QBZ is CONTROLLING a peer renderer the local player is stopped, so
the poll loop saw track_id == 0 and the seek bar froze. Add
RemoteNowPlaying + SlintQconnectService::remote_now_playing(), sourced
from effective_remote_renderer_snapshot (peer position / updated_at /
playing). The poll loop now branches at the top of each tick: when a peer
is active it drives position/duration/progress/playing from the peer
(duration from the core queue's current track, position extrapolated and
clamped), resets the local edge trackers, and skips the local body. In
every non-controller situation remote_now_playing returns None and the
existing local path runs byte-unchanged, so renderer mode does not regress.

cc
This commit is contained in:
vicrodh committed 2026-06-03 09:23:08 -06:00
1 parent 0d7ac7930d
commit 6405b10204
2 files changed
+100

No files matched your search

+72
View File
@@ -480,6 +480,17 @@ fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, art: qbz_models::Artwo
});
}
/// Wall-clock now in milliseconds. Used by the poll loop to extrapolate the
/// peer renderer's position (`position_ms + (now - updated_at_ms)`) while
/// QBZ is CONTROLLING a peer (the local player is stopped, so the seek bar
/// must follow the peer instead). Mirrors the Svelte `qconnectRemoteClockMs`.
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// `M:SS` for the elapsed string.
fn fmt_elapsed(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
@@ -1972,6 +1983,67 @@ pub fn start_poll_loop(
loop {
ticker.tick().await;
// --- QConnect CONTROLLER mode: peer-state reflection ----------
// 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.
if let Some(remote) = match crate::qconnect_service::service() {
Some(svc) => svc.remote_now_playing().await,
None => None,
} {
// Duration from the core queue's current track (aligned to the
// peer's track by the sink). Zero when unknown — clamp is skipped.
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;
}
let position_secs = position_ms / 1000;
let progress = if duration_ms > 0 {
(position_ms as f32 / duration_ms as f32).clamp(0.0, 1.0)
} else {
0.0
};
let elapsed = fmt_elapsed(position_secs);
let remaining = fmt_remaining(position_secs, duration_secs);
let playing = remote.playing;
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
np.set_position_secs(position_secs as i32);
if duration_secs > 0 {
np.set_duration_secs(duration_secs as i32);
}
np.set_progress(progress);
np.set_seekable_max(1.0);
np.set_elapsed(elapsed.into());
np.set_remaining(remaining.into());
np.set_playing(playing);
});
// 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;
continue;
}
let event = runtime.core().player().get_playback_event();
let track_id = event.track_id;
+28
View File
@@ -63,6 +63,17 @@ fn qconnect_now_ms() -> u64 {
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Reduced peer-renderer playback snapshot for the now-playing seek bar while
/// QBZ is CONTROLLING a peer. Sourced from the effective remote renderer
/// snapshot; the poll loop extrapolates position from `position_ms` +
/// (now - `updated_at_ms`) while `playing`. Avoids leaking core types into the
/// playback module. Mirrors the Svelte `effectiveCurrentTime` derivation.
pub struct RemoteNowPlaying {
pub position_ms: u64,
pub updated_at_ms: u64,
pub playing: bool,
}
/// Process-wide QConnect service singleton (one per app, like the playback
/// QueueController). Initialized once at shell setup; the connect trigger + the
/// future `*_if_remote` transport routing reach it through `service()`.
@@ -676,6 +687,23 @@ impl SlintQconnectService {
Ok(Some((renderer, queue, session)))
}
/// Reduced peer-renderer playback snapshot for the now-playing seek bar.
/// Returns `Some` ONLY when a PEER renderer is active (controller mode); the
/// poll loop then drives the bar from the peer and skips its local body. When
/// `None`, the poll loop falls through to the local player path verbatim.
/// Sources position / updated_at / playing from the effective remote renderer
/// snapshot (`playing_state == PLAYING`). Title/artist/art come from the
/// materialized local core queue, so only these three fields are needed here.
pub async fn remote_now_playing(&self) -> Option<RemoteNowPlaying> {
let (renderer, _queue, _session) =
self.effective_remote_renderer_snapshot().await.ok().flatten()?;
Some(RemoteNowPlaying {
position_ms: renderer.current_position_ms.unwrap_or(0),
updated_at_ms: renderer.updated_at_ms,
playing: renderer.playing_state == Some(PLAYING_STATE_PLAYING),
})
}
/// Optimistically apply a queue_item_id / playing_state / position to the
/// active peer renderer's cached state, so the UI doesn't bounce back before
/// the cloud echo. Mirrors the Tauri `prime_remote_renderer_state`.