Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ The pill in the pipeline popover needs no change: it already compares the source

- **Windows** — SMTC. Now-Playing artwork is served to SMTC over a tiny localhost HTTP shim because Windows expects a URL, not a file path.
- **Linux** — MPRIS via D-Bus.
souvlaki answers the MPRIS `Position` property with the last value it was handed and never advances it, so `playerctl position` (and the lyrics clients built on it) used to read the start of the track until the next seek (#807). While playing, the controls thread now re-reads the engine's position once a second (`POSITION_REFRESH`) and republishes it; it reads the engine's own clock, so speed, seeks and crossfade hand-offs are already accounted for, and it skips the publish when the engine has already left `Playing`.
- **macOS** — MediaRemote (NowPlayingInfoCenter).

Initialised after the main window exists (needs an HWND on Windows). State transitions are driven through `transition_state()` so the OS overlay flips at the same instant as the in-app controls; the brief `Loading` state is skipped to avoid a 50 ms "controls flash off" between tracks.
Expand Down
53 changes: 51 additions & 2 deletions src-tauri/crates/app/src/media_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
//! - `audio::decoder::transition_state` pushes playback state changes.
//! - `commands::player::player_seek` pushes the new position so the
//! OS overlay's progress bar resyncs immediately.
//! - While playing, the controls thread re-reads the engine's position
//! every second ([`POSITION_REFRESH`]): MPRIS `Position` is otherwise
//! frozen at the last pushed value (#807).
//!
//! Event flow (OS keys / overlay buttons):
//! - The souvlaki callback runs on souvlaki's own thread. We forward
Expand All @@ -28,7 +31,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use crossbeam_channel::{unbounded, Sender};
use crossbeam_channel::{unbounded, RecvTimeoutError, Sender};
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
SeekDirection,
Expand All @@ -37,6 +40,15 @@ use tauri::{AppHandle, Manager};

use crate::audio::{AudioCmd, AudioEngine, PlayerState};

/// How often the controls thread re-publishes the position while playing.
///
/// souvlaki answers MPRIS `Position` with the last value it was handed and
/// never advances it, and we hand it one only on a state change or a seek.
/// `playerctl position`, and every lyrics client built on it, read the
/// track's start for the whole track (#807). Once a second is the cadence
/// of the clients that poll it, and each publish is one D-Bus signal.
const POSITION_REFRESH: Duration = Duration::from_secs(1);

/// Cached metadata held inside the controls thread so we can re-emit on
/// every state transition (souvlaki forgets metadata between some
/// playback updates on Windows otherwise).
Expand Down Expand Up @@ -121,6 +133,27 @@ impl MediaControlsHandle {
}
}

/// Re-publish the live position, read from the engine rather than
/// extrapolated here: the engine's clock already accounts for the playback
/// speed, a seek and a crossfade hand-off.
fn refresh_position(controls: &mut MediaControls, app: &AppHandle) {
let Some(engine) = app.try_state::<Arc<AudioEngine>>() else {
return;
};
let shared = engine.shared();
// The state message for a pause may still be in flight: never publish
// a Playing the engine has already left.
if shared.state() != PlayerState::Playing {
return;
}
let progress = Some(MediaPosition(Duration::from_millis(
shared.current_position_ms(),
)));
if let Err(err) = controls.set_playback(MediaPlayback::Playing { progress }) {
tracing::debug!(?err, "media_controls: position refresh");
}
}

/// Resolve a local cover-art path into a URL the OS media overlay can
/// fetch. On Windows we register the file with the local artwork
/// server and return its `http://127.0.0.1:<port>/artwork/<basename>`
Expand Down Expand Up @@ -274,6 +307,7 @@ pub fn init(app: AppHandle) -> Option<MediaControlsHandle> {

let (tx, rx) = unbounded::<Msg>();
let event_app = app.clone();
let event_app_for_ticks = app.clone();

let spawn = std::thread::Builder::new()
.name("waveflow-media-controls".into())
Expand Down Expand Up @@ -303,12 +337,27 @@ pub fn init(app: AppHandle) -> Option<MediaControlsHandle> {
return;
}

while let Ok(msg) = rx.recv() {
let tick_app = event_app_for_ticks;
// Whether the last state handed over was Playing: a paused or
// stopped position does not move, so it needs no refresh.
let mut playing = false;
loop {
let msg = match rx.recv_timeout(POSITION_REFRESH) {
Ok(msg) => msg,
Err(RecvTimeoutError::Timeout) => {
if playing {
refresh_position(&mut controls, &tick_app);
}
continue;
}
Err(RecvTimeoutError::Disconnected) => break,
};
match msg {
Msg::Metadata(meta) => {
push_metadata(&mut controls, &meta);
}
Msg::Playback { state, position_ms } => {
playing = state == PlayerState::Playing;
let progress = Some(MediaPosition(Duration::from_millis(position_ms)));
let pb = match state {
PlayerState::Playing => MediaPlayback::Playing { progress },
Expand Down
Loading