aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.rs
diff options
context:
space:
mode:
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}