diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:19:49 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:19:49 +0100 |
| commit | 50cd2a17734dee4c4739112261fc40974cce409a (patch) | |
| tree | 027366c170eb8dc035c5bd24be2669c41df64f28 /src/daemon.rs | |
| parent | state: persist the queue and pick up where playback stopped (diff) | |
| download | rsplayer-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.rs | 140 |
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`. | ||
| 960 | fn 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. | ||
| 975 | fn 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` | ||
| 990 | fn 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 | |||
| 957 | fn process_command( | 1000 | fn 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)] | ||
| 1914 | mod 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 | } | ||
