diff --git a/crates/qbz-media-controls/src/linux.rs b/crates/qbz-media-controls/src/linux.rs index 43dc85ccc..a7c48f714 100644 --- a/crates/qbz-media-controls/src/linux.rs +++ b/crates/qbz-media-controls/src/linux.rs @@ -20,7 +20,7 @@ use std::sync::{ use mpris_server::zbus::{self, fdo}; use mpris_server::{ LoopStatus, Metadata, PlaybackRate, PlaybackStatus as MprisStatus, PlayerInterface, Property, - RootInterface, Server, Time, TrackId, Volume, + RootInterface, Server, Signal, Time, TrackId, Volume, }; use crate::inhibit::SleepInhibitor; @@ -58,6 +58,11 @@ enum Update { /// `PropertiesChanged` by design, so there is no signal to send; the point /// is purely that the next on-demand read is not stale. Position(Time), + /// A DISCONTINUOUS jump. Stores like `Position` and additionally emits the + /// `Seeked` signal, which is the ONE thing that tells an already-open + /// client to stop extrapolating and re-read. Without it a widget keeps + /// counting from wherever it was until it happens to poll again. + Seeked(Time), } /// The cloneable handle returned to the app. Pushing state is a non-blocking @@ -87,6 +92,12 @@ impl MediaIntegration for LinuxHandle { .tx .try_send(Update::Position(Time::from_micros(position.as_micros() as i64))); } + + fn seeked(&self, position: std::time::Duration) { + let _ = self + .tx + .try_send(Update::Seeked(Time::from_micros(position.as_micros() as i64))); + } } fn map_status(s: PlaybackStatus) -> MprisStatus { @@ -297,6 +308,10 @@ async fn apply(server: &Server, state: &Arc>, update: Upd Update::Position(p) => { state.lock().unwrap().position = p; } + Update::Seeked(p) => { + state.lock().unwrap().position = p; + let _ = server.emit(Signal::Seeked { position: p }).await; + } } } diff --git a/crates/qbz-media-controls/src/types.rs b/crates/qbz-media-controls/src/types.rs index 0ea04f32c..4a34abeb3 100644 --- a/crates/qbz-media-controls/src/types.rs +++ b/crates/qbz-media-controls/src/types.rs @@ -91,4 +91,18 @@ pub trait MediaIntegration: Send + Sync { fn set_position(&self, position: Duration) { let _ = position; } + + /// Report a DISCONTINUOUS jump — a user seek, not the ordinary advance of + /// playback. + /// + /// This is the one thing that reaches a client which is already open and + /// extrapolating: `Seeked` tells it to stop and re-read. Keeping the + /// stored position fresh (above) fixes what a client sees when it OPENS; + /// this fixes what it sees while it is already watching. + /// + /// Defaulted to `set_position`, so a backend without the signal still gets + /// the value right on the next read. + fn seeked(&self, position: Duration) { + self.set_position(position); + } } diff --git a/crates/qbz-qt/src/media_controls_qt.rs b/crates/qbz-qt/src/media_controls_qt.rs index ab22261c9..8210e91e1 100644 --- a/crates/qbz-qt/src/media_controls_qt.rs +++ b/crates/qbz-qt/src/media_controls_qt.rs @@ -208,6 +208,20 @@ pub(crate) fn push_position(position_secs: u64) { } } +/// Report a user SEEK — a discontinuous jump, not the ordinary advance. +/// +/// `push_position` fixes what a client sees when it OPENS; this is what +/// reaches one that is ALREADY open. The owner's observation is what made the +/// distinction concrete: *"el contador del seekbar del widget solo empieza a +/// contar hasta que lo abro"* — the widget extrapolates from the value it read +/// at open and never looks again, so only the `Seeked` signal can tell it the +/// ground moved. +pub(crate) fn push_seeked(position_secs: u64) { + if let Some(mc) = handle() { + mc.seeked(Duration::from_secs(position_secs)); + } +} + /// Compare-and-record the metadata de-dupe key. `true` when `key` differs from /// the last pushed value (→ caller pushes now), recording it as the new /// last-pushed value. A poisoned lock falls back to pushing. diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 2c27607e2..0493622c5 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -1619,20 +1619,10 @@ pub async fn seek_frac(runtime: &Arc>, frac: f32) { log::warn!("[qbz-qt] seek failed: {e}"); return; } - // Tell the desktop where we jumped to. Without this the MPRIS position is - // only ever refreshed on the play/pause edge, so a widget extrapolates - // from a base that is now wrong by the whole seek distance — the owner - // measured ~40 s of lag on 2026-08-04 after seeking forward. - // - // The shared crate exposes no `Seeked` emitter (`types.rs`), and - // `set_playback` already carries a position, so the honest fix inside the - // current API is to re-assert the state at the new position. The SHIPPING - // Slint build has the same gap and the same cure would apply there — that - // is a shared-crate conversation, not this one. - crate::media_controls_qt::push_playback_state( - runtime.core().player().get_playback_event().is_playing, - target, - ); + // 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>, volume: f32) {