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
65 changes: 59 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,8 @@ of the app. Beware that this comes at a CPU cost!",
let shared_is_playing_for_events = Arc::clone(&shared_is_playing);
#[cfg(all(feature = "mpris", target_os = "linux"))]
let shared_is_playing_for_mpris = Arc::clone(&shared_is_playing);
#[cfg(all(feature = "mpris", target_os = "linux"))]
let shared_position_for_mpris = Arc::clone(&shared_position);
#[cfg(all(feature = "macos-media", target_os = "macos"))]
let shared_is_playing_for_macos = Arc::clone(&shared_is_playing);

Expand Down Expand Up @@ -860,11 +862,16 @@ of the app. Beware that this comes at a CPU cost!",
if let Some(ref mpris) = mpris_manager {
if let Some(event_rx) = mpris.take_event_rx() {
let streaming_player_for_mpris = streaming_player.clone();
let mpris_for_seek = Arc::clone(mpris);
let app_for_mpris = Arc::clone(&app);
tokio::spawn(async move {
handle_mpris_events(
event_rx,
streaming_player_for_mpris,
shared_is_playing_for_mpris,
shared_position_for_mpris,
mpris_for_seek,
app_for_mpris,
)
.await;
});
Expand Down Expand Up @@ -1254,6 +1261,11 @@ async fn handle_player_events(
// Use atomic store for lock-free position updates
// This never blocks or fails, ensuring every position update is captured
shared_position.store(position_ms as u64, Ordering::Relaxed);

// Update MPRIS position so external clients (playerctl, desktop widgets) stay in sync
if let Some(ref mpris) = mpris_manager {
mpris.set_position(position_ms as u64);
}
}
_ => {
// Ignore other events
Expand Down Expand Up @@ -1422,6 +1434,9 @@ async fn handle_mpris_events(
mut event_rx: tokio::sync::mpsc::UnboundedReceiver<mpris::MprisEvent>,
streaming_player: Option<Arc<player::StreamingPlayer>>,
shared_is_playing: Arc<std::sync::atomic::AtomicBool>,
shared_position: Arc<AtomicU64>,
mpris_manager: Arc<mpris::MprisManager>,
app: Arc<Mutex<App>>,
) {
use mpris::MprisEvent;
use std::sync::atomic::Ordering;
Expand Down Expand Up @@ -1463,12 +1478,50 @@ async fn handle_mpris_events(
player.stop();
}
MprisEvent::Seek(offset_micros) => {
// Seek by offset - convert from microseconds to milliseconds
// Note: This is a relative seek, not absolute position
let offset_ms = (offset_micros / 1000) as u32;
// Since we don't have the current position here easily,
// this is a simplified implementation
player.seek(offset_ms);
// MPRIS sends relative offset in microseconds (can be negative for rewind)
// We need to calculate: new_absolute_position = current_position + offset

// Get current position (stored in milliseconds)
let current_ms = shared_position.load(Ordering::Relaxed) as i64;

// Convert offset from microseconds to milliseconds
let offset_ms = offset_micros / 1000;

// Calculate new position, clamping to prevent going negative
let new_position_ms = (current_ms + offset_ms).max(0) as u32;

// Seek the player
player.seek(new_position_ms);

// Update shared position immediately so UI reflects the change
shared_position.store(new_position_ms as u64, Ordering::Relaxed);

// Update app's song_progress_ms so UI updates even when paused
if let Ok(mut app_lock) = app.try_lock() {
app_lock.song_progress_ms = new_position_ms as u128;
}

// Emit Seeked signal so external clients know position jumped
mpris_manager.emit_seeked(new_position_ms as u64);
}
MprisEvent::SetPosition(position_micros) => {
// MPRIS SetPosition sends absolute position in microseconds
// Convert to milliseconds and seek directly
let new_position_ms = (position_micros / 1000).max(0) as u32;

Comment on lines +1489 to +1511
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new_position_ms is computed as i64 and then cast with as u32. If current_ms + offset_ms (or position_micros/1000) exceeds u32::MAX, the cast will wrap/truncate and can seek to an unintended position. Clamp to an upper bound before converting (e.g., clamp to u32::MAX or track duration if available), or keep the value as u64 until the final validated conversion to u32 for player.seek().

Copilot uses AI. Check for mistakes.
// Seek the player
player.seek(new_position_ms);

// Update shared position immediately so UI reflects the change
shared_position.store(new_position_ms as u64, Ordering::Relaxed);

// Update app's song_progress_ms so UI updates even when paused
if let Ok(mut app_lock) = app.try_lock() {
app_lock.song_progress_ms = new_position_ms as u128;
}

// Emit Seeked signal so external clients know position jumped
mpris_manager.emit_seeked(new_position_ms as u64);
}
}
}
Expand Down
29 changes: 24 additions & 5 deletions src/mpris.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ pub enum MprisEvent {
Next,
Previous,
Stop,
Seek(i64), // Offset in microseconds
Seek(i64), // Relative offset in microseconds
SetPosition(i64), // Absolute position in microseconds
}

/// Commands to send TO the MPRIS server to update its state
#[derive(Debug, Clone)]
#[allow(dead_code)] // SetPosition kept for future use
pub enum MprisCommand {
Metadata {
title: String,
Expand All @@ -36,7 +36,8 @@ pub enum MprisCommand {
art_url: Option<String>,
},
PlaybackStatus(bool), // true = playing, false = paused
Position(u64), // position in milliseconds (for future use)
Position(u64), // position in milliseconds (silent update)
Seeked(u64), // position in milliseconds (emits Seeked signal to notify clients)
Volume(u8), // 0-100
Stopped,
}
Expand Down Expand Up @@ -126,6 +127,11 @@ impl MprisManager {
let _ = tx.send(MprisEvent::Seek(offset.as_micros()));
});

let tx = event_tx.clone();
player.connect_set_position(move |_player, _track_id, position| {
let _ = tx.send(MprisEvent::SetPosition(position.as_micros()));
});

// Spawn the player event loop
tokio::task::spawn_local(player.run());

Expand Down Expand Up @@ -167,8 +173,17 @@ impl MprisManager {
}
}
MprisCommand::Position(position_ms) => {
// Silent position update (for regular playback progress)
player.set_position(Time::from_millis(position_ms as i64));
}
MprisCommand::Seeked(position_ms) => {
// Update position AND emit Seeked signal so clients know to refresh
let time = Time::from_millis(position_ms as i64);
player.set_position(time);
if let Err(e) = player.seeked(time).await {
Comment on lines 175 to +183
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time::from_millis(position_ms as i64) casts from u64 to i64 with as, which can wrap for large values. Even if current callers pass small positions, this is an API footgun (and Seeked uses the same pattern). Consider changing Position/Seeked to carry u32 milliseconds (matching track durations elsewhere), or use a checked conversion/clamp before building Time so invalid/oversized positions can’t become negative/incorrect.

Copilot uses AI. Check for mistakes.
eprintln!("MPRIS: Failed to emit Seeked signal: {}", e);
}
}
MprisCommand::Volume(volume_percent) => {
let volume = (volume_percent as f64) / 100.0;
if let Err(e) = player.set_volume(volume).await {
Expand Down Expand Up @@ -223,12 +238,16 @@ impl MprisManager {
.send(MprisCommand::PlaybackStatus(is_playing));
}

/// Update playback position
#[allow(dead_code)] // Kept for future use
/// Update playback position (silent, no signal emitted)
pub fn set_position(&self, position_ms: u64) {
let _ = self.command_tx.send(MprisCommand::Position(position_ms));
}

/// Update position AND emit Seeked signal (use when position jumps due to seeking)
pub fn emit_seeked(&self, position_ms: u64) {
let _ = self.command_tx.send(MprisCommand::Seeked(position_ms));
}

/// Update volume (0-100)
pub fn set_volume(&self, volume_percent: u8) {
let _ = self.command_tx.send(MprisCommand::Volume(volume_percent));
Expand Down
Loading