aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:19:49 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:19:49 +0100
commit50cd2a17734dee4c4739112261fc40974cce409a (patch)
tree027366c170eb8dc035c5bd24be2669c41df64f28
parentstate: persist the queue and pick up where playback stopped (diff)
downloadrsplayer-50cd2a17734dee4c4739112261fc40974cce409a.tar.gz
rsplayer-50cd2a17734dee4c4739112261fc40974cce409a.zip
seek: jump within the current track from the CLI and over MPRIS
`rsp seek 1:30`, `rsp seek +15`, `rsp seek -30` and `rsp seek` (print the position) drive a new AudioCommand that seeks the format reader, resets the decoder and drops the buffered samples, so the cpal stream keeps running and playback continues from the new spot. MPRIS gets a working Seek/SetPosition, reports CanSeek, and the state watcher now emits Seeked when the playhead jumps (not just on track changes), which is what clients need to move their progress bar after an external seek. `rsp status` also reports the position (and duration when known) and says Paused instead of Stopped when a track is loaded but not playing.
-rw-r--r--README.md1
-rw-r--r--src/audio.rs56
-rw-r--r--src/cli.rs11
-rw-r--r--src/daemon.rs140
-rw-r--r--src/mpris.rs71
5 files changed, 245 insertions, 34 deletions
diff --git a/README.md b/README.md
index c171fce..c2cf39a 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ these are normal commands:
61- `stop` Stop playback 61- `stop` Stop playback
62- `next` Skip to next track 62- `next` Skip to next track
63- `previous` Skip to previous track 63- `previous` Skip to previous track
64- `seek` Seek within the current track (absolute, `+30`/`-30`, or `1:30`)
64- `volume` Set volume (0-100) 65- `volume` Set volume (0-100)
65- `shuffle` Shuffle mode 66- `shuffle` Shuffle mode
66- `queue` Show the current queue 67- `queue` Show the current queue
diff --git a/src/audio.rs b/src/audio.rs
index 8bac26f..5ce00ae 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -123,6 +123,7 @@ pub enum AudioCommand {
123 Pause, 123 Pause,
124 Resume, 124 Resume,
125 Stop, 125 Stop,
126 Seek(f64),
126 SetVolume(f32), 127 SetVolume(f32),
127} 128}
128 129
@@ -193,6 +194,11 @@ impl AudioEngine {
193 let _ = self.command_tx.send(AudioCommand::Stop); 194 let _ = self.command_tx.send(AudioCommand::Stop);
194 } 195 }
195 196
197 /// Jump to `position` seconds into the current track.
198 pub fn seek(&self, position: f64) {
199 let _ = self.command_tx.send(AudioCommand::Seek(position));
200 }
201
196 pub fn set_volume(&self, volume: f32) { 202 pub fn set_volume(&self, volume: f32) {
197 let _ = self.command_tx.send(AudioCommand::SetVolume(volume)); 203 let _ = self.command_tx.send(AudioCommand::SetVolume(volume));
198 } 204 }
@@ -799,6 +805,22 @@ fn audio_thread(
799 *track = None; 805 *track = None;
800 } 806 }
801 } 807 }
808 AudioCommand::Seek(seconds) => {
809 let target = seconds.max(0.0);
810 if let Ok(mut d) = ctx.audio_data.lock()
811 && let Some(data) = d.as_mut()
812 {
813 match seek_audio(data, target) {
814 Ok(()) => {
815 if let Ok(mut pos) = position.lock() {
816 *pos = target;
817 }
818 crate::debug_eprintln(format_args!("Seeked to {:.1}s", target));
819 }
820 Err(e) => eprintln!("Failed to seek: {}", e),
821 }
822 }
823 }
802 AudioCommand::SetVolume(vol) => { 824 AudioCommand::SetVolume(vol) => {
803 if let Ok(mut volume_ref) = volume.lock() { 825 if let Ok(mut volume_ref) = volume.lock() {
804 *volume_ref = vol; 826 *volume_ref = vol;
@@ -865,6 +887,40 @@ fn audio_thread(
865 } 887 }
866} 888}
867 889
890/// Moves the decoder to `seconds` into the current track.
891///
892/// Symphonia's accurate seek lands on the packet *before* the requested
893/// position, so packets short of the required timestamp are dropped before
894/// playback resumes. The cpal stream itself keeps running: only the decoded
895/// buffers are flushed, so there is no gap beyond the dropped packet.
896fn seek_audio(data: &mut AudioData, seconds: f64) -> Result<(), String> {
897 use symphonia::core::formats::{SeekMode, SeekTo};
898 use symphonia::core::units::Time;
899
900 let time = Time::try_from_secs_f64(seconds).ok_or("invalid seek position")?;
901 let seeked = data
902 .format
903 .seek(
904 SeekMode::Accurate,
905 SeekTo::Time {
906 time,
907 track_id: None,
908 },
909 )
910 .map_err(|e| e.to_string())?;
911
912 data.decoder.reset();
913 data.sample_buffer.clear();
914 data.buffer_pos = 0;
915 while let Ok(Some(packet)) = data.format.next_packet() {
916 if packet.pts >= seeked.required_ts {
917 break;
918 }
919 }
920
921 Ok(())
922}
923
868fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Error>> { 924fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Error>> {
869 use symphonia::core::audio::layouts::CHANNEL_LAYOUT_STEREO; 925 use symphonia::core::audio::layouts::CHANNEL_LAYOUT_STEREO;
870 use symphonia::core::codecs::audio::AudioDecoderOptions; 926 use symphonia::core::codecs::audio::AudioDecoderOptions;
diff --git a/src/cli.rs b/src/cli.rs
index 12bc3b0..79c31a8 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -68,6 +68,13 @@ pub enum Commands {
68 Queue, 68 Queue,
69 /// Show player status 69 /// Show player status
70 Status, 70 Status,
71 /// Seek within the current track
72 Seek {
73 /// Position: `90`, `1:30`, `1:02:03`, or a relative offset `+15` / `-30`;
74 /// without one, the current position is printed
75 #[arg(value_name = "POSITION", allow_hyphen_values = true)]
76 position: Option<String>,
77 },
71 /// Scan music paths to populate database 78 /// Scan music paths to populate database
72 Scan { 79 Scan {
73 #[arg(long)] 80 #[arg(long)]
@@ -198,6 +205,10 @@ impl Cli {
198 Commands::Status => { 205 Commands::Status => {
199 crate::daemon::send_command("status".to_string())?; 206 crate::daemon::send_command("status".to_string())?;
200 } 207 }
208 Commands::Seek { position } => match position {
209 Some(p) => crate::daemon::send_command(format!("seek {}", p))?,
210 None => crate::daemon::send_command("seek".to_string())?,
211 },
201 Commands::Scan { force, paths } => { 212 Commands::Scan { force, paths } => {
202 let config = Config::load()?; 213 let config = Config::load()?;
203 let db = Database::open(&config)?; 214 let db = Database::open(&config)?;
diff --git a/src/daemon.rs b/src/daemon.rs
index 8f470c1..4f95820 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -954,6 +954,49 @@ fn resume_queue_track(
954 true 954 true
955} 955}
956 956
957/// Parse a seek argument into an absolute position in seconds.
958///
959/// Accepts `90`, `1:30`, `1:02:03`, and relative offsets `+15` / `-30`.
960fn parse_seek(arg: &str, current: f64) -> Option<f64> {
961 let (sign, body) = match arg.as_bytes().first()? {
962 b'+' => (1.0, &arg[1..]),
963 b'-' => (-1.0, &arg[1..]),
964 _ => (0.0, arg),
965 };
966 let value = parse_time(body)?;
967 Some(if sign == 0.0 {
968 value
969 } else {
970 (current + sign * value).max(0.0)
971 })
972}
973
974/// Parse `SS`, `MM:SS`, or `HH:MM:SS` (fractions allowed) into seconds.
975fn parse_time(text: &str) -> Option<f64> {
976 let mut total = 0.0f64;
977 let mut parts = 0usize;
978 for part in text.split(':') {
979 let value: f64 = part.trim().parse().ok()?;
980 if value < 0.0 {
981 return None;
982 }
983 total = total * 60.0 + value;
984 parts += 1;
985 }
986 (1..=3).contains(&parts).then_some(total)
987}
988
989/// `83.4` -> `1:23`, `3723` -> `1:02:03`
990fn format_time(seconds: f64) -> String {
991 let secs = seconds.max(0.0) as u64;
992 let (hours, minutes, secs) = (secs / 3600, (secs % 3600) / 60, secs % 60);
993 if hours > 0 {
994 format!("{}:{:02}:{:02}", hours, minutes, secs)
995 } else {
996 format!("{}:{:02}", minutes, secs)
997 }
998}
999
957fn process_command( 1000fn process_command(
958 cmd: &str, 1001 cmd: &str,
959 audio: &AudioEngine, 1002 audio: &AudioEngine,
@@ -964,20 +1007,38 @@ fn process_command(
964) -> String { 1007) -> String {
965 match cmd { 1008 match cmd {
966 "status" => { 1009 "status" => {
967 if audio.is_playing() { 1010 let current = audio.current_track().or_else(|| {
968 if let Some(track_path) = audio.current_track() { 1011 queue
969 let track = Track::from_path(track_path); 1012 .lock()
970 let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); 1013 .ok()
971 let track_str = match fmt.as_deref() { 1014 .and_then(|q| q.current().map(|t| t.path.clone()))
972 Some(f) => track.format(f), 1015 });
973 None => track.display_string(0, 1), 1016 let Some(track_path) = current else {
974 }; 1017 return "Stopped\n".to_string();
975 format!("Playing\nTrack: {}\n", track_str) 1018 };
976 } else { 1019 let state = if audio.is_playing() {
977 "Playing\n".to_string() 1020 "Playing"
978 }
979 } else { 1021 } else {
980 "Stopped\n".to_string() 1022 "Paused"
1023 };
1024 let track = Track::from_path(track_path);
1025 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
1026 let track_str = match fmt.as_deref() {
1027 Some(f) => track.format(f),
1028 None => track.display_string(0, 1),
1029 };
1030 let position = format_time(audio.position());
1031 match track.duration_secs {
1032 Some(duration) if duration > 0.0 => {
1033 format!(
1034 "{}\nTrack: {}\nPosition: {} / {}\n",
1035 state,
1036 track_str,
1037 position,
1038 format_time(duration)
1039 )
1040 }
1041 _ => format!("{}\nTrack: {}\nPosition: {}\n", state, track_str, position),
981 } 1042 }
982 } 1043 }
983 "pause" => { 1044 "pause" => {
@@ -1014,6 +1075,20 @@ fn process_command(
1014 notification::notify_change("Stopped"); 1075 notification::notify_change("Stopped");
1015 "Stopped\n".to_string() 1076 "Stopped\n".to_string()
1016 } 1077 }
1078 _ if cmd == "seek" || cmd.starts_with("seek ") => {
1079 let current = audio.position();
1080 let arg = cmd[4..].trim();
1081 if arg.is_empty() {
1082 return format!("Position: {}\n", format_time(current));
1083 }
1084 match parse_seek(arg, current) {
1085 Some(target) => {
1086 audio.seek(target);
1087 format!("Seeking to {}\n", format_time(target))
1088 }
1089 None => "Usage: rsp seek <seconds|MM:SS|[+|-]seconds>\n".to_string(),
1090 }
1091 }
1017 "stop_daemon" => { 1092 "stop_daemon" => {
1018 shutdown_flag.store(true, Ordering::SeqCst); 1093 shutdown_flag.store(true, Ordering::SeqCst);
1019 "Shutting down\n".to_string() 1094 "Shutting down\n".to_string()
@@ -1834,3 +1909,42 @@ fn cleanup_daemon(socket_path: &std::path::Path, pid_path: &std::path::Path) {
1834 std::fs::remove_file(socket_path).ok(); 1909 std::fs::remove_file(socket_path).ok();
1835 std::fs::remove_file(pid_path).ok(); 1910 std::fs::remove_file(pid_path).ok();
1836} 1911}
1912
1913#[cfg(test)]
1914mod tests {
1915 use super::*;
1916
1917 #[test]
1918 fn parses_absolute_seek_positions() {
1919 assert_eq!(parse_seek("90", 10.0), Some(90.0));
1920 assert_eq!(parse_seek("1:30", 10.0), Some(90.0));
1921 assert_eq!(parse_seek("1:02:03", 0.0), Some(3723.0));
1922 assert_eq!(parse_seek("83.5", 0.0), Some(83.5));
1923 }
1924
1925 #[test]
1926 fn parses_relative_seek_offsets() {
1927 assert_eq!(parse_seek("+15", 60.0), Some(75.0));
1928 assert_eq!(parse_seek("-15", 60.0), Some(45.0));
1929 assert_eq!(
1930 parse_seek("-60", 30.0),
1931 Some(0.0),
1932 "relative seeks never go below the start"
1933 );
1934 }
1935
1936 #[test]
1937 fn rejects_bad_seek_positions() {
1938 assert_eq!(parse_seek("abc", 0.0), None);
1939 assert_eq!(parse_seek("", 0.0), None);
1940 assert_eq!(parse_seek("-", 0.0), None);
1941 assert_eq!(parse_seek("1:2:3:4", 0.0), None, "at most hours:min:sec");
1942 }
1943
1944 #[test]
1945 fn formats_positions_for_status() {
1946 assert_eq!(format_time(0.0), "0:00");
1947 assert_eq!(format_time(83.4), "1:23");
1948 assert_eq!(format_time(3723.0), "1:02:03");
1949 }
1950}
diff --git a/src/mpris.rs b/src/mpris.rs
index 3fc706d..de19113 100644
--- a/src/mpris.rs
+++ b/src/mpris.rs
@@ -22,7 +22,7 @@ use std::path::PathBuf;
22use std::sync::atomic::AtomicBool; 22use std::sync::atomic::AtomicBool;
23use std::sync::{Arc, Mutex, RwLock}; 23use std::sync::{Arc, Mutex, RwLock};
24use std::thread; 24use std::thread;
25use std::time::Duration; 25use std::time::{Duration, Instant};
26use zbus::blocking::Connection; 26use zbus::blocking::Connection;
27use zbus::interface; 27use zbus::interface;
28use zbus::zvariant::{ObjectPath, Value}; 28use zbus::zvariant::{ObjectPath, Value};
@@ -101,14 +101,17 @@ pub struct MprisPlayer {
101 loop_status: Arc<Mutex<String>>, 101 loop_status: Arc<Mutex<String>>,
102} 102}
103 103
104/// `mpris:trackid` advertised for whatever is playing. The spec only requires it to be a stable
105/// object path that changes per track; clients echo it back in `SetPosition`.
106const CURRENT_TRACK_ID: &str = "/org/mpris/MediaPlayer2/TrackList/CurrentTrack";
107
104/// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values 108/// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values
105/// so no borrowing from locals. 109/// so no borrowing from locals.
106fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { 110fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> {
107 let mut map: HashMap<String, Value<'static>> = HashMap::new(); 111 let mut map: HashMap<String, Value<'static>> = HashMap::new();
108 112
109 // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string. 113 // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string.
110 let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/CurrentTrack") 114 let trackid = ObjectPath::try_from(CURRENT_TRACK_ID).expect("static object path");
111 .expect("static object path");
112 map.insert("mpris:trackid".into(), Value::new(trackid)); 115 map.insert("mpris:trackid".into(), Value::new(trackid));
113 116
114 let length_us = track 117 let length_us = track
@@ -273,12 +276,23 @@ impl MprisPlayer {
273 } 276 }
274 } 277 }
275 278
276 fn seek(&self, _offset: i64) { 279 fn seek(&self, offset: i64) {
277 // Seeking not implemented yet. 280 let target = (self.audio.position() + offset as f64 / 1_000_000.0).max(0.0);
281 crate::debug_eprintln(format_args!("mpris: seek by {}us", offset));
282 self.audio.seek(target);
278 } 283 }
279 284
280 fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) { 285 fn set_position(&self, track_id: zbus::zvariant::ObjectPath<'_>, position: i64) {
281 // Seeking not implemented yet. 286 // The spec says to ignore the request when the id is not the track we are playing.
287 if track_id.as_str() != CURRENT_TRACK_ID {
288 crate::debug_eprintln(format_args!(
289 "mpris: ignoring SetPosition for foreign track {}",
290 track_id.as_str()
291 ));
292 return;
293 }
294 crate::debug_eprintln(format_args!("mpris: set position to {}us", position));
295 self.audio.seek((position as f64 / 1_000_000.0).max(0.0));
282 } 296 }
283 297
284 fn open_uri(&self, uri: String) { 298 fn open_uri(&self, uri: String) {
@@ -446,7 +460,7 @@ impl MprisPlayer {
446 460
447 #[zbus(property)] 461 #[zbus(property)]
448 fn can_seek(&self) -> bool { 462 fn can_seek(&self) -> bool {
449 false 463 true
450 } 464 }
451 465
452 #[zbus(property)] 466 #[zbus(property)]
@@ -543,6 +557,7 @@ pub fn start_mpris_server(
543 let mut last_volume: Option<f64> = None; 557 let mut last_volume: Option<f64> = None;
544 let mut last_shuffle: Option<ShuffleMode> = None; 558 let mut last_shuffle: Option<ShuffleMode> = None;
545 let mut last_loop: Option<String> = None; 559 let mut last_loop: Option<String> = None;
560 let mut last_position: Option<(Instant, i64)> = None;
546 561
547 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { 562 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) {
548 let status = playback_status(&poll_audio); 563 let status = playback_status(&poll_audio);
@@ -610,25 +625,39 @@ pub fn start_mpris_server(
610 } 625 }
611 } 626 }
612 627
613 // A different track (or none at all) means the playhead jumped, which is what 628 // A different track, or a position that is nowhere near where the playhead
614 // `Seeked` exists for. Clients use it to reset their progress bar without 629 // should be by now, means it jumped — which is what `Seeked` exists for. Clients
615 // having to notice that two consecutive tracks happen to share a length. 630 // use it to reset their progress bar without having to notice that two
616 if track_changed { 631 // consecutive tracks happen to share a length. The steady tick is intentionally
617 let position_us = if track.is_some() { 632 // not signalled: the spec tells clients to interpolate while we report `Playing`.
618 (poll_audio.position() * 1_000_000.0) as i64 633 let position_us = if track.is_some() {
619 } else { 634 (poll_audio.position() * 1_000_000.0) as i64
620 0 635 } else {
621 }; 636 0
622 if let Err(e) = conn.emit_signal( 637 };
638 let jumped = match last_position {
639 Some((at, seen)) if !track_changed => {
640 let expected = if status == "Playing" {
641 seen + at.elapsed().as_micros() as i64
642 } else {
643 seen
644 };
645 (position_us - expected).abs() > 1_500_000
646 }
647 _ => false,
648 };
649 if (track_changed || jumped)
650 && let Err(e) = conn.emit_signal(
623 None::<&str>, 651 None::<&str>,
624 "/org/mpris/MediaPlayer2", 652 "/org/mpris/MediaPlayer2",
625 "org.mpris.MediaPlayer2.Player", 653 "org.mpris.MediaPlayer2.Player",
626 "Seeked", 654 "Seeked",
627 &(position_us,), 655 &(position_us,),
628 ) { 656 )
629 crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); 657 {
630 } 658 crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e));
631 } 659 }
660 last_position = Some((Instant::now(), position_us));
632 661
633 last_status = Some(status); 662 last_status = Some(status);
634 last_track = track; 663 last_track = track;