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 | |
| 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.
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | src/audio.rs | 56 | ||||
| -rw-r--r-- | src/cli.rs | 11 | ||||
| -rw-r--r-- | src/daemon.rs | 140 | ||||
| -rw-r--r-- | src/mpris.rs | 71 |
5 files changed, 245 insertions, 34 deletions
| @@ -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. | ||
| 896 | fn 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 | |||
| 868 | fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Error>> { | 924 | fn 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; |
| @@ -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`. | ||
| 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 | } | ||
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; | |||
| 22 | use std::sync::atomic::AtomicBool; | 22 | use std::sync::atomic::AtomicBool; |
| 23 | use std::sync::{Arc, Mutex, RwLock}; | 23 | use std::sync::{Arc, Mutex, RwLock}; |
| 24 | use std::thread; | 24 | use std::thread; |
| 25 | use std::time::Duration; | 25 | use std::time::{Duration, Instant}; |
| 26 | use zbus::blocking::Connection; | 26 | use zbus::blocking::Connection; |
| 27 | use zbus::interface; | 27 | use zbus::interface; |
| 28 | use zbus::zvariant::{ObjectPath, Value}; | 28 | use 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`. | ||
| 106 | const 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. |
| 106 | fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { | 110 | fn 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; |
