aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.rs
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 /src/daemon.rs
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.
Diffstat (limited to 'src/daemon.rs')
-rw-r--r--src/daemon.rs140
1 files changed, 127 insertions, 13 deletions
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}