diff options
| -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; |
