aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/audio.rs56
-rw-r--r--src/cli.rs11
-rw-r--r--src/daemon.rs140
-rw-r--r--src/mpris.rs71
4 files changed, 244 insertions, 34 deletions
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;