fix: emit MPRIS Seeked, so an already-open widget stops extrapolating

The owner's observation is what separated the two halves of this: "el
contador del seekbar del widget solo empieza a contar hasta que lo abro."
A client reads Position ONCE, when it opens, and extrapolates from there —
it does not poll. So two different things were broken and only one was fixed
by keeping the stored position fresh.

Fresh storage fixes what a client sees when it OPENS. `Seeked` is the only
thing that reaches one that is ALREADY open: it tells the client to stop
extrapolating and re-read. Without it, a seek moved the music and the widget
kept counting from where it had been.

So the seek path emits Seeked instead of re-asserting the playback state,
which was the previous commit's guess at a cure inside an API that had no
signal. `mpris_server::Signal::Seeked` was there all along.

Defaulted on the trait to `set_position`, so a backend without the signal
still gets the value right on the next read and no other consumer changes:
cargo check passes for qbz-app and qbzd.

cc
This commit is contained in:
vicrodh committed 2026-08-04 14:05:05 -06:00
1 parent f45b703c64
commit 7ba07ec6f8
4 files changed
+48 -15

No files matched your search

+16 -1
View File
@@ -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<QbzMpris>, state: &Arc<Mutex<State>>, 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;
}
}
}
+14
View File
@@ -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);
}
}
+14
View File
@@ -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.
+4 -14
View File
@@ -1619,20 +1619,10 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, volume: f32) {