From 959547cdc8d2a6e0dbba2e1483c229771d24a03c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 19:56:16 +0200 Subject: [PATCH] feat(app): media keys and desktop media widget, keep the screen on during a film - MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next, previous, stop and seeking from media keys and the panel widget, with the title, episode, duration and the cached cover as a file:// URL - the screensaver is held off while a film plays on the player page, through the desktop portal with org.freedesktop.ScreenSaver as the fallback, and SetThreadExecutionState on Windows. mpv's own stop-screensaver has no window to act for under the render API --- Cargo.lock | 128 ++++++++++++++++++++++- Cargo.toml | 9 ++ crates/pstr-app/Cargo.toml | 8 ++ crates/pstr-app/src/app.rs | 65 ++++++++++++ crates/pstr-app/src/engine.rs | 9 ++ crates/pstr-app/src/inhibit.rs | 185 +++++++++++++++++++++++++++++++++ crates/pstr-app/src/lib.rs | 2 + crates/pstr-app/src/media.rs | 170 ++++++++++++++++++++++++++++++ 8 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 crates/pstr-app/src/inhibit.rs create mode 100644 crates/pstr-app/src/media.rs diff --git a/Cargo.lock b/Cargo.lock index 5b48f33..95aacb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,6 +609,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -1029,6 +1035,36 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1136,6 +1172,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.23.2" @@ -1145,7 +1194,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1377,6 +1426,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + [[package]] name = "dbus-secret-service" version = "4.1.0" @@ -2024,6 +2082,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2031,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2045,6 +2112,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3183,6 +3256,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3503,6 +3585,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -4304,6 +4395,7 @@ name = "pstr-app" version = "0.4.2" dependencies = [ "anyhow", + "dbus", "eframe", "egui", "futures", @@ -4313,12 +4405,15 @@ dependencies = [ "pstr-meta", "pstr-player", "pstr-stream", + "raw-window-handle", "serde", "serde_json", "sha2 0.10.9", + "souvlaki", "tokio", "tracing", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] @@ -5422,6 +5517,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64 0.22.1", + "block", + "cocoa", + "core-graphics 0.22.3", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "thiserror 1.0.69", + "windows", +] + [[package]] name = "spin" version = "0.9.9" @@ -6561,6 +6674,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6933,7 +7055,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 37ea6d4..14069ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,15 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", # behind a poisoned lock for the rest of the session. Same reason # `proton-drive-linux` uses it. parking_lot = "0.12" +# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows. +# `dbus` rather than `zbus` because the keyring's Secret Service store already +# links libdbus, so this adds no second D-Bus stack. +souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] } +# Holding the screensaver off while a film plays. mpv does that for a window of +# its own, but the picture here is drawn into ours, and mpv has no window to +# hold it for. +dbus = "0.9" +windows-sys = "0.61" rpassword = "7" libloading = "0.8" lru = "0.12" diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index 09254d3..99f71ed 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -29,3 +29,11 @@ sha2.workspace = true parking_lot.workspace = true serde.workspace = true serde_json.workspace = true +souvlaki.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +dbus.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_System_Power"] } +raw-window-handle = "0.6" diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 4f3cb66..7a53fe4 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -428,6 +428,12 @@ pub struct App { crawl_progress: std::collections::BTreeMap, /// The window's size, written at exit for the next launch. window: crate::window::WindowMemory, + /// Media keys and the desktop's media widget. + media: crate::media::MediaSession, + /// The cover handed to it, worked out once per player. + media_cover: Option<(u64, Option)>, + /// Holds the screensaver off while a film is on screen and playing. + inhibitor: crate::inhibit::Inhibitor, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, @@ -494,6 +500,9 @@ impl App { crawling: false, crawl_progress: Default::default(), window, + media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)), + media_cover: None, + inhibitor: crate::inhibit::Inhibitor::new(), downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, @@ -740,6 +749,34 @@ impl App { } } + /// The playing title's artwork as a `file://` URL, if it is on disk. + /// + /// The cached file rather than the provider's URL: the desktop would + /// otherwise fetch it from the provider itself, and a desktop shell is not + /// something the viewer agreed would talk to one. + fn media_cover(&mut self) -> Option { + let playback = self.playback.as_ref()?; + if let Some((id, cover)) = &self.media_cover + && *id == playback.id + { + return cover.clone(); + } + let cover = self + .metadata + .get(&playback.target.title_key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|found| { + found + .poster_url + .as_deref() + .or_else(|| found.tile_art().map(|(url, _)| url)) + }) + .and_then(|url| self.engine.poster_file(url)) + .map(|path| format!("file://{}", path.display())); + self.media_cover = Some((playback.id, cover.clone())); + cover + } + fn share_name(&self, id: &str) -> String { self.shares .iter() @@ -1343,6 +1380,9 @@ impl eframe::App for App { self.window.observe(ctx); let mut actions: Vec = Vec::new(); + self.media.drain(self.playback.as_ref(), &mut actions); + let cover = self.media_cover(); + self.media.show(self.playback.as_ref(), cover.as_deref()); // A player page with nothing playing and nothing opening is a black // window with no way out. It should be unreachable — `pump` routes away @@ -1351,6 +1391,15 @@ impl eframe::App for App { self.page = Page::Library; } self.engine.set_picture_shown(self.page == Page::Player); + // On screen and moving: a film paused, or playing behind another page, + // is no reason to keep the screen awake. + self.inhibitor.set( + self.page == Page::Player + && self + .playback + .as_ref() + .is_some_and(|playback| playback.loaded && !playback.paused), + ); if self.page == Page::Player { self.overlay.observe(ctx); @@ -1656,6 +1705,22 @@ impl eframe::App for App { } } +/// The native window, for the parts of the OS that want it named — SMTC on +/// Windows. Nothing else asks. +#[cfg(windows)] +fn window_handle(cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> { + use raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match cc.window_handle().ok()?.as_raw() { + RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as *mut std::ffi::c_void), + _ => None, + } +} + +#[cfg(not(windows))] +fn window_handle(_cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> { + None +} + /// What the top bar says while shares are being listed: one share by name, /// several as a count, with the files found across all of them. fn crawl_label(progress: &std::collections::BTreeMap) -> String { diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index b16882e..012c740 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -2008,6 +2008,15 @@ impl Engine { }); } + /// Where a provider's picture is cached, if it is. + pub fn poster_file(&self, url: &str) -> Option { + let path = self + .dirs + .poster_cache() + .join(format!("{}.img", digest(url))); + path.is_file().then_some(path) + } + /// Fetch, decode and post one title's artwork. /// /// Cached on disk under a hash of the URL, so a provider that moves its CDN diff --git a/crates/pstr-app/src/inhibit.rs b/crates/pstr-app/src/inhibit.rs new file mode 100644 index 0000000..38a3b9d --- /dev/null +++ b/crates/pstr-app/src/inhibit.rs @@ -0,0 +1,185 @@ +//! Keeping the screen on while a film plays. +//! +//! mpv does this itself for a window of its own (`stop-screensaver`), but here +//! the picture is drawn into the app's window through the render API, and mpv +//! has no window to hold the screensaver off for. So the app asks. +//! +//! Best effort, like the media controls: a desktop that does not answer means +//! the screen may blank during a film, which is what happened before this. + +/// Holds the screensaver off while [`Inhibitor::set`] says so. +pub struct Inhibitor { + held: bool, + #[cfg(target_os = "linux")] + orders: Option>, +} + +impl Inhibitor { + pub fn new() -> Self { + Self { + held: false, + #[cfg(target_os = "linux")] + orders: linux::spawn(), + } + } + + /// Hold or release. Called every frame; acts only on a change. + pub fn set(&mut self, hold: bool) { + if hold == self.held { + return; + } + self.held = hold; + #[cfg(target_os = "linux")] + if let Some(orders) = &self.orders { + let _ = orders.send(hold); + } + #[cfg(windows)] + windows::set(hold); + } +} + +impl Default for Inhibitor { + fn default() -> Self { + Self::new() + } +} + +impl Drop for Inhibitor { + fn drop(&mut self) { + self.set(false); + } +} + +#[cfg(target_os = "linux")] +mod linux { + use std::sync::mpsc::{Sender, channel}; + use std::time::Duration; + + use dbus::Path; + use dbus::arg::PropMap; + use dbus::blocking::Connection; + + const TIMEOUT: Duration = Duration::from_secs(2); + const REASON: &str = "Playing a video"; + + /// What is holding the screensaver off, and how to let go of it. + enum Hold { + /// The desktop portal: released by closing the request it returned. + Portal(Path<'static>), + /// The older `org.freedesktop.ScreenSaver`: released by its cookie. + ScreenSaver(u32), + } + + /// A thread of its own, holding the session bus connection. The hold is + /// tied to that connection — closing it releases the hold — so it has to + /// outlive any one call, and a D-Bus call that stalls must not stall a + /// frame. + pub fn spawn() -> Option> { + let (orders, receiver) = channel::(); + std::thread::Builder::new() + .name("inhibit".into()) + .spawn(move || { + let connection = match Connection::new_session() { + Ok(connection) => connection, + Err(error) => { + tracing::debug!("no session bus to inhibit the screensaver on: {error}"); + return; + } + }; + let mut hold = None; + for wanted in receiver { + match (wanted, hold.take()) { + (true, None) => hold = acquire(&connection), + (false, Some(held)) => release(&connection, held), + (_, held) => hold = held, + } + } + if let Some(held) = hold { + release(&connection, held); + } + }) + .inspect_err(|error| tracing::debug!("inhibit thread: {error}")) + .ok()?; + Some(orders) + } + + fn acquire(connection: &Connection) -> Option { + // The portal first: it is what works under Flatpak and on GNOME, which + // does not implement the screensaver interface's `Inhibit`. + let portal = connection.with_proxy( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + TIMEOUT, + ); + let mut options = PropMap::new(); + options.insert( + "reason".into(), + dbus::arg::Variant(Box::new(REASON.to_owned())), + ); + // 8 is "idle": the screen blanking or locking for want of input. + let answer: Result<(Path<'static>,), _> = portal.method_call( + "org.freedesktop.portal.Inhibit", + "Inhibit", + ("", 8u32, options), + ); + match answer { + Ok((request,)) => return Some(Hold::Portal(request)), + Err(error) => tracing::debug!("portal inhibit: {error}"), + } + + let screensaver = connection.with_proxy( + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + TIMEOUT, + ); + let answer: Result<(u32,), _> = screensaver.method_call( + "org.freedesktop.ScreenSaver", + "Inhibit", + ("proton-stream", REASON), + ); + match answer { + Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)), + Err(error) => { + tracing::debug!("screensaver inhibit: {error}"); + None + } + } + } + + fn release(connection: &Connection, hold: Hold) { + let result: Result<(), dbus::Error> = match hold { + Hold::Portal(request) => connection + .with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT) + .method_call("org.freedesktop.portal.Request", "Close", ()), + Hold::ScreenSaver(cookie) => connection + .with_proxy( + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + TIMEOUT, + ) + .method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)), + }; + if let Err(error) = result { + tracing::debug!("release the screensaver: {error}"); + } + } +} + +#[cfg(windows)] +mod windows { + use windows_sys::Win32::System::Power::{ + ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState, + }; + + pub fn set(hold: bool) { + let flags = if hold { + ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED + } else { + ES_CONTINUOUS + }; + // SAFETY: takes flags by value and touches nothing of ours. + unsafe { + SetThreadExecutionState(flags); + } + } +} diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index 520401f..4514bd1 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -21,6 +21,8 @@ pub mod app; pub mod engine; +pub mod inhibit; +pub mod media; pub mod pacing; pub mod playback; pub mod theme; diff --git a/crates/pstr-app/src/media.rs b/crates/pstr-app/src/media.rs new file mode 100644 index 0000000..4ae5694 --- /dev/null +++ b/crates/pstr-app/src/media.rs @@ -0,0 +1,170 @@ +//! The desktop's view of what is playing: media keys, the media widget in the +//! panel, the lock screen. +//! +//! MPRIS on Linux, the System Media Transport Controls on Windows, through +//! `souvlaki`. Both are optional in the strongest sense — a session with no +//! D-Bus, or a desktop with nothing listening, plays exactly as before — so +//! every failure here is a debug line and nothing more. + +use std::sync::mpsc::{Receiver, channel}; +use std::time::Duration; + +use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, + SeekDirection, +}; + +use crate::app::{Action, Adjacent}; +use crate::playback::{Command, Playback}; + +/// How far one press of a seek key moves, when the desktop does not say. +const SEEK_SECONDS: f64 = 10.0; + +/// What was last told to the desktop, so it is told again only on a change. +#[derive(Debug, Clone, PartialEq)] +struct Told { + /// What the metadata was built from: the player, whether its duration was + /// known yet, and the cover. Any of them changing means sending it again. + item: Option<(u64, bool, Option)>, + paused: bool, + /// Where the playhead was, to the second. A desktop interpolates between + /// updates, so this only has to be sent when the playhead jumps. + second: i64, +} + +pub struct MediaSession { + controls: Option, + events: Receiver, + told: Option, +} + +impl MediaSession { + /// Register with the desktop. `window` is the handle SMTC needs on + /// Windows; elsewhere it is not used. + pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self { + let (sender, events) = channel(); + let config = PlatformConfig { + display_name: "proton-stream", + dbus_name: "proton_stream", + hwnd: window, + }; + let ctx = ctx.clone(); + let controls = MediaControls::new(config) + .and_then(|mut controls| { + controls.attach(move |event| { + // The press arrives on the D-Bus thread; it is acted on in + // the next frame, which this is what asks for. + let _ = sender.send(event); + ctx.request_repaint(); + })?; + Ok(controls) + }) + .inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}")) + .ok(); + Self { + controls, + events, + told: None, + } + } + + /// Turn the presses since the last frame into actions. + pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec) { + while let Ok(event) = self.events.try_recv() { + if let Some(action) = action_for(event, playback) { + actions.push(action); + } + } + } + + /// Tell the desktop what is playing, if that changed since the last frame. + /// + /// `cover` is a `file://` URL to the title's artwork when it is on disk. + pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) { + let Some(controls) = self.controls.as_mut() else { + return; + }; + let told = Told { + item: playback.map(|playback| { + ( + playback.id, + playback.duration.is_some(), + cover.map(str::to_owned), + ) + }), + paused: playback.is_none_or(|playback| playback.paused), + second: playback.map_or(0, |playback| playback.position as i64), + }; + let previous = self.told.replace(told.clone()); + // Within the second, or one second on from it: playback carrying on, + // which the desktop already shows without being told. + if let Some(previous) = &previous + && previous.item == told.item + && previous.paused == told.paused + && (0..=1).contains(&(told.second - previous.second)) + { + return; + } + + let Some(playback) = playback else { + if let Err(error) = controls.set_playback(MediaPlayback::Stopped) { + tracing::debug!("media controls: {error:?}"); + } + return; + }; + if previous.is_none_or(|previous| previous.item != told.item) { + let target = &playback.target; + let episode = target.caption(); + let metadata = MediaMetadata { + title: Some(if episode.is_empty() { + &target.title_name + } else { + &episode + }), + album: Some(&target.title_name), + artist: None, + cover_url: cover, + duration: playback.duration.map(Duration::from_secs_f64), + }; + if let Err(error) = controls.set_metadata(metadata) { + tracing::debug!("media controls: {error:?}"); + } + } + let progress = Some(MediaPosition(Duration::from_secs_f64( + playback.position.max(0.0), + ))); + let state = if playback.paused { + MediaPlayback::Paused { progress } + } else { + MediaPlayback::Playing { progress } + }; + if let Err(error) = controls.set_playback(state) { + tracing::debug!("media controls: {error:?}"); + } + } +} + +/// What a press means here. `None` for the ones with nothing to act on. +fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option { + let playback = playback?; + let seek = |direction: SeekDirection, seconds: f64| { + Action::Player(Command::SeekBy(match direction { + SeekDirection::Forward => seconds, + SeekDirection::Backward => -seconds, + })) + }; + Some(match event { + MediaControlEvent::Toggle => Action::Player(Command::TogglePause), + MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause), + MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause), + MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next), + MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous), + MediaControlEvent::Stop => Action::LeavePlayer, + MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS), + MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()), + MediaControlEvent::SetPosition(MediaPosition(at)) => { + Action::Player(Command::SeekTo(at.as_secs_f64())) + } + _ => return None, + }) +}