From 8a0dd7615e05ba965df06ecf55c11a35f260ca6e Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 5 Oct 2026 16:09:18 +0100 Subject: [PATCH] mpris and bar control impl gitignore fix import error --- .gitignore | 1 - Cargo.lock | 102 +++- README.md | 1 + compositor/Cargo.toml | 1 + compositor/src/config.rs | 30 +- compositor/src/config/bindings.rs | 19 + compositor/src/idle_inhibition.rs | 1 + compositor/src/idle_inhibition/media.rs | 299 +++------- compositor/src/idle_inhibition/media/bus.rs | 492 +++++++++++++++ .../src/idle_inhibition/media/policy.rs | 89 +++ compositor/src/idle_inhibition/media/tests.rs | 116 ++++ compositor/src/input.rs | 5 + compositor/src/ipc.rs | 33 ++ compositor/src/state.rs | 2 + crates/ferese-ipc/Cargo.toml | 1 + crates/ferese-ipc/src/lib.rs | 1 + crates/ferese-ipc/src/media.rs | 146 +++++ crates/ferese-ipc/src/theme.rs | 22 +- .../assets/icons/status/media-next.svg | 4 + .../assets/icons/status/media-pause.svg | 3 + .../assets/icons/status/media-play.svg | 3 + .../assets/icons/status/media-previous.svg | 4 + .../assets/icons/status/media.svg | 3 + crates/ferese-theme/src/icons.rs | 5 + docs/configuration.md | 46 ++ feresectl/src/main.rs | 35 +- protocol.c | 103 ++++ protocol_client_header.c | 560 +++++++++++++++++ protocol_server_header.c | 561 ++++++++++++++++++ scripts/tests/test_media_isolated.py | 270 +++++++++ shell/ferese-shell/Cargo.toml | 4 + shell/ferese-shell/src/bar.rs | 8 +- shell/ferese-shell/src/main.rs | 59 ++ shell/ferese-shell/src/media.rs | 199 +++++++ shell/ferese-shell/src/media/art.rs | 334 +++++++++++ shell/ferese-shell/src/media/subscription.rs | 146 +++++ shell/ferese-shell/src/motion.rs | 39 +- shell/ferese-shell/src/status_ui/bar.rs | 8 +- shell/ferese-shell/src/status_ui/lifecycle.rs | 17 + shell/ferese-shell/src/status_ui/media.rs | 471 +++++++++++++++ shell/ferese-shell/src/status_ui/mod.rs | 8 +- shell/ferese-shell/src/surfaces.rs | 2 + 42 files changed, 4026 insertions(+), 227 deletions(-) create mode 100644 compositor/src/idle_inhibition/media/bus.rs create mode 100644 compositor/src/idle_inhibition/media/policy.rs create mode 100644 compositor/src/idle_inhibition/media/tests.rs create mode 100644 crates/ferese-ipc/src/media.rs create mode 100644 crates/ferese-theme/assets/icons/status/media-next.svg create mode 100644 crates/ferese-theme/assets/icons/status/media-pause.svg create mode 100644 crates/ferese-theme/assets/icons/status/media-play.svg create mode 100644 crates/ferese-theme/assets/icons/status/media-previous.svg create mode 100644 crates/ferese-theme/assets/icons/status/media.svg create mode 100644 protocol.c create mode 100644 protocol_client_header.c create mode 100644 protocol_server_header.c create mode 100644 scripts/tests/test_media_isolated.py create mode 100644 shell/ferese-shell/src/media.rs create mode 100644 shell/ferese-shell/src/media/art.rs create mode 100644 shell/ferese-shell/src/media/subscription.rs create mode 100644 shell/ferese-shell/src/status_ui/media.rs diff --git a/.gitignore b/.gitignore index 373a9c5..cbcc2ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ /target/ /tools/ *.log -ChatGPT Image*.png # Local architecture decision notes /docs/adr/ diff --git a/Cargo.lock b/Cargo.lock index e28eb68..a3e15cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,7 +902,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1648,6 +1648,7 @@ name = "ferese" version = "0.1.0" dependencies = [ "ab_glyph", + "async-channel", "calloop 0.14.4", "ferese-animation", "ferese-config", @@ -1706,6 +1707,7 @@ name = "ferese-ipc" version = "0.1.0" dependencies = [ "ferese-theme-model", + "libc", "serde", "serde_json", ] @@ -1793,6 +1795,7 @@ source = "git+https://github.com/ferese-wm/ferese-shape?tag=v0.1.0#63d7b6dfd520d name = "ferese-shell" version = "0.1.0" dependencies = [ + "base64", "ferese-animation", "ferese-config", "ferese-ipc", @@ -1800,6 +1803,7 @@ dependencies = [ "ferese-theme", "ferese-theme-client", "iced_accessibility", + "image", "jiff", "libc", "libcosmic", @@ -1808,6 +1812,8 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "ureq", + "url", "wayland-client", "zbus", ] @@ -5003,6 +5009,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.2" @@ -5105,6 +5125,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5577,6 +5632,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -6143,6 +6204,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -6499,6 +6581,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/README.md b/README.md index 52f82a0..a866daa 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Shortcuts** whenever you want to change a shortcut or gesture. | --- | --- | | Super + Enter | Open a terminal | | Display Fn key (`XF86Display`) | Choose the display mode | +| Media Play / Pause, Next, Previous | Control the selected MPRIS player | | Super + H / J / K / L | Focus left / down / up / right | | Super + Backspace | Return to the last focused window | | Super + Escape | Toggle between the last two visited workspaces on this monitor | diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 570b04a..53c091e 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -17,6 +17,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } futures-lite = "2.6.1" +async-channel = "2.5.0" libc.workspace = true jiff.workspace = true notify = "8.2.0" diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 52e8c16..fb7e6f3 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -971,13 +971,37 @@ mod tests { assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter")); } + #[test] + fn native_media_keys_share_the_selected_player_and_reject_arguments() { + let defaults = Config::default().runtime_config().unwrap(); + for (key, name, action) in [ + (keysyms::KEY_XF86AudioPlay, "media-play-pause", "play-pause"), + (keysyms::KEY_XF86AudioPause, "media-play-pause", "play-pause"), + (keysyms::KEY_XF86AudioNext, "media-next", "next"), + (keysyms::KEY_XF86AudioPrev, "media-previous", "previous"), + ] { + assert!( + defaults + .bindings + .iter() + .any(|binding| binding.trigger == BindingTrigger::Keysym(key) + && binding.action == BindingAction::Media(action)) + ); + assert_eq!( + parse_action(name, None, &HashMap::new()).unwrap(), + BindingAction::Media(action) + ); + assert!(parse_action(name, Some("unexpected"), &HashMap::new()).is_err()); + } + } + #[test] fn supplies_complete_v0_bindings_and_terminal_command() { let config = parse(""); let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 54); + assert_eq!(bindings.len(), 58); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -1021,7 +1045,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 54); + assert_eq!(bindings.len(), 58); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) @@ -1029,7 +1053,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 53); + assert_eq!(bindings.len(), 57); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 76532ff..812b12c 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -336,6 +336,14 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::ToggleOverview) } + "media-play-pause" | "media-next" | "media-previous" => { + no_argument()?; + Ok(BindingAction::Media(match action { + "media-next" => "next", + "media-previous" => "previous", + _ => "play-pause", + })) + } "toggle-display-mode" => { no_argument()?; Ok(BindingAction::ToggleDisplayMode) @@ -385,6 +393,10 @@ pub(super) fn default_bindings() -> Vec { binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), binding("XF86Display", "toggle-display-mode", None), + binding("XF86AudioPlay", "media-play-pause", None), + binding("XF86AudioPause", "media-play-pause", None), + binding("XF86AudioNext", "media-next", None), + binding("XF86AudioPrev", "media-previous", None), binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+BackSpace", "focus-last-window", None), @@ -473,6 +485,7 @@ pub enum BindingAction { ToggleOverview, ToggleKeybindingGuide, ToggleDisplayMode, + Media(&'static str), } impl Binding { @@ -581,6 +594,12 @@ impl Binding { BindingAction::Consume => "Join window into column".into(), BindingAction::Expel => "Move window out of column".into(), BindingAction::ToggleFloating => "Toggle floating window".into(), + BindingAction::Media(action) => match *action { + "next" => "Next track", + "previous" => "Previous track", + _ => "Play or pause media", + } + .into(), BindingAction::ToggleOverview => "Open or close overview".into(), BindingAction::ToggleDisplayMode => "Open display mode chooser".into(), BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), diff --git a/compositor/src/idle_inhibition.rs b/compositor/src/idle_inhibition.rs index 55fc5ad..7b97192 100644 --- a/compositor/src/idle_inhibition.rs +++ b/compositor/src/idle_inhibition.rs @@ -224,6 +224,7 @@ mod tests { pid: Some(42), desktop_entry: Some("player".into()), playing: true, + ..Default::default() }; let window = AppWindow { id: WindowId(1), diff --git a/compositor/src/idle_inhibition/media.rs b/compositor/src/idle_inhibition/media.rs index d009b81..ec06741 100644 --- a/compositor/src/idle_inhibition/media.rs +++ b/compositor/src/idle_inhibition/media.rs @@ -1,245 +1,124 @@ -//! MPRIS reads and signal handling run off the compositor thread, without polling. +mod bus; +mod policy; + use std::collections::HashMap; -use std::time::Duration; +use std::sync::mpsc; +use std::time::Instant; use calloop::EventLoop; -use calloop::channel::{Event, channel}; -use futures_lite::{StreamExt, future, stream}; -use zbus::zvariant::OwnedValue; -use zbus::{Connection, MatchRule, MessageStream}; +use calloop::channel::{Event, sync_channel}; +use ferese_ipc::{Response, media::Snapshot}; +use serde_json::Value; use crate::Ferese; -const PREFIX: &str = "org.mpris.MediaPlayer2"; -const PATH: &str = "/org/mpris/MediaPlayer2"; -const PLAYER: &str = "org.mpris.MediaPlayer2.Player"; -const PROPERTIES: &str = "org.freedesktop.DBus.Properties"; -const MAX_PLAYERS: usize = 64; - -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Player { pub name: String, pub owner: String, pub pid: Option, pub desktop_entry: Option, pub playing: bool, + pub view: ferese_ipc::media::Player, + pub(super) started: u64, + pub(super) active: u64, } -async fn bus_call( - connection: &Connection, - member: &str, - name: &str, -) -> zbus::Result { - connection - .call_method( - Some("org.freedesktop.DBus"), - "/org/freedesktop/DBus", - Some("org.freedesktop.DBus"), - member, - &(name,), - ) - .await? - .body() - .deserialize() +pub(crate) struct Command { + args: Value, + sent: Instant, + reply: Option<(u64, mpsc::SyncSender)>, } -async fn property(connection: &Connection, owner: &str, interface: &str, name: &str) -> zbus::Result { - connection - .call_method(Some(owner), PATH, Some(PROPERTIES), "Get", &(interface, name)) - .await? - .body() - .deserialize() +struct Update { + players: Vec, + snapshot: Snapshot, } -async fn refresh_player(connection: &Connection, player: &mut Player, identity: bool) { - // Bind reads to the unique owner, never a name that can change mid-query. - player.playing = property(connection, &player.owner, PLAYER, "PlaybackStatus") - .await - .ok() - .and_then(|value| String::try_from(value).ok()) - .is_some_and(|status| status == "Playing"); - - if identity { - player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner) - .await - .ok(); - player.desktop_entry = property(connection, &player.owner, PREFIX, "DesktopEntry") - .await - .ok() - .and_then(|value| String::try_from(value).ok()) - .map(|value| crate::window_rules::normalize_app_id(&value)) - .filter(|value| !value.is_empty()); - } +fn inhibition_changed(before: &[Player], after: &[Player]) -> bool { + before.len() != after.len() + || before.iter().zip(after).any(|(a, b)| { + (&a.name, &a.owner, a.pid, &a.desktop_entry, a.playing) + != (&b.name, &b.owner, b.pid, &b.desktop_entry, b.playing) + }) } -fn publish( - players: &HashMap, - previous: &mut Vec, - send: &mut impl FnMut(Vec) -> bool, -) -> bool { - let mut snapshot: Vec<_> = players.values().cloned().collect(); - snapshot.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - if snapshot == *previous { - return true; - } - - *previous = snapshot.clone(); - send(snapshot) +#[derive(Default)] +pub(crate) struct Engine { + snapshot: Snapshot, + value: Value, + waiters: HashMap)>, + commands: Option>, } -async fn monitor(connection: Connection, mut send: impl FnMut(Vec) -> bool) -> zbus::Result<()> { - // Subscribe before discovery so startup and owner replacement cannot lose - // a pause/stop event. Ignore unrelated property traffic such as position. - let properties = MatchRule::builder() - .msg_type(zbus::message::Type::Signal) - .path(PATH)? - .interface(PROPERTIES)? - .member("PropertiesChanged")? - .build(); - let owners = MatchRule::builder() - .msg_type(zbus::message::Type::Signal) - .sender("org.freedesktop.DBus")? - .interface("org.freedesktop.DBus")? - .member("NameOwnerChanged")? - .arg0ns(PREFIX)? - .build(); - let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?; - let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?; - let mut signals = stream::or(properties, owners); - let mut players = HashMap::new(); - let mut previous = Vec::new(); - let names: Vec = connection - .call_method( - Some("org.freedesktop.DBus"), - "/org/freedesktop/DBus", - Some("org.freedesktop.DBus"), - "ListNames", - &(), - ) - .await? - .body() - .deserialize()?; - let mut names: Vec<_> = names - .into_iter() - .filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.'))) - .collect(); - names.sort_unstable(); - for name in names.into_iter().take(MAX_PLAYERS) { - if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await { - let mut player = Player { - name: name.clone(), - owner, - pid: None, - desktop_entry: None, - playing: false, - }; - refresh_player(&connection, &mut player, true).await; - players.insert(name, player); - } - } - - if !publish(&players, &mut previous, &mut send) { - return Ok(()); - } - while let Some(message) = signals.next().await { - let message = message?; - let header = message.header(); - if header - .member() - .is_some_and(|member| member.as_str() == "NameOwnerChanged") - { - let (name, _, owner): (String, String, String) = message.body().deserialize()?; - // Remove the old owner's state before looking up the replacement. - players.remove(&name); - if !publish(&players, &mut previous, &mut send) { - return Ok(()); - } - - if !owner.is_empty() && players.len() < MAX_PLAYERS { - let mut player = Player { - name: name.clone(), - owner, - pid: None, - desktop_entry: None, - playing: false, - }; - refresh_player(&connection, &mut player, true).await; - players.insert(name, player); - } +impl Engine { + pub(crate) fn value(&self) -> Value { + if self.value.is_null() { + serde_json::to_value(&self.snapshot).expect("serializable media snapshot") } else { - let Ok((interface, changed, invalidated)) = - message - .body() - .deserialize::<(String, HashMap, Vec)>() - else { - continue; - }; - let identity = interface == PREFIX - && (changed.contains_key("DesktopEntry") || invalidated.iter().any(|field| field == "DesktopEntry")); - let status = interface == PLAYER - && (changed.contains_key("PlaybackStatus") - || invalidated.iter().any(|field| field == "PlaybackStatus")); - if !identity && !status { - continue; - } - - let Some(owner) = header.sender() else { - continue; - }; - for player in players.values_mut().filter(|player| player.owner == owner.as_str()) { - // Re-read current state: a signal queued during discovery may - // describe older playback than the startup snapshot. - refresh_player(&connection, player, identity).await; - } - } - - if !publish(&players, &mut previous, &mut send) { - return Ok(()); + self.value.clone() } } - Err(zbus::Error::Failure("MPRIS signal connection ended".into())) + fn update(&mut self, mut snapshot: Snapshot) { + snapshot.revision = self.snapshot.revision; + if snapshot == self.snapshot { + return; + } + + snapshot.revision = snapshot.revision.wrapping_add(1); + self.value = serde_json::to_value(&snapshot).expect("serializable media snapshot"); + self.snapshot = snapshot; + for (_, (id, response)) in self.waiters.drain() { + let _ = response.try_send(Response::success(id, self.value.clone())); + } + } + + pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender) { + if since != self.snapshot.revision { + let _ = response.try_send(Response::success(id, self.value())); + } else { + self.waiters.insert(owner, (id, response)); + } + } + + pub(crate) fn remove(&mut self, owner: u64) { + self.waiters.remove(&owner); + } + + pub(crate) fn action(&self, args: Value, reply: Option<(u64, mpsc::SyncSender)>) -> Result<(), String> { + let commands = self.commands.as_ref().ok_or("Media service is unavailable")?; + commands + .try_send(Command { + args, + sent: Instant::now(), + reply, + }) + .map_err(|_| "Media command queue is unavailable or full".into()) + } } pub(crate) fn init(event_loop: &mut EventLoop) -> Result<(), Box> { - let (sender, receiver) = channel(); - event_loop.handle().insert_source(receiver, |event, _, state| { - if let Event::Msg(players) = event { - state.media_players = players; - state.refresh_idle_inhibition(); + let (sender, receiver) = sync_channel::(1); + let (commands, receive_commands) = async_channel::bounded(16); + event_loop.handle().insert_source(receiver, move |event, _, state| { + if let Event::Msg(update) = event { + let changed = inhibition_changed(&state.media_players, &update.players); + state.media_players = update.players; + state.media_engine.update(update.snapshot); + if changed { + state.refresh_idle_inhibition(); + } } })?; - + event_loop + .handle() + .insert_idle(move |state| state.media_engine.commands = Some(commands)); std::thread::Builder::new() .name("ferese-playback".into()) - .spawn(move || { - let mut had_players = false; - loop { - let result = future::block_on(async { - let connection = zbus::connection::Builder::session()? - .method_timeout(Duration::from_millis(500)) - .build() - .await?; - monitor(connection, |players| { - had_players = !players.is_empty(); - sender.send(players).is_ok() - }) - .await - }); - match result { - Ok(()) => break, - Err(error) => { - tracing::debug!(%error, "playback monitor disconnected; releasing automatic inhibition") - } - } - - if had_players && sender.send(Vec::new()).is_err() { - break; - } - had_players = false; - // Reconnect only after failure; healthy operation sleeps on signals. - std::thread::sleep(Duration::from_secs(5)); - } - })?; + .spawn(move || bus::run(sender, receive_commands))?; Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/compositor/src/idle_inhibition/media/bus.rs b/compositor/src/idle_inhibition/media/bus.rs new file mode 100644 index 0000000..f00143c --- /dev/null +++ b/compositor/src/idle_inhibition/media/bus.rs @@ -0,0 +1,492 @@ +use std::collections::HashMap; +use std::time::Duration; + +use calloop::channel::SyncSender; +use ferese_ipc::{ + Response, + media::{Playback, now_us}, +}; +use futures_lite::{StreamExt, future, stream}; +use zbus::zvariant::{Array, OwnedObjectPath, OwnedValue, Value}; +use zbus::{Connection, MatchRule, MessageStream}; + +use super::policy::PlayersState; +use super::{Command, Player, Update}; + +const PREFIX: &str = "org.mpris.MediaPlayer2"; +const PATH: &str = "/org/mpris/MediaPlayer2"; +const PLAYER: &str = "org.mpris.MediaPlayer2.Player"; +const PROPERTIES: &str = "org.freedesktop.DBus.Properties"; +const MAX_PLAYERS: usize = 64; +type Properties = HashMap; + +async fn bus_call( + connection: &Connection, + member: &str, + name: &str, +) -> zbus::Result { + connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + member, + &(name,), + ) + .await? + .body() + .deserialize() +} + +async fn properties(connection: &Connection, owner: &str, interface: &str) -> zbus::Result { + connection + .call_method(Some(owner), PATH, Some(PROPERTIES), "GetAll", &(interface,)) + .await? + .body() + .deserialize() +} + +fn string<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> { + properties.get(name).and_then(|value| <&str>::try_from(value).ok()) +} + +pub(super) fn clean(value: &str, length: usize) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .take(length) + .collect::() + .trim() + .to_owned() +} + +fn boolean(properties: &Properties, name: &str) -> bool { + properties + .get(name) + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false) +} + +fn integer(properties: &Properties, name: &str) -> Option { + properties + .get(name) + .and_then(|value| i64::try_from(value).ok()) + .and_then(|value| value.try_into().ok()) +} + +fn number(properties: &Properties, name: &str) -> Option { + properties + .get(name) + .and_then(|value| f64::try_from(value).ok()) + .filter(|value| value.is_finite()) +} + +async fn refresh(connection: &Connection, player: &mut Player, identity: bool) -> zbus::Result<()> { + let mut fields = properties(connection, &player.owner, PLAYER).await?; + let view = &mut player.view; + view.status = match string(&fields, "PlaybackStatus") { + Some("Playing") => Playback::Playing, + Some("Paused") => Playback::Paused, + _ => Playback::Stopped, + }; + player.playing = view.status == Playback::Playing; + view.position_us = integer(&fields, "Position"); + view.sampled_at_us = now_us(); + view.rate = number(&fields, "Rate") + .filter(|rate| *rate != 0.0 && rate.abs() <= 100.0) + .unwrap_or(1.0); + view.volume = number(&fields, "Volume").filter(|volume| *volume >= 0.0); + view.can_control = boolean(&fields, "CanControl"); + view.can_play = boolean(&fields, "CanPlay"); + view.can_pause = boolean(&fields, "CanPause"); + view.can_next = boolean(&fields, "CanGoNext"); + view.can_previous = boolean(&fields, "CanGoPrevious"); + view.can_seek = boolean(&fields, "CanSeek"); + let metadata = fields + .remove("Metadata") + .and_then(|value| Properties::try_from(value).ok()) + .unwrap_or_default(); + view.title = clean(string(&metadata, "xesam:title").unwrap_or_default(), 256); + view.album = clean(string(&metadata, "xesam:album").unwrap_or_default(), 256); + view.artist = metadata + .get("xesam:artist") + .and_then(|value| <&Array>::try_from(value).ok()) + .map(|array| { + array + .inner() + .iter() + .take(8) + .filter_map(|value| <&str>::try_from(value).ok()) + .map(|artist| clean(artist, 64)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + view.length_us = integer(&metadata, "mpris:length").filter(|length| *length > 0); + view.track_id = metadata + .get("mpris:trackid") + .and_then(|value| <&zbus::zvariant::ObjectPath>::try_from(value).ok()) + .map(|path| path.to_string()); + view.art_url = string(&metadata, "mpris:artUrl") + .filter(|url| !url.is_empty() && url.len() <= 256 * 1024) + .map(str::to_owned); + + if identity || player.pid.is_none() { + player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner) + .await + .ok(); + if let Ok(root) = properties(connection, &player.owner, PREFIX).await { + view.identity = clean(string(&root, "Identity").unwrap_or(&player.name), 80); + view.can_raise = boolean(&root, "CanRaise"); + player.desktop_entry = string(&root, "DesktopEntry") + .map(crate::window_rules::normalize_app_id) + .filter(|value| !value.is_empty()); + } + } + + Ok(()) +} + +async fn discover(connection: &Connection, name: String, owner: String) -> Player { + let mut player = Player { + view: ferese_ipc::media::Player { + name: name.clone(), + owner: owner.clone(), + identity: clean(&name, 80), + ..Default::default() + }, + name, + owner, + ..Default::default() + }; + let _ = refresh(connection, &mut player, true).await; + player +} + +async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_json::Value) -> Result<(), String> { + let kind = args["action"].as_str().ok_or("Missing media action")?; + if kind == "auto" { + state.pinned = None; + return Ok(()); + } + + let player = match args["player"].as_str() { + Some(name) => state.players.get(name), + None => state.selected(), + } + .cloned() + .ok_or("No active media player")?; + if args["owner"].as_str().is_some_and(|owner| owner != player.owner) { + return Err("Media player was replaced".into()); + } + + match kind { + "pin" => { + state.ignored.remove(&player.name); + state.pinned = Some(player.name); + return Ok(()); + } + "ignore" => { + let ignored = args["ignored"].as_bool().ok_or("Missing ignore state")?; + if ignored { + state.ignored.insert(player.name.clone()); + } else { + state.ignored.remove(&player.name); + } + if ignored && state.pinned.as_deref() == Some(&player.name) { + state.pinned = None; + } + return Ok(()); + } + _ => (), + } + + let view = &player.view; + let supported = match kind { + "play-pause" => view.can_toggle(), + "next" => view.can_control && view.can_next, + "previous" => view.can_control && view.can_previous, + "raise" => view.can_raise, + "seek" => view.can_control && view.can_seek && view.track_id.is_some(), + "volume" => view.can_control && view.volume.is_some(), + _ => false, + }; + if !supported { + return Err("Media action is unavailable for this player".into()); + } + + let method = match kind { + "play-pause" => "PlayPause", + "next" => "Next", + "previous" => "Previous", + "raise" => "Raise", + _ => "", + }; + let response = match kind { + "seek" => { + if args["track_id"].as_str() != view.track_id.as_deref() { + return Err("Media track changed".into()); + } + let position = args["position_us"].as_u64().ok_or("Invalid media position")?; + let position = view + .length_us + .map_or(position, |length| position.min(length)) + .min(i64::MAX as u64) as i64; + let track = OwnedObjectPath::try_from(view.track_id.clone().unwrap()).map_err(|error| error.to_string())?; + connection + .call_method( + Some(player.owner.as_str()), + PATH, + Some(PLAYER), + "SetPosition", + &(track, position), + ) + .await + } + "volume" => { + let delta = args["delta"] + .as_f64() + .filter(|delta| delta.is_finite() && delta.abs() <= 1.0) + .ok_or("Invalid volume change")?; + let volume = (view.volume.unwrap() + delta).clamp(0.0, 1.0); + connection + .call_method( + Some(player.owner.as_str()), + PATH, + Some(PROPERTIES), + "Set", + &(PLAYER, "Volume", Value::F64(volume)), + ) + .await + } + _ => { + connection + .call_method( + Some(player.owner.as_str()), + PATH, + Some(if kind == "raise" { PREFIX } else { PLAYER }), + method, + &(), + ) + .await + } + }; + response.map_err(|error| error.to_string())?; + let mut player = player; + if refresh(connection, &mut player, false).await.is_ok() { + state.update(player); + } + Ok(()) +} + +fn publish(state: &PlayersState, previous: &mut Option, sender: &SyncSender) -> bool { + let update = state.update_snapshot(); + if previous + .as_ref() + .is_some_and(|old| old.players == update.players && old.snapshot == update.snapshot) + { + return true; + } + *previous = Some(Update { + players: update.players.clone(), + snapshot: update.snapshot.clone(), + }); + sender.send(update).is_ok() +} + +async fn monitor( + connection: Connection, + commands: &async_channel::Receiver, + sender: &SyncSender, +) -> zbus::Result<()> { + let properties = MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .path(PATH)? + .interface(PROPERTIES)? + .member("PropertiesChanged")? + .build(); + let owners = MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .arg0ns(PREFIX)? + .build(); + let seeks = MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .path(PATH)? + .interface(PLAYER)? + .member("Seeked")? + .build(); + let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?; + let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?; + let seeks = MessageStream::for_match_rule(seeks, &connection, Some(64)).await?; + let mut signals = stream::or(stream::or(properties, owners), seeks); + let mut state = PlayersState::default(); + let names: Vec = connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .await? + .body() + .deserialize()?; + let mut names: Vec<_> = names + .into_iter() + .filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.'))) + .collect(); + names.sort_unstable(); + for name in names.into_iter().take(MAX_PLAYERS) { + if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await { + let player = discover(&connection, name.clone(), owner).await; + // Existing players have no known start order; bus names break ties. + state.players.insert(name, player); + } + } + + let mut previous = None; + if !publish(&state, &mut previous, sender) { + return Ok(()); + } + enum Input { + Signal(Option>), + Action(Option), + } + loop { + let input = match commands.try_recv() { + Ok(command) => Input::Action(Some(command)), + Err(_) => { + future::or(async { Input::Signal(signals.next().await) }, async { + Input::Action(commands.recv().await.ok()) + }) + .await + } + }; + match input { + Input::Action(None) => return Ok(()), + Input::Signal(None) => return Err(zbus::Error::Failure("MPRIS signal stream ended".into())), + Input::Action(Some(command)) => { + let result = if command.sent.elapsed() > Duration::from_secs(2) { + Err("Media command expired".into()) + } else { + action(&connection, &mut state, &command.args).await + }; + if let Some((id, response)) = command.reply { + let reply = match result { + Ok(()) => Response::success(id, serde_json::json!({})), + Err(error) => Response::error(id, "media_unavailable", error), + }; + let _ = response.try_send(reply); + } + } + Input::Signal(Some(message)) => { + let message = message?; + let header = message.header(); + if header + .member() + .is_some_and(|member| member.as_str() == "NameOwnerChanged") + { + let (name, _, owner): (String, String, String) = message.body().deserialize()?; + if !name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.')) { + continue; + } + state.remove(&name); + if !publish(&state, &mut previous, sender) { + return Ok(()); + } + if !owner.is_empty() && state.players.len() < MAX_PLAYERS { + let player = discover(&connection, name, owner).await; + state.update(player); + } + } else if let Some(owner) = header.sender() { + if header.member().is_some_and(|member| member.as_str() == "Seeked") { + let names: Vec<_> = state + .players + .values() + .filter(|player| player.owner == owner.as_str()) + .map(|player| player.name.clone()) + .collect(); + for name in names { + let mut player = state.players[&name].clone(); + if refresh(&connection, &mut player, false).await.is_ok() { + state.update(player); + } + } + } else { + let Ok((interface, changed, invalidated)) = + message.body().deserialize::<(String, Properties, Vec)>() + else { + continue; + }; + let identity = interface == PREFIX + && ["Identity", "DesktopEntry", "CanRaise"] + .iter() + .any(|key| changed.contains_key(*key) || invalidated.iter().any(|field| field == key)); + let playback = interface == PLAYER + && [ + "PlaybackStatus", + "Metadata", + "Rate", + "Volume", + "CanGoNext", + "CanGoPrevious", + "CanPlay", + "CanPause", + "CanSeek", + "CanControl", + ] + .iter() + .any(|key| changed.contains_key(*key) || invalidated.iter().any(|field| field == key)); + if !identity && !playback { + continue; + } + let names: Vec<_> = state + .players + .values() + .filter(|player| player.owner == owner.as_str()) + .map(|player| player.name.clone()) + .collect(); + for name in names { + let mut player = state.players[&name].clone(); + // Re-read after a signal so queued startup messages cannot regress state. + if refresh(&connection, &mut player, identity).await.is_ok() { + state.update(player); + } else { + player.playing = false; + player.view.status = Playback::Stopped; + state.update(player); + } + } + } + } + } + } + + if !publish(&state, &mut previous, sender) { + return Ok(()); + } + } +} + +pub(super) fn run(sender: SyncSender, commands: async_channel::Receiver) { + loop { + let result = future::block_on(async { + let connection = zbus::connection::Builder::session()? + .method_timeout(Duration::from_millis(500)) + .build() + .await?; + monitor(connection, &commands, &sender).await + }); + if matches!(result, Ok(())) || commands.is_closed() { + break; + } + if sender.send(PlayersState::default().update_snapshot()).is_err() { + break; + } + tracing::debug!(?result, "MPRIS disconnected; clearing playback and retrying"); + std::thread::sleep(Duration::from_secs(5)); + } +} diff --git a/compositor/src/idle_inhibition/media/policy.rs b/compositor/src/idle_inhibition/media/policy.rs new file mode 100644 index 0000000..b2f35aa --- /dev/null +++ b/compositor/src/idle_inhibition/media/policy.rs @@ -0,0 +1,89 @@ +use std::collections::{BTreeMap, HashSet}; + +use ferese_ipc::media::{Choice, Playback, Snapshot}; + +use super::{Player, Update}; + +#[derive(Default)] +pub(super) struct PlayersState { + pub players: BTreeMap, + pub pinned: Option, + pub ignored: HashSet, + activity: u64, +} + +impl PlayersState { + pub fn update(&mut self, mut player: Player) { + if let Some(old) = self.players.get(&player.name) { + player.started = old.started; + player.active = old.active; + if player.view.status != old.view.status + || player.view.track_id != old.view.track_id + || player.view.title != old.view.title + || player.view.artist != old.view.artist + { + self.activity += 1; + player.active = self.activity; + if player.playing && !old.playing { + player.started = self.activity; + } + } + } else { + self.activity += 1; + player.active = self.activity; + if player.playing { + player.started = self.activity; + } + } + self.players.insert(player.name.clone(), player); + } + + pub fn remove(&mut self, name: &str) { + self.players.remove(name); + self.ignored.remove(name); + if self.pinned.as_deref() == Some(name) { + self.pinned = None; + } + } + + pub fn selected(&self) -> Option<&Player> { + let eligible = + |player: &&Player| player.view.status != Playback::Stopped && !self.ignored.contains(&player.name); + if let Some(player) = self + .pinned + .as_ref() + .and_then(|name| self.players.get(name)) + .filter(eligible) + { + return Some(player); + } + + self.players.values().filter(eligible).max_by(|a, b| { + (a.playing, if a.playing { a.started } else { a.active }) + .cmp(&(b.playing, if b.playing { b.started } else { b.active })) + .then_with(|| b.name.cmp(&a.name)) + }) + } + + pub fn update_snapshot(&self) -> Update { + Update { + players: self.players.values().cloned().collect(), + snapshot: Snapshot { + revision: 0, + selected: self.selected().map(|player| player.view.clone()), + pinned: self.pinned.clone(), + players: self + .players + .values() + .map(|player| Choice { + name: player.name.clone(), + owner: player.owner.clone(), + identity: player.view.identity.clone(), + status: player.view.status, + ignored: self.ignored.contains(&player.name), + }) + .collect(), + }, + } + } +} diff --git a/compositor/src/idle_inhibition/media/tests.rs b/compositor/src/idle_inhibition/media/tests.rs new file mode 100644 index 0000000..b967bef --- /dev/null +++ b/compositor/src/idle_inhibition/media/tests.rs @@ -0,0 +1,116 @@ +use super::*; +use ferese_ipc::media::Playback; +use policy::PlayersState; + +fn player(name: &str, status: Playback) -> Player { + Player { + name: name.into(), + owner: format!(":{name}"), + playing: status == Playback::Playing, + view: ferese_ipc::media::Player { + name: name.into(), + status, + ..Default::default() + }, + ..Default::default() + } +} + +#[test] +fn selection_tracks_starts_not_metadata_or_volume_changes() { + let mut state = PlayersState::default(); + state.update(player("first", Playback::Playing)); + state.update(player("second", Playback::Playing)); + let mut first = state.players["first"].clone(); + first.view.title = "New track".into(); + first.view.volume = Some(0.5); + state.update(first); + assert_eq!(state.selected().unwrap().name, "second"); + + state.update(player("second", Playback::Paused)); + assert_eq!(state.selected().unwrap().name, "first"); + state.update(player("first", Playback::Paused)); + assert_eq!(state.selected().unwrap().name, "first"); + state.update(player("second", Playback::Playing)); + assert_eq!(state.selected().unwrap().name, "second"); +} + +#[test] +fn pin_ignore_and_departure_do_not_leave_a_ghost_player() { + let mut state = PlayersState::default(); + state.update(player("first", Playback::Paused)); + state.update(player("second", Playback::Playing)); + state.pinned = Some("first".into()); + assert_eq!(state.selected().unwrap().name, "first"); + state.ignored.insert("first".into()); + assert_eq!(state.selected().unwrap().name, "second"); + state.remove("first"); + assert!(state.pinned.is_none()); + assert!(state.ignored.is_empty()); + state.remove("second"); + assert!(state.selected().is_none()); +} + +#[test] +fn initial_ties_are_deterministic_and_stopped_players_are_hidden() { + let mut state = PlayersState::default(); + for name in ["z", "a"] { + state.players.insert(name.into(), player(name, Playback::Playing)); + } + assert_eq!(state.selected().unwrap().name, "a"); + state.update(player("a", Playback::Stopped)); + state.update(player("z", Playback::Stopped)); + assert!(state.selected().is_none()); +} + +#[test] +fn watch_waits_for_changes_deduplicates_and_drops_disconnected_clients() { + let mut engine = Engine::default(); + let (send, receive) = mpsc::sync_channel(1); + engine.watch(1, 42, 0, send); + engine.update(Snapshot::default()); + assert!(receive.try_recv().is_err()); + let mut state = PlayersState::default(); + state.update(player("first", Playback::Playing)); + engine.update(state.update_snapshot().snapshot); + let response = receive.try_recv().unwrap(); + assert_eq!(response.id, 42); + assert_eq!(response.result.unwrap()["revision"], 1); + let (send, receive) = mpsc::sync_channel(1); + engine.watch(2, 43, 1, send); + engine.remove(2); + state.remove("first"); + engine.update(state.update_snapshot().snapshot); + assert!(receive.try_recv().is_err()); +} + +#[test] +fn metadata_is_bounded_and_control_characters_are_removed() { + assert_eq!(bus::clean(" Track\nname\0 ", 80), "Trackname"); + assert_eq!(bus::clean(&"é".repeat(500), 80).chars().count(), 80); +} + +#[test] +fn metadata_and_position_updates_do_not_recompute_idle_policy() { + let before = player("first", Playback::Playing); + let mut after = before.clone(); + after.view.title = "New track".into(); + after.view.position_us = Some(10); + after.view.volume = Some(0.8); + assert!(!inhibition_changed( + std::slice::from_ref(&before), + std::slice::from_ref(&after) + )); + after.playing = false; + assert!(inhibition_changed( + std::slice::from_ref(&before), + std::slice::from_ref(&after) + )); + after.playing = true; + after.pid = Some(42); + assert!(inhibition_changed( + std::slice::from_ref(&before), + std::slice::from_ref(&after) + )); + assert!(inhibition_changed(std::slice::from_ref(&before), &[])); +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index f3cc216..07fc741 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1098,6 +1098,11 @@ impl Ferese { BindingAction::Expel => self.expel_focused_window(), BindingAction::ToggleFloating => self.toggle_focused_floating(), BindingAction::ToggleOverview => self.toggle_overview(), + BindingAction::Media(action) => { + if let Err(error) = self.media_engine.action(serde_json::json!({"action": action}), None) { + tracing::debug!(%error, "media action unavailable"); + } + } BindingAction::ToggleDisplayMode => { self.toggle_display_mode(); } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index b7aefa1..61322f1 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -121,6 +121,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result) -> Result self.close_focused_window(), "get-focused-window" => return Ok(self.focused_window_json()), "get-windows" => return Ok(self.windows_json()), + "media-get" => return Ok(self.media_engine.value()), "get-idle-inhibition" => { return Ok(json!({ "inhibited": self.idle_notifier_state.is_inhibited(), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 20f10c6..73c9b9c 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -474,6 +474,7 @@ pub struct Ferese { pub idle_inhibitors: HashMap, pub(crate) idle_inhibit: crate::idle_inhibition::Settings, pub(crate) media_players: Vec, + pub(crate) media_engine: crate::idle_inhibition::media::Engine, pub(crate) automatic_idle_inhibited: bool, pub active_shortcuts_inhibitor: Option, pub direct_backend: Option, @@ -719,6 +720,7 @@ impl Ferese { idle_inhibitors: HashMap::new(), idle_inhibit: config.idle_inhibit, media_players: Vec::new(), + media_engine: Default::default(), automatic_idle_inhibited: false, active_shortcuts_inhibitor: None, direct_backend: None, diff --git a/crates/ferese-ipc/Cargo.toml b/crates/ferese-ipc/Cargo.toml index fa1f7c0..6047d42 100644 --- a/crates/ferese-ipc/Cargo.toml +++ b/crates/ferese-ipc/Cargo.toml @@ -8,4 +8,5 @@ rust-version.workspace = true [dependencies] serde.workspace = true serde_json.workspace = true +libc.workspace = true ferese-theme-model = { path = "../ferese-theme-model" } diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs index 7c92826..78fe0f3 100644 --- a/crates/ferese-ipc/src/lib.rs +++ b/crates/ferese-ipc/src/lib.rs @@ -5,6 +5,7 @@ use std::{fmt, io}; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub mod media; pub mod theme; pub const VERSION: u32 = 1; diff --git a/crates/ferese-ipc/src/media.rs b/crates/ferese-ipc/src/media.rs new file mode 100644 index 0000000..ddaad6b --- /dev/null +++ b/crates/ferese-ipc/src/media.rs @@ -0,0 +1,146 @@ +use serde::{Deserialize, Serialize}; + +pub fn now_us() -> u64 { + let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // CLOCK_MONOTONIC shares one epoch between the compositor and shell. + if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut time) } != 0 { + return 0; + } + + (time.tv_sec.max(0) as u64) + .saturating_mul(1_000_000) + .saturating_add(time.tv_nsec.max(0) as u64 / 1_000) +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Playback { + Playing, + Paused, + #[default] + Stopped, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct Player { + pub name: String, + pub owner: String, + pub identity: String, + pub title: String, + pub artist: String, + pub album: String, + pub art_url: Option, + pub track_id: Option, + pub status: Playback, + pub position_us: Option, + pub sampled_at_us: u64, + pub length_us: Option, + pub rate: f64, + pub volume: Option, + pub can_control: bool, + pub can_play: bool, + pub can_pause: bool, + pub can_next: bool, + pub can_previous: bool, + pub can_seek: bool, + pub can_raise: bool, +} + +impl Default for Player { + fn default() -> Self { + Self { + name: String::new(), + owner: String::new(), + identity: String::new(), + title: String::new(), + artist: String::new(), + album: String::new(), + art_url: None, + track_id: None, + status: Playback::Stopped, + position_us: None, + sampled_at_us: 0, + length_us: None, + rate: 1.0, + volume: None, + can_control: false, + can_play: false, + can_pause: false, + can_next: false, + can_previous: false, + can_seek: false, + can_raise: false, + } + } +} + +impl Player { + pub fn label(&self) -> &str { + if self.title.is_empty() { + &self.identity + } else { + &self.title + } + } + + pub fn can_toggle(&self) -> bool { + self.can_control + && if self.status == Playback::Playing { + self.can_pause + } else { + self.can_play + } + } + + pub fn position_at(&self, now_us: u64) -> Option { + let mut position = self.position_us? as f64; + if self.status == Playback::Playing && self.rate.is_finite() { + position += now_us.saturating_sub(self.sampled_at_us) as f64 * self.rate; + } + + let position = position.max(0.0) as u64; + Some(self.length_us.map_or(position, |length| position.min(length))) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Choice { + pub name: String, + pub owner: String, + pub identity: String, + pub status: Playback, + pub ignored: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct Snapshot { + pub revision: u64, + pub selected: Option, + pub players: Vec, + pub pinned: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn position_uses_rate_and_monotonic_sample_without_polling() { + let mut player = Player { + status: Playback::Playing, + position_us: Some(2_000_000), + sampled_at_us: 10_000_000, + length_us: Some(8_000_000), + rate: 1.5, + ..Player::default() + }; + assert_eq!(player.position_at(12_000_000), Some(5_000_000)); + assert_eq!(player.position_at(20_000_000), Some(8_000_000)); + assert_eq!(player.position_at(9_000_000), Some(2_000_000)); + player.status = Playback::Paused; + assert_eq!(player.position_at(20_000_000), Some(2_000_000)); + player.position_us = None; + assert_eq!(player.position_at(20_000_000), None); + } +} diff --git a/crates/ferese-ipc/src/theme.rs b/crates/ferese-ipc/src/theme.rs index e156f51..0b2ffbc 100644 --- a/crates/ferese-ipc/src/theme.rs +++ b/crates/ferese-ipc/src/theme.rs @@ -78,9 +78,16 @@ impl Connection { command: command.into(), args, }; - write_frame(&mut self.stream, &request).map_err(|e| e.to_string())?; - let response: Response = read_frame(&mut self.stream).map_err(|e| e.to_string())?; + let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::(&mut self.stream)); + let response = match response { + Ok(response) => response, + Err(error) => { + let _ = self.stream.shutdown(Shutdown::Both); + return Err(error.to_string()); + } + }; if response.id != self.id || response.version != VERSION { + let _ = self.stream.shutdown(Shutdown::Both); return Err("Unexpected theme IPC response".into()); } if let Some(error) = response.error { @@ -98,8 +105,17 @@ impl Connection { revision: u64, fallback_families: impl FnOnce() -> Vec, ) -> Result { + Snapshot::decode(self.wait("theme-watch", json!({"since": revision}))?, fallback_families) + } + + /// Wait for a server-side change; dropping the cancellation handle interrupts it. + pub fn wait(&mut self, command: &str, args: Value) -> Result { self.stream.set_read_timeout(None).map_err(|e| e.to_string())?; - Snapshot::decode(self.call("theme-watch", json!({"since": revision}))?, fallback_families) + let result = self.call(command, args); + self.stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|e| e.to_string())?; + result } } diff --git a/crates/ferese-theme/assets/icons/status/media-next.svg b/crates/ferese-theme/assets/icons/status/media-next.svg new file mode 100644 index 0000000..d347842 --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/media-next.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/ferese-theme/assets/icons/status/media-pause.svg b/crates/ferese-theme/assets/icons/status/media-pause.svg new file mode 100644 index 0000000..9947b1c --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/media-pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ferese-theme/assets/icons/status/media-play.svg b/crates/ferese-theme/assets/icons/status/media-play.svg new file mode 100644 index 0000000..d7f1cf9 --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/media-play.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ferese-theme/assets/icons/status/media-previous.svg b/crates/ferese-theme/assets/icons/status/media-previous.svg new file mode 100644 index 0000000..ce56d2c --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/media-previous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/ferese-theme/assets/icons/status/media.svg b/crates/ferese-theme/assets/icons/status/media.svg new file mode 100644 index 0000000..21efc73 --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/media.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs index 6845375..61363b3 100644 --- a/crates/ferese-theme/src/icons.rs +++ b/crates/ferese-theme/src/icons.rs @@ -32,6 +32,11 @@ pub const CONTROL_CENTER: &[u8] = include_bytes!("../assets/icons/status/control pub const DND: &[u8] = include_bytes!("../assets/icons/status/dnd.svg"); pub const ETHERNET: &[u8] = include_bytes!("../assets/icons/status/ethernet.svg"); pub const LOCK: &[u8] = include_bytes!("../assets/icons/status/lock.svg"); +pub const MEDIA: &[u8] = include_bytes!("../assets/icons/status/media.svg"); +pub const MEDIA_PLAY: &[u8] = include_bytes!("../assets/icons/status/media-play.svg"); +pub const MEDIA_PAUSE: &[u8] = include_bytes!("../assets/icons/status/media-pause.svg"); +pub const MEDIA_NEXT: &[u8] = include_bytes!("../assets/icons/status/media-next.svg"); +pub const MEDIA_PREVIOUS: &[u8] = include_bytes!("../assets/icons/status/media-previous.svg"); pub const MIC_MUTE: &[u8] = include_bytes!("../assets/icons/status/mic-mute.svg"); pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg"); pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg"); diff --git a/docs/configuration.md b/docs/configuration.md index 37b8b6d..49c2354 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -802,6 +802,52 @@ The gaps between islands are transparent and let clicks pass through. Each islan Use **Island side padding** in Settings → Bar to tighten the space around each section. It applies only to islands; continuous bars keep their existing padding. +### Now Playing + +The media control on the right shows artwork, the track title and a play/pause +button. Click the artwork or title to open playback controls for MPRIS players. +Scroll over the control to change the player's volume, or +middle-click to raise its window when supported. The menu has previous/next +buttons, a seek slider when supported, and a player chooser. It uses the same +colors, transparency and animations as the other bar menus. + +Automatic selection prefers the player that most recently started playing. +When none is playing, it selects the most recently active paused player. Players +already running at startup have no known activity order; ties use their bus names. +Stopped players hide the control. Pin a player through the chooser to keep it +selected, or ignore it to exclude it. These choices last until that player leaves +the bus or the media service reconnects. `Choose automatically` clears the pin. + +The bar and media keys share one MPRIS monitor. Playback updates come from D-Bus +signals. Progress updates only while the menu is open and playback is running: +once a second, or every three seconds on battery. Album art loads while the +bar or menu is visible, on a worker with download, decode and cache limits. A placeholder +keeps its space while loading or when art is unavailable. + +The default Play/Pause, Next and Previous keys use `media-play-pause`, +`media-next` and `media-previous`. These actions take no arguments. Custom +bindings can use them too: + +```kdl +binding "Super+P" "media-play-pause" +``` + +Inspect players or control the selected one from the terminal: + +```sh +feresectl media +feresectl media play-pause +feresectl media next +feresectl media previous +feresectl media pin org.mpris.MediaPlayer2.example +feresectl media ignore org.mpris.MediaPlayer2.example +feresectl media unignore org.mpris.MediaPlayer2.example +feresectl media auto +``` + +`unignore` restores an ignored player even when the bar control is hidden. Apps +without MPRIS support do not appear here. + ## Login items and locking | `autostart` key | Type | Default | Meaning | diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index cbaeaf5..2001642 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -152,6 +152,19 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), .map_err(|_| format!("{} requires a positive workspace index", command))?; json!({ "index": index }) } + "media" => { + return match positional.as_slice() { + [] => Ok(("media-get".into(), json!({}))), + [mode] if mode == "get" => Ok(("media-get".into(), json!({}))), + [action] if ["play-pause", "next", "previous", "raise", "auto"].contains(&action.as_str()) => { + Ok(("media-action".into(), json!({"action": action}))) + } + [action, player] if ["pin", "ignore", "unignore"].contains(&action.as_str()) => { + Ok(("media-action".into(), json!({"action":if action == "pin" { "pin" } else { "ignore" },"player":player,"ignored":action == "ignore"}))) + } + _ => Err("usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]".into()), + }; + } "screenshot" => match positional.as_slice() { [] => json!({}), [flag, geometry] if flag == "--geometry" || flag == "-g" => { @@ -220,13 +233,33 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] mod tests { use super::*; + #[test] + fn media_commands_route_transport_and_player_preferences() { + assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get"); + for action in ["play-pause", "next", "previous", "raise", "auto"] { + let (command, args) = parse_args(["media".into(), action.into()]).unwrap(); + assert_eq!(command, "media-action"); + assert_eq!(args["action"], action); + } + + for (action, ignored) in [("ignore", true), ("unignore", false)] { + let (_, args) = parse_args(["media".into(), action.into(), "player".into()]).unwrap(); + assert_eq!(args["action"], "ignore"); + assert_eq!(args["ignored"], ignored); + assert_eq!(args["player"], "player"); + } + + assert!(parse_args(["media".into(), "pin".into()]).is_err()); + assert!(parse_args(["media".into(), "next".into(), "extra".into()]).is_err()); + } + #[test] fn display_mode_commands_validate_layouts() { for layout in ["internal-only", "external-only", "extend", "mirror"] { diff --git a/protocol.c b/protocol.c new file mode 100644 index 0000000..7fddbf8 --- /dev/null +++ b/protocol.c @@ -0,0 +1,103 @@ +/* Generated by wayland-scanner 1.26.0 */ + +/* + * Copyright 2026 Ferese contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#include +#include +#include +#include "wayland-util.h" + +#ifndef __has_attribute +# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */ +#endif + +#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4) +#define WL_PRIVATE __attribute__ ((visibility("hidden"))) +#else +#define WL_PRIVATE +#endif + +extern const struct wl_interface ferese_shell_v1_interface; + +static const struct wl_interface *ferese_shell_v1_types[] = { + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + &ferese_shell_v1_interface, +}; + +static const struct wl_message ferese_shell_manager_v1_requests[] = { + { "get_shell", "n", ferese_shell_v1_types + 9 }, + { "destroy", "", ferese_shell_v1_types + 0 }, +}; + +WL_PRIVATE const struct wl_interface ferese_shell_manager_v1_interface = { + "ferese_shell_manager_v1", 6, + 2, ferese_shell_manager_v1_requests, + 0, NULL, +}; + +static const struct wl_message ferese_shell_v1_requests[] = { + { "activate_window", "uu", ferese_shell_v1_types + 0 }, + { "close_window", "uu", ferese_shell_v1_types + 0 }, + { "activate_workspace", "uu", ferese_shell_v1_types + 0 }, + { "enter_overview", "", ferese_shell_v1_types + 0 }, + { "exit_overview", "", ferese_shell_v1_types + 0 }, + { "select_overview_window", "uu", ferese_shell_v1_types + 0 }, + { "destroy", "", ferese_shell_v1_types + 0 }, + { "confirm_logout", "3u", ferese_shell_v1_types + 0 }, + { "cancel_logout", "3u", ferese_shell_v1_types + 0 }, + { "confirm_logout_with_inhibitors", "4uuuu", ferese_shell_v1_types + 0 }, +}; + +static const struct wl_message ferese_shell_v1_events[] = { + { "snapshot_begin", "u", ferese_shell_v1_types + 0 }, + { "window", "uuuussu", ferese_shell_v1_types + 0 }, + { "output", "uusuuu", ferese_shell_v1_types + 0 }, + { "workspace", "uuuusuuuu", ferese_shell_v1_types + 0 }, + { "snapshot_end", "u", ferese_shell_v1_types + 0 }, + { "overview_state", "u", ferese_shell_v1_types + 0 }, + { "request_failed", "uuus", ferese_shell_v1_types + 0 }, + { "capabilities", "u", ferese_shell_v1_types + 0 }, + { "config_begin", "2", ferese_shell_v1_types + 0 }, + { "config_chunk", "2s", ferese_shell_v1_types + 0 }, + { "config_end", "2", ferese_shell_v1_types + 0 }, + { "logout_cancelled", "3u", ferese_shell_v1_types + 0 }, + { "logout_requested", "3us", ferese_shell_v1_types + 0 }, + { "toggle_keybinding_guide", "5s", ferese_shell_v1_types + 0 }, + { "toggle_display_mode", "6s", ferese_shell_v1_types + 0 }, + { "monitor_state_changed", "6", ferese_shell_v1_types + 0 }, +}; + +WL_PRIVATE const struct wl_interface ferese_shell_v1_interface = { + "ferese_shell_v1", 6, + 10, ferese_shell_v1_requests, + 16, ferese_shell_v1_events, +}; + diff --git a/protocol_client_header.c b/protocol_client_header.c new file mode 100644 index 0000000..fa2dab5 --- /dev/null +++ b/protocol_client_header.c @@ -0,0 +1,560 @@ +/* Generated by wayland-scanner 1.26.0 */ + +#ifndef FERESE_SHELL_V1_CLIENT_PROTOCOL_H +#define FERESE_SHELL_V1_CLIENT_PROTOCOL_H + +#include +#include +#include "wayland-client.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @page page_ferese_shell_v1 The ferese_shell_v1 protocol + * @section page_ifaces_ferese_shell_v1 Interfaces + * - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller + * - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control + * @section page_copyright_ferese_shell_v1 Copyright + *
+ *
+ * Copyright 2026 Ferese contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ * 
+ */ +struct ferese_shell_manager_v1; +struct ferese_shell_v1; + +#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE +#define FERESE_SHELL_MANAGER_V1_INTERFACE +/** + * @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1 + * @section page_iface_ferese_shell_manager_v1_desc Description + * + * This private global is visible only to clients granted the shell-control + * capability. It does not assign a Wayland surface role. + * @section page_iface_ferese_shell_manager_v1_api API + * See @ref iface_ferese_shell_manager_v1. + */ +/** + * @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface + * + * This private global is visible only to clients granted the shell-control + * capability. It does not assign a Wayland surface role. + */ +extern const struct wl_interface ferese_shell_manager_v1_interface; +#endif +#ifndef FERESE_SHELL_V1_INTERFACE +#define FERESE_SHELL_V1_INTERFACE +/** + * @page page_iface_ferese_shell_v1 ferese_shell_v1 + * @section page_iface_ferese_shell_v1_desc Description + * + * Window, workspace, and output identifiers are unsigned 64-bit values + * encoded as high and low 32-bit words. Each snapshot atomically replaces + * the prior shell view after snapshot_end. + * @section page_iface_ferese_shell_v1_api API + * See @ref iface_ferese_shell_v1. + */ +/** + * @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface + * + * Window, workspace, and output identifiers are unsigned 64-bit values + * encoded as high and low 32-bit words. Each snapshot atomically replaces + * the prior shell view after snapshot_end. + */ +extern const struct wl_interface ferese_shell_v1_interface; +#endif + +#define FERESE_SHELL_MANAGER_V1_GET_SHELL 0 +#define FERESE_SHELL_MANAGER_V1_DESTROY 1 + + +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1 + +/** @ingroup iface_ferese_shell_manager_v1 */ +static inline void +ferese_shell_manager_v1_set_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1, void *user_data) +{ + wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_manager_v1, user_data); +} + +/** @ingroup iface_ferese_shell_manager_v1 */ +static inline void * +ferese_shell_manager_v1_get_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) +{ + return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_manager_v1); +} + +static inline uint32_t +ferese_shell_manager_v1_get_version(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) +{ + return wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1); +} + +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +static inline struct ferese_shell_v1 * +ferese_shell_manager_v1_get_shell(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) +{ + struct wl_proxy *id; + + id = wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1, + FERESE_SHELL_MANAGER_V1_GET_SHELL, &ferese_shell_v1_interface, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), 0, NULL); + + return (struct ferese_shell_v1 *) id; +} + +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +static inline void +ferese_shell_manager_v1_destroy(struct ferese_shell_manager_v1 *ferese_shell_manager_v1) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1, + FERESE_SHELL_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), WL_MARSHAL_FLAG_DESTROY); +} + +#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM +#define FERESE_SHELL_V1_WINDOW_STATE_ENUM +enum ferese_shell_v1_window_state { + FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1, + FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2, + FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4, + FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8, +}; +#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */ + +#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM +#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM +enum ferese_shell_v1_failed_request { + FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0, + FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1, + FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2, + FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3, +}; +#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */ + +/** + * @ingroup iface_ferese_shell_v1 + * @struct ferese_shell_v1_listener + */ +struct ferese_shell_v1_listener { + /** + */ + void (*snapshot_begin)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t serial); + /** + */ + void (*window)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t window_hi, + uint32_t window_lo, + uint32_t workspace_hi, + uint32_t workspace_lo, + const char *app_id, + const char *title, + uint32_t state); + /** + * @param focused one when this is the focused output + */ + void (*output)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t output_hi, + uint32_t output_lo, + const char *name, + uint32_t active_workspace_hi, + uint32_t active_workspace_lo, + uint32_t focused); + /** + * @param output_hi zero when the workspace has no output + * @param output_lo zero when the workspace has no output + * @param index one-based position on this output + * @param window_count managed windows, including floating windows + * @param visible one when displayed on its output + * @param focused one when visible on the focused output + */ + void (*workspace)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t workspace_hi, + uint32_t workspace_lo, + uint32_t output_hi, + uint32_t output_lo, + const char *name, + uint32_t index, + uint32_t window_count, + uint32_t visible, + uint32_t focused); + /** + */ + void (*snapshot_end)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t serial); + /** + * @param active one when overview is active + */ + void (*overview_state)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t active); + /** + */ + void (*request_failed)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t request, + uint32_t object_hi, + uint32_t object_lo, + const char *reason); + /** + * marks completion of initial protocol setup + * + * + */ + void (*capabilities)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t version); + /** + * complete validated configuration for live reload + * + * Sent only after the compositor has accepted the complete + * configuration. Followed by bounded UTF-8 chunks and config_end. + * Clients apply only the completed source, preserving their + * current appearance if parsing fails. + * @since 2 + */ + void (*config_begin)(void *data, + struct ferese_shell_v1 *ferese_shell_v1); + /** + * @since 2 + */ + void (*config_chunk)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + const char *source); + /** + * @since 2 + */ + void (*config_end)(void *data, + struct ferese_shell_v1 *ferese_shell_v1); + /** + * @since 3 + */ + void (*logout_cancelled)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t serial); + /** + * @since 3 + */ + void (*logout_requested)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + uint32_t serial, + const char *output_name); + /** + * toggle the shortcut hint on the focused output + * + * Explicit requests are independent of the guide-at-login + * preference. + * @since 5 + */ + void (*toggle_keybinding_guide)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + const char *output_name); + /** + * toggle the display mode chooser on the focused output + * + * + * @since 6 + */ + void (*toggle_display_mode)(void *data, + struct ferese_shell_v1 *ferese_shell_v1, + const char *output_name); + /** + * physical monitor configuration changed + * + * Query the output IPC inventory after reconciliation. This + * includes disabled monitors and mirror targets without a logical + * wl_output. + * @since 6 + */ + void (*monitor_state_changed)(void *data, + struct ferese_shell_v1 *ferese_shell_v1); +}; + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline int +ferese_shell_v1_add_listener(struct ferese_shell_v1 *ferese_shell_v1, + const struct ferese_shell_v1_listener *listener, void *data) +{ + return wl_proxy_add_listener((struct wl_proxy *) ferese_shell_v1, + (void (**)(void)) listener, data); +} + +#define FERESE_SHELL_V1_ACTIVATE_WINDOW 0 +#define FERESE_SHELL_V1_CLOSE_WINDOW 1 +#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE 2 +#define FERESE_SHELL_V1_ENTER_OVERVIEW 3 +#define FERESE_SHELL_V1_EXIT_OVERVIEW 4 +#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW 5 +#define FERESE_SHELL_V1_DESTROY 6 +#define FERESE_SHELL_V1_CONFIRM_LOGOUT 7 +#define FERESE_SHELL_V1_CANCEL_LOGOUT 8 +#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS 9 + +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6 + +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4 + +/** @ingroup iface_ferese_shell_v1 */ +static inline void +ferese_shell_v1_set_user_data(struct ferese_shell_v1 *ferese_shell_v1, void *user_data) +{ + wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_v1, user_data); +} + +/** @ingroup iface_ferese_shell_v1 */ +static inline void * +ferese_shell_v1_get_user_data(struct ferese_shell_v1 *ferese_shell_v1) +{ + return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_v1); +} + +static inline uint32_t +ferese_shell_v1_get_version(struct ferese_shell_v1 *ferese_shell_v1) +{ + return wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_activate_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_ACTIVATE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_close_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_CLOSE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_activate_workspace(struct ferese_shell_v1 *ferese_shell_v1, uint32_t workspace_hi, uint32_t workspace_lo) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_ACTIVATE_WORKSPACE, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, workspace_hi, workspace_lo); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_enter_overview(struct ferese_shell_v1 *ferese_shell_v1) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_ENTER_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_exit_overview(struct ferese_shell_v1 *ferese_shell_v1) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_EXIT_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_select_overview_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_destroy(struct ferese_shell_v1 *ferese_shell_v1) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), WL_MARSHAL_FLAG_DESTROY); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_confirm_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_CONFIRM_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_cancel_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_CANCEL_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial); +} + +/** + * @ingroup iface_ferese_shell_v1 + */ +static inline void +ferese_shell_v1_confirm_logout_with_inhibitors(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial, uint32_t inhibitor_revision, uint32_t query_token, uint32_t force) +{ + wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1, + FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial, inhibitor_revision, query_token, force); +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/protocol_server_header.c b/protocol_server_header.c new file mode 100644 index 0000000..19df514 --- /dev/null +++ b/protocol_server_header.c @@ -0,0 +1,561 @@ +/* Generated by wayland-scanner 1.26.0 */ + +#ifndef FERESE_SHELL_V1_SERVER_PROTOCOL_H +#define FERESE_SHELL_V1_SERVER_PROTOCOL_H + +#include +#include +#include "wayland-server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct wl_client; +struct wl_resource; + +/** + * @page page_ferese_shell_v1 The ferese_shell_v1 protocol + * @section page_ifaces_ferese_shell_v1 Interfaces + * - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller + * - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control + * @section page_copyright_ferese_shell_v1 Copyright + *
+ *
+ * Copyright 2026 Ferese contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ * 
+ */ +struct ferese_shell_manager_v1; +struct ferese_shell_v1; + +#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE +#define FERESE_SHELL_MANAGER_V1_INTERFACE +/** + * @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1 + * @section page_iface_ferese_shell_manager_v1_desc Description + * + * This private global is visible only to clients granted the shell-control + * capability. It does not assign a Wayland surface role. + * @section page_iface_ferese_shell_manager_v1_api API + * See @ref iface_ferese_shell_manager_v1. + */ +/** + * @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface + * + * This private global is visible only to clients granted the shell-control + * capability. It does not assign a Wayland surface role. + */ +extern const struct wl_interface ferese_shell_manager_v1_interface; +#endif +#ifndef FERESE_SHELL_V1_INTERFACE +#define FERESE_SHELL_V1_INTERFACE +/** + * @page page_iface_ferese_shell_v1 ferese_shell_v1 + * @section page_iface_ferese_shell_v1_desc Description + * + * Window, workspace, and output identifiers are unsigned 64-bit values + * encoded as high and low 32-bit words. Each snapshot atomically replaces + * the prior shell view after snapshot_end. + * @section page_iface_ferese_shell_v1_api API + * See @ref iface_ferese_shell_v1. + */ +/** + * @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface + * + * Window, workspace, and output identifiers are unsigned 64-bit values + * encoded as high and low 32-bit words. Each snapshot atomically replaces + * the prior shell view after snapshot_end. + */ +extern const struct wl_interface ferese_shell_v1_interface; +#endif + +/** + * @ingroup iface_ferese_shell_manager_v1 + * @struct ferese_shell_manager_v1_interface + */ +struct ferese_shell_manager_v1_interface { + /** + */ + void (*get_shell)(struct wl_client *client, + struct wl_resource *resource, + uint32_t id); + /** + */ + void (*destroy)(struct wl_client *client, + struct wl_resource *resource); +}; + + +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_manager_v1 + */ +#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1 + +#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM +#define FERESE_SHELL_V1_WINDOW_STATE_ENUM +enum ferese_shell_v1_window_state { + FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1, + FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2, + FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4, + FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8, +}; +#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */ + +#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID +#define FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID +/** + * @ingroup iface_ferese_shell_v1 + * Validate a ferese_shell_v1 window_state value. + * + * @return true on success, false on error. + * @ref ferese_shell_v1_window_state + */ +static inline bool +ferese_shell_v1_window_state_is_valid(uint32_t value, uint32_t version) { + uint32_t valid = 0; + if (version >= 1) + valid |= FERESE_SHELL_V1_WINDOW_STATE_FOCUSED; + if (version >= 1) + valid |= FERESE_SHELL_V1_WINDOW_STATE_URGENT; + if (version >= 1) + valid |= FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN; + if (version >= 1) + valid |= FERESE_SHELL_V1_WINDOW_STATE_FLOATING; + return (value & ~valid) == 0; +} +#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID */ + +#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM +#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM +enum ferese_shell_v1_failed_request { + FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0, + FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1, + FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2, + FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3, +}; +#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */ + +#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID +#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID +/** + * @ingroup iface_ferese_shell_v1 + * Validate a ferese_shell_v1 failed_request value. + * + * @return true on success, false on error. + * @ref ferese_shell_v1_failed_request + */ +static inline bool +ferese_shell_v1_failed_request_is_valid(uint32_t value, uint32_t version) { + switch (value) { + case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW: + return version >= 1; + case FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW: + return version >= 1; + case FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW: + return version >= 1; + case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE: + return version >= 1; + default: + return false; + } +} +#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID */ + +/** + * @ingroup iface_ferese_shell_v1 + * @struct ferese_shell_v1_interface + */ +struct ferese_shell_v1_interface { + /** + */ + void (*activate_window)(struct wl_client *client, + struct wl_resource *resource, + uint32_t window_hi, + uint32_t window_lo); + /** + */ + void (*close_window)(struct wl_client *client, + struct wl_resource *resource, + uint32_t window_hi, + uint32_t window_lo); + /** + */ + void (*activate_workspace)(struct wl_client *client, + struct wl_resource *resource, + uint32_t workspace_hi, + uint32_t workspace_lo); + /** + */ + void (*enter_overview)(struct wl_client *client, + struct wl_resource *resource); + /** + */ + void (*exit_overview)(struct wl_client *client, + struct wl_resource *resource); + /** + */ + void (*select_overview_window)(struct wl_client *client, + struct wl_resource *resource, + uint32_t window_hi, + uint32_t window_lo); + /** + */ + void (*destroy)(struct wl_client *client, + struct wl_resource *resource); + /** + * @since 3 + */ + void (*confirm_logout)(struct wl_client *client, + struct wl_resource *resource, + uint32_t serial); + /** + * @since 3 + */ + void (*cancel_logout)(struct wl_client *client, + struct wl_resource *resource, + uint32_t serial); + /** + * @since 4 + */ + void (*confirm_logout_with_inhibitors)(struct wl_client *client, + struct wl_resource *resource, + uint32_t serial, + uint32_t inhibitor_revision, + uint32_t query_token, + uint32_t force); +}; + +#define FERESE_SHELL_V1_SNAPSHOT_BEGIN 0 +#define FERESE_SHELL_V1_WINDOW 1 +#define FERESE_SHELL_V1_OUTPUT 2 +#define FERESE_SHELL_V1_WORKSPACE 3 +#define FERESE_SHELL_V1_SNAPSHOT_END 4 +#define FERESE_SHELL_V1_OVERVIEW_STATE 5 +#define FERESE_SHELL_V1_REQUEST_FAILED 6 +#define FERESE_SHELL_V1_CAPABILITIES 7 +#define FERESE_SHELL_V1_CONFIG_BEGIN 8 +#define FERESE_SHELL_V1_CONFIG_CHUNK 9 +#define FERESE_SHELL_V1_CONFIG_END 10 +#define FERESE_SHELL_V1_LOGOUT_CANCELLED 11 +#define FERESE_SHELL_V1_LOGOUT_REQUESTED 12 +#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE 13 +#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE 14 +#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED 15 + +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6 + +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3 +/** + * @ingroup iface_ferese_shell_v1 + */ +#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4 + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an snapshot_begin event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_snapshot_begin(struct wl_resource *resource_, uint32_t serial) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_BEGIN, serial); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an window event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_window(struct wl_resource *resource_, uint32_t window_hi, uint32_t window_lo, uint32_t workspace_hi, uint32_t workspace_lo, const char *app_id, const char *title, uint32_t state) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_WINDOW, window_hi, window_lo, workspace_hi, workspace_lo, app_id, title, state); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an output event to the client owning the resource. + * @param resource_ The client's resource + * @param focused one when this is the focused output + */ +static inline void +ferese_shell_v1_send_output(struct wl_resource *resource_, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t active_workspace_hi, uint32_t active_workspace_lo, uint32_t focused) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_OUTPUT, output_hi, output_lo, name, active_workspace_hi, active_workspace_lo, focused); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an workspace event to the client owning the resource. + * @param resource_ The client's resource + * @param output_hi zero when the workspace has no output + * @param output_lo zero when the workspace has no output + * @param index one-based position on this output + * @param window_count managed windows, including floating windows + * @param visible one when displayed on its output + * @param focused one when visible on the focused output + */ +static inline void +ferese_shell_v1_send_workspace(struct wl_resource *resource_, uint32_t workspace_hi, uint32_t workspace_lo, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t index, uint32_t window_count, uint32_t visible, uint32_t focused) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_WORKSPACE, workspace_hi, workspace_lo, output_hi, output_lo, name, index, window_count, visible, focused); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an snapshot_end event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_snapshot_end(struct wl_resource *resource_, uint32_t serial) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_END, serial); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an overview_state event to the client owning the resource. + * @param resource_ The client's resource + * @param active one when overview is active + */ +static inline void +ferese_shell_v1_send_overview_state(struct wl_resource *resource_, uint32_t active) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_OVERVIEW_STATE, active); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an request_failed event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_request_failed(struct wl_resource *resource_, uint32_t request, uint32_t object_hi, uint32_t object_lo, const char *reason) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_REQUEST_FAILED, request, object_hi, object_lo, reason); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an capabilities event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_capabilities(struct wl_resource *resource_, uint32_t version) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_CAPABILITIES, version); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an config_begin event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_config_begin(struct wl_resource *resource_) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_BEGIN); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an config_chunk event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_config_chunk(struct wl_resource *resource_, const char *source) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_CHUNK, source); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an config_end event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_config_end(struct wl_resource *resource_) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_END); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an logout_cancelled event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_logout_cancelled(struct wl_resource *resource_, uint32_t serial) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_CANCELLED, serial); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an logout_requested event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_logout_requested(struct wl_resource *resource_, uint32_t serial, const char *output_name) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_REQUESTED, serial, output_name); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an toggle_keybinding_guide event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_toggle_keybinding_guide(struct wl_resource *resource_, const char *output_name) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE, output_name); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an toggle_display_mode event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_toggle_display_mode(struct wl_resource *resource_, const char *output_name) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE, output_name); +} + +/** + * @ingroup iface_ferese_shell_v1 + * Sends an monitor_state_changed event to the client owning the resource. + * @param resource_ The client's resource + */ +static inline void +ferese_shell_v1_send_monitor_state_changed(struct wl_resource *resource_) +{ + wl_resource_post_event(resource_, FERESE_SHELL_V1_MONITOR_STATE_CHANGED); +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/scripts/tests/test_media_isolated.py b/scripts/tests/test_media_isolated.py new file mode 100644 index 0000000..f2b8dd5 --- /dev/null +++ b/scripts/tests/test_media_isolated.py @@ -0,0 +1,270 @@ +"""Exercise Now Playing on a private bus and nested desktop. + +FERESE_TEST_MEDIA=1 FERESE_TEST_BINARY=target/release/ferese \ +FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_media_isolated.py +Requires a Wayland host, PyGObject and dbus-run-session. +""" +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +REPO = Path(__file__).resolve().parents[2] +PLAYER = "org.mpris.MediaPlayer2.Player" +ROOT = "org.mpris.MediaPlayer2" +PATH = "/org/mpris/MediaPlayer2" + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_MEDIA") == "1", "requires a Wayland host") +class Media(unittest.TestCase): + def test_private_players_and_actions(self): + result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +def private_checks(): + import gi + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib + loop = GLib.MainLoop() + threading.Thread(target=loop.run, daemon=True).start() + + class FakePlayer: + def __init__(self, name, status="Paused"): + self.name = ROOT + "." + name + self.status = status + self.title = name + " track" + self.position = 1_000_000 + self.volume = 0.5 + self.calls = [] + self.reads = 0 + self.can_next = True + self.connection = Gio.DBusConnection.new_for_address_sync( + os.environ["DBUS_SESSION_BUS_ADDRESS"], + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, None) + properties = [('PlaybackStatus', 's'), ('Metadata', 'a{sv}'), ('Position', 'x'), + ('Rate', 'd'), ('Volume', 'd'), ('CanControl', 'b'), ('CanPlay', 'b'), + ('CanPause', 'b'), ('CanGoNext', 'b'), ('CanGoPrevious', 'b'), ('CanSeek', 'b')] + xml = f'' + ''.join( + f'' + for key, kind in [('Identity', 's'), ('DesktopEntry', 's'), ('CanRaise', 'b')]) + xml += '' + f'' + xml += ''.join(f'' for key, kind in properties) + xml += ''' + + ''' + node = Gio.DBusNodeInfo.new_for_xml(xml) + self.registrations = [self.connection.register_object(PATH, interface, self.method, self.property, self.set_property) + for interface in node.interfaces] + self.connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "RequestName", GLib.Variant("(su)", (self.name, 0)), None, + Gio.DBusCallFlags.NONE, 2000, None) + + def property(self, connection, sender, path, interface, name): + self.reads += 1 + if name == 'Metadata': + return GLib.Variant('a{sv}', {'xesam:title': GLib.Variant('s', self.title), + 'xesam:artist': GLib.Variant('as', ['Artist']), 'mpris:length': GLib.Variant('x', 120_000_000), + 'mpris:trackid': GLib.Variant('o', '/track/one')}) + if name == 'PlaybackStatus': + return GLib.Variant('s', self.status) + if name in ('Identity', 'DesktopEntry'): + return GLib.Variant('s', self.name.rsplit('.', 1)[-1]) + if name == 'Position': + return GLib.Variant('x', self.position) + if name in ('Rate', 'Volume'): + return GLib.Variant('d', 1.0 if name == 'Rate' else self.volume) + return GLib.Variant('b', self.can_next if name == 'CanGoNext' else True) + + def set_property(self, connection, sender, path, interface, name, value): + if interface == PLAYER and name == 'Volume': + self.volume = value.unpack() + self.changed(['Volume']) + return True + return False + + def method(self, connection, sender, path, interface, name, parameters, invocation): + self.calls.append((name, parameters.unpack())) + if name == 'PlayPause': + self.set('Paused' if self.status == 'Playing' else 'Playing') + elif name == 'SetPosition': + self.position = parameters.unpack()[1] + self.connection.emit_signal(None, PATH, PLAYER, 'Seeked', GLib.Variant('(x)', (self.position,))) + invocation.return_value(GLib.Variant('()', ())) + + def changed(self, fields): + self.connection.emit_signal(None, PATH, 'org.freedesktop.DBus.Properties', 'PropertiesChanged', + GLib.Variant('(sa{sv}as)', (PLAYER, {}, fields))) + self.connection.flush_sync(None) + + def set(self, status): + self.status = status + self.changed(['PlaybackStatus']) + + def close(self): + self.connection.close_sync(None) + + players = [FakePlayer('first')] + child = None + shell = None + try: + with tempfile.TemporaryDirectory(prefix='ferese-media-') as directory: + root = Path(directory) + runtime = root / 'runtime' + runtime.mkdir(mode=0o700) + config = root / 'config/ferese' + config.mkdir(parents=True) + (config / 'config.kdl').write_text('animations { reduced-motion #true; }\n') + display = Path(os.environ['WAYLAND_DISPLAY']) + if not display.is_absolute(): + display = Path(os.environ['XDG_RUNTIME_DIR']) / display + env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / 'config'), XDG_STATE_HOME=str(root / 'state')) + for variable in ['WAYLAND_SOCKET', 'FERESE_SOCKET', 'FERESE_SHELL_CONTROL_SOCKET']: + env.pop(variable, None) + binary = REPO / os.environ.get('FERESE_TEST_BINARY', 'target/debug/ferese') + ctl = REPO / os.environ.get('FERESE_TEST_CTL', 'target/debug/feresectl') + with (root / 'compositor.log').open('w') as log: + child = subprocess.Popen([str(binary), '--backend=nested'], env=env, + stdout=log, stderr=log, start_new_session=True) + + def wait(check): + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + if child.poll() is not None: + raise AssertionError((root / 'compositor.log').read_text()) + if check(): + return + time.sleep(.02) + raise AssertionError('Timed out\n' + (root / 'compositor.log').read_text()) + + wait(lambda: (runtime / 'ferese/control.sock').exists()) + + def call(*args): + return json.loads(subprocess.check_output([str(ctl), *args], env=env, timeout=5)) + + def request(command, args): + payload = json.dumps({'version': 1, 'id': 17, 'type': 'command', + 'command': command, 'args': args}).encode() + connection = socket.socket(socket.AF_UNIX) + connection.settimeout(3) + connection.connect(str(runtime / 'ferese/control.sock')) + connection.sendall(struct.pack('!I', len(payload)) + payload) + return connection + + def response(connection): + def read(size): + data = b'' + while len(data) < size: + chunk = connection.recv(size - len(data)) + assert chunk, "IPC closed before the response completed" + data += chunk + return data + result = json.loads(read(struct.unpack('!I', read(4))[0])) + connection.close() + assert result['id'] == 17 + return result + + def selected(name): + wait(lambda: call('media')['selected'] and call('media')['selected']['name'] == name) + + env['WAYLAND_DISPLAY'] = str(next(path for path in runtime.glob('wayland-*') if not path.name.endswith('.lock'))) + shell_binary = os.environ.get('FERESE_TEST_SHELL') + if shell_binary: + shell = subprocess.Popen([str(REPO / shell_binary)], env=env, stdout=log, stderr=log, start_new_session=True) + first = players[0] + selected(first.name) + initial = call('media') + watcher = request('media-watch', {'since': initial['revision']}) + first.set('Playing') + update = response(watcher)['result'] + assert update['selected']['status'] == 'playing' + assert update['selected']['title'] == 'first track' + assert update['selected']['artist'] == 'Artist' + # No timer queries while the player is idle and unchanged. + time.sleep(.1) + reads = first.reads + time.sleep(1.2) + assert first.reads == reads, (first.reads, reads) + second = FakePlayer('second', 'Playing') + players.append(second) + selected(second.name) + first.title = 'A different first track' + first.changed(['Metadata']) + time.sleep(.1) + assert call('media')['selected']['name'] == second.name + call('media', 'pin', first.name) + selected(first.name) + call('media', 'next') + assert first.calls[-1][0] == 'Next' + call('media', 'previous') + assert first.calls[-1][0] == 'Previous' + call('media', 'raise') + assert first.calls[-1][0] == 'Raise' + assert 'error' not in response(request('media-action', {'action': 'volume', 'delta': 0.1})) + wait(lambda: abs(call('media')['selected']['volume'] - 0.6) < 1e-9) + assert abs(first.volume - 0.6) < 1e-9 + assert 'error' in response(request('media-action', {'action': 'volume', 'delta': 2.0})) + stale = response(request('media-action', {'action': 'play-pause', 'player': first.name, 'owner': ':999999'})) + assert 'error' in stale + before = len(first.calls) + first.can_next = False + first.changed(['CanGoNext']) + wait(lambda: not call('media')['selected']['can_next']) + assert 'error' in response(request('media-action', {'action': 'next'})) + assert len(first.calls) == before + assert 'error' in response(request('media-action', {'action': 'seek', 'track_id': '/track/stale', 'position_us': 10_000_000})) + assert 'error' not in response(request('media-action', {'action': 'seek', 'track_id': '/track/one', 'position_us': 10_000_000})) + wait(lambda: call('media')['selected']['position_us'] == 10_000_000) + call('media', 'ignore', first.name) + selected(second.name) + call('media', 'unignore', first.name) + call('media', 'pin', first.name) + first.close() + players.remove(first) + selected(second.name) + assert call('media')['pinned'] is None + replacement = FakePlayer('first', 'Paused') + players.append(replacement) + wait(lambda: len(call('media')['players']) == 2) + assert call('media')['selected']['name'] == second.name + call('media', 'play-pause') + wait(lambda: second.status == 'Paused') + second.set('Stopped') + selected(replacement.name) + replacement.set('Stopped') + wait(lambda: call('media')['selected'] is None) + assert child.poll() is None + if shell: + time.sleep(.3) + assert shell.poll() is None, (root / "compositor.log").read_text() + print('MPRIS discovery, selection, watches, capabilities, stale actions, seeking and owner replacement passed.') + finally: + for player in players: + player.close() + for process in [shell, child]: + if process and process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + loop.quit() + + +if __name__ == '__main__': + if sys.argv[1:] == ['--private']: + private_checks() + else: + unittest.main() diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 1cb40e9..e02a833 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -25,3 +25,7 @@ tokio = { version = "1", features = ["time", "rt", "sync"] } wayland-client.workspace = true zbus = "5.19.0" pipewire = { version = "0.10.1", features = ["v0_3_65"] } +image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } +url = "2.5.8" +base64 = "0.22.1" +ureq = { version = "2.12.1", default-features = false, features = ["tls"] } diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index bd4cec9..d91e03c 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -225,7 +225,11 @@ impl FereseShell { .and_then(|output| output.size) .map_or(0., |(width, _)| width as f32) - 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32); - let title_width = (available - 920.).clamp(0., 360.); + let media_visible = self.media.snapshot.selected.is_some(); + let title_width = (available - 920. - if media_visible { 400. } else { 0. }).clamp(0., 360.); + // Without a center title, give media controls more of a narrow bar. + // Keep equal sides when the title is shown so it stays centered. + let right_portion = if media_visible && title_width == 0. { 2 } else { 1 }; let title = if self.config.status.window_title && title_width > 0. { focused_bar_title(&self.snapshot, focused_output) } else { @@ -270,7 +274,7 @@ impl FereseShell { container(left).width(Length::Fill), center, container(right) - .width(Length::Fill) + .width(Length::FillPortion(right_portion)) .align_x(alignment::Horizontal::Right), ] .spacing(8) diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 98830d6..a878d87 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -12,6 +12,7 @@ mod config; mod control; mod display_mode; mod keybinding_guide; +mod media; mod motion; mod note_store; mod notification_ui; @@ -178,6 +179,7 @@ struct FereseShell { status_service: status::Service, status: status::Snapshot, recorder: recording::Recorder, + media: media::Model, calendar_offset: i32, status_error: Option, theme_error: Option, @@ -246,6 +248,14 @@ enum Message { ClockChanged(clock::Labels), RecorderEvent(recording::Event), RecorderElapsed, + MediaUpdated(std::sync::Arc), + MediaAction(serde_json::Value), + MediaCompleted(Result<(), String>), + MediaTick, + MediaChoose, + MediaSeek(u64), + MediaSeekCommit, + MediaArt(media::Artwork), ControlReady, ActivateWorkspace(u64), ToggleOverview, @@ -316,6 +326,7 @@ impl cosmic::Application for FereseShell { status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), recorder: recording::Recorder::default(), + media: media::Model::default(), calendar_offset: 0, status_error: None, theme_error: None, @@ -393,6 +404,9 @@ impl cosmic::Application for FereseShell { }), ) .map(Message::ClockChanged), + self.media.subscription().map(Message::MediaUpdated), + self.media_tick_subscription().map(|_| Message::MediaTick), + self.media.art.subscription().map(Message::MediaArt), self.recorder.subscription().map(Message::RecorderEvent), self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed), self.status_service.subscription().map(Message::StatusUpdated), @@ -427,6 +441,45 @@ impl cosmic::Application for FereseShell { ) } + Message::MediaUpdated(snapshot) => { + self.media.receive(snapshot); + self.refresh_media_art( + self.menu + .as_ref() + .is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()), + ); + Task::none() + } + Message::MediaAction(args) => self.media.command(args), + Message::MediaCompleted(result) => self.media.completed(result), + Message::MediaTick => { + self.media.now_us = ferese_ipc::media::now_us(); + Task::none() + } + Message::MediaChoose => { + self.media.choosing = !self.media.choosing; + Task::none() + } + Message::MediaSeek(position) => { + self.media.seeking = Some(position); + Task::none() + } + Message::MediaSeekCommit => { + let Some(position) = self.media.seeking.take() else { + return Task::none(); + }; + let mut args = self.media.action("seek"); + args["position_us"] = serde_json::json!(position); + args["track_id"] = + serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone())); + self.media.command(args) + } + Message::MediaArt(art) => { + if self.media.art.accepts(&art) { + self.media.artwork = Some(art); + } + Task::none() + } Message::ThemeChanged(snapshot) => { let mut config = self.config.clone(); config.apply_theme(&snapshot.presented); @@ -787,6 +840,7 @@ impl cosmic::Application for FereseShell { if let Some(snapshot) = poll.snapshot { self.snapshot = snapshot; let mut tasks = vec![reload_task]; + let mut visibility_changed = false; for entry in &mut self.outputs { let output = self @@ -802,6 +856,7 @@ impl cosmic::Application for FereseShell { } entry.hidden = hidden; + visibility_changed = true; if let Some(effects) = &entry.effects && let Err(error) = effects.set_visible(!hidden) { @@ -815,6 +870,10 @@ impl cosmic::Application for FereseShell { if self.bar_hidden(self.bar_surface_id) { tasks.push(self.destroy_menu()); } + if visibility_changed { + self.refresh_media_art(false); + } + reload_task = Task::batch(tasks); } for command in poll.commands { diff --git a/shell/ferese-shell/src/media.rs b/shell/ferese-shell/src/media.rs new file mode 100644 index 0000000..9624739 --- /dev/null +++ b/shell/ferese-shell/src/media.rs @@ -0,0 +1,199 @@ +mod art; +mod subscription; + +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Duration; + +use cosmic::app::Task; +use cosmic::iced::Subscription; +use ferese_ipc::media::{Playback, Snapshot, now_us}; +use serde_json::{Value, json}; + +use crate::{FereseShell, Message}; + +pub(crate) use art::Artwork; + +pub(crate) struct Model { + pub snapshot: Arc, + pub error: Option, + pub choosing: bool, + pub seeking: Option, + pub now_us: u64, + pub art: art::Service, + pub artwork: Option, + busy: bool, + pending: VecDeque, +} + +impl Default for Model { + fn default() -> Self { + Self { + snapshot: Arc::new(Snapshot::default()), + error: None, + choosing: false, + seeking: None, + now_us: now_us(), + art: art::Service::new(), + artwork: None, + busy: false, + pending: VecDeque::new(), + } + } +} + +impl Model { + pub fn action(&self, action: &str) -> Value { + match &self.snapshot.selected { + Some(player) => json!({"action": action, "player":player.name, "owner":player.owner}), + None => json!({"action": action}), + } + } + + pub fn subscription(&self) -> Subscription> { + subscription::subscription() + } + + pub fn tick_subscription(&self, visible: bool, on_battery: bool) -> Subscription<()> { + if visible && needs_progress(&self.snapshot) { + cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 })).map(|_| ()) + } else { + Subscription::none() + } + } + + pub fn receive(&mut self, snapshot: Arc) { + let previous = self.snapshot.selected.as_ref(); + let next = snapshot.selected.as_ref(); + if previous.map(|p| (&p.owner, &p.track_id)) != next.map(|p| (&p.owner, &p.track_id)) { + self.seeking = None; + self.error = None; + } + + self.snapshot = snapshot; + self.now_us = now_us(); + } + + pub fn refresh_art(&mut self, visible: bool) { + let player = if visible { self.snapshot.selected.as_ref() } else { None }; + self.art.request(player); + if visible && self.artwork.as_ref().is_some_and(|art| !self.art.accepts(art)) { + self.artwork = None; + } + } + + pub fn command(&mut self, args: Value) -> Task { + if self.busy { + // Coalesce adjacent wheel events without dropping transport clicks. + if args["action"] == "volume" + && let Some(pending) = self.pending.back_mut() + && pending["action"] == "volume" + && pending["owner"] == args["owner"] + { + pending["delta"] = json!( + (pending["delta"].as_f64().unwrap_or(0.0) + args["delta"].as_f64().unwrap_or(0.0)).clamp(-1.0, 1.0) + ); + } else if self.pending.len() < 8 { + self.pending.push_back(args); + } else { + self.error = Some("Media controls are busy".into()); + } + + return Task::none(); + } + + self.busy = true; + self.error = None; + cosmic::task::future(async move { + let result = tokio::task::spawn_blocking(move || { + let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?; + connection.call("media-action", args).map(|_| ()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())); + cosmic::Action::App(Message::MediaCompleted(result)) + }) + } + + pub fn completed(&mut self, result: Result<(), String>) -> Task { + self.busy = false; + self.error = result.err(); + match self.pending.pop_front() { + Some(args) => self.command(args), + None => Task::none(), + } + } +} + +fn needs_progress(snapshot: &Snapshot) -> bool { + snapshot.selected.as_ref().is_some_and(|player| { + player.status == Playback::Playing && player.position_us.is_some() && player.length_us.is_some() + }) +} + +impl FereseShell { + pub fn refresh_media_art(&mut self, menu_visible: bool) { + self.media + .refresh_art(menu_visible || self.outputs.iter().any(|output| !output.hidden)); + } + + pub fn media_tick_subscription(&self) -> Subscription<()> { + let visible = self + .menu + .as_ref() + .is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()) + && self.outputs.iter().any(|output| !output.hidden); + let on_battery = self + .status + .battery + .as_ref() + .is_some_and(|battery| battery.status == "Discharging"); + self.media.tick_subscription(visible, on_battery) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_updates_coalesce_and_transport_commands_remain_ordered_and_bounded() { + let mut model = Model { + busy: true, + ..Default::default() + }; + for _ in 0..100 { + let _ = model.command(json!({"action":"volume", "owner":":1.1", "delta":0.01})); + } + + assert_eq!(model.pending.len(), 1); + assert!((model.pending[0]["delta"].as_f64().unwrap() - 1.0).abs() < 1e-9); + let _ = model.command(json!({"action":"next"})); + let _ = model.command(json!({"action":"previous"})); + assert_eq!(model.pending[1]["action"], "next"); + assert_eq!(model.pending[2]["action"], "previous"); + for _ in 0..100 { + let _ = model.command(json!({"action":"next"})); + } + assert_eq!(model.pending.len(), 8); + assert!(model.error.is_some()); + } + + #[test] + fn progress_only_runs_for_playing_media_with_known_position_and_duration() { + let mut snapshot = Snapshot::default(); + assert!(!needs_progress(&snapshot)); + snapshot.selected = Some(ferese_ipc::media::Player { + status: Playback::Playing, + position_us: Some(0), + length_us: Some(100), + ..Default::default() + }); + assert!(needs_progress(&snapshot)); + snapshot.selected.as_mut().unwrap().status = Playback::Paused; + assert!(!needs_progress(&snapshot)); + snapshot.selected.as_mut().unwrap().status = Playback::Playing; + snapshot.selected.as_mut().unwrap().length_us = None; + assert!(!needs_progress(&snapshot)); + } +} diff --git a/shell/ferese-shell/src/media/art.rs b/shell/ferese-shell/src/media/art.rs new file mode 100644 index 0000000..f7d57d8 --- /dev/null +++ b/shell/ferese-shell/src/media/art.rs @@ -0,0 +1,334 @@ +use std::collections::VecDeque; +use std::io::{Cursor, Read}; +use std::os::unix::fs::OpenOptionsExt; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use base64::Engine; +use cosmic::iced::Subscription; +use cosmic::iced::futures::SinkExt; +use cosmic::widget::image; + +const MAX_BYTES: usize = 2 * 1024 * 1024; +const CACHE_SIZE: usize = 16; +const SIDE: u32 = 128; + +#[derive(Clone, Debug)] +pub(crate) struct Artwork { + key: Key, + pub handle: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Key { + uri: String, + owner: String, + track_id: Option, + title: String, + artist: String, +} + +impl Key { + fn from_player(player: &ferese_ipc::media::Player) -> Option { + Some(Self { + uri: player.art_url.clone()?, + owner: player.owner.clone(), + track_id: player.track_id.clone(), + title: player.title.clone(), + artist: player.artist.clone(), + }) + } +} + +#[derive(Default)] +struct Queue { + pending: Option, + stopped: bool, +} + +pub(crate) struct Service { + queue: Arc<(Mutex, Condvar)>, + receive: Updates, + requested: Option, +} + +impl Service { + pub fn new() -> Self { + let queue = Arc::new((Mutex::new(Queue::default()), Condvar::new())); + let (send, receive) = tokio::sync::watch::channel(None); + let worker_queue = queue.clone(); + let _ = std::thread::Builder::new() + .name("ferese-media-art".into()) + .spawn(move || { + let agent = ureq::AgentBuilder::new() + .redirects(0) + .timeout(Duration::from_secs(5)) + .build(); + work(worker_queue, send, |uri| load(&agent, uri).ok()); + }); + Self { + queue, + receive: Updates(Arc::new(receive)), + requested: None, + } + } + + pub fn request(&mut self, player: Option<&ferese_ipc::media::Player>) { + let key = player.and_then(Key::from_player); + if self.requested == key { + return; + } + self.requested = key.clone(); + self.queue.0.lock().unwrap().pending = key; + self.queue.1.notify_one(); + } + + pub fn accepts(&self, art: &Artwork) -> bool { + self.requested.as_ref() == Some(&art.key) + } + + pub fn subscription(&self) -> Subscription { + Subscription::run_with(self.receive.clone(), stream) + } +} + +impl Drop for Service { + fn drop(&mut self) { + self.queue.0.lock().unwrap().stopped = true; + self.queue.1.notify_one(); + } +} + +fn work( + queue: Arc<(Mutex, Condvar)>, + send: tokio::sync::watch::Sender>, + mut load: impl FnMut(&str) -> Option, +) { + let mut cache: VecDeque = VecDeque::new(); + loop { + let (lock, changed) = &*queue; + let mut state = lock.lock().unwrap(); + while state.pending.is_none() && !state.stopped { + state = changed.wait(state).unwrap(); + } + + if state.stopped { + return; + } + let key = state.pending.take().unwrap(); + drop(state); + let artwork = match cache.iter().position(|art| art.key == key) { + Some(index) => cache.remove(index).unwrap(), + None => Artwork { + handle: load(&key.uri), + key, + }, + }; + cache.push_front(artwork.clone()); + cache.truncate(CACHE_SIZE); + if send.send(Some(artwork)).is_err() { + return; + } + } +} + +// Receiver identity stays stable while the latest result is replaced. +#[derive(Clone)] +struct Updates(Arc>>); + +impl std::hash::Hash for Updates { + fn hash(&self, state: &mut H) { + std::ptr::hash(Arc::as_ptr(&self.0), state); + } +} + +fn stream(receive: &Updates) -> impl cosmic::iced::futures::Stream + use<> { + let mut receive = receive.0.as_ref().clone(); + cosmic::iced::stream::channel(1, async move |mut output| { + loop { + let art = receive.borrow_and_update().clone(); + if let Some(art) = art + && output.send(art).await.is_err() + { + return; + } + if receive.changed().await.is_err() { + return; + } + } + }) +} + +fn bounded(mut reader: impl Read) -> Result, String> { + let mut bytes = Vec::new(); + reader + .by_ref() + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > MAX_BYTES { + return Err("Album art exceeds the size limit".into()); + } + Ok(bytes) +} + +fn load(agent: &ureq::Agent, uri: &str) -> Result { + let url = url::Url::parse(uri).map_err(|e| e.to_string())?; + let bytes = match url.scheme() { + "file" => { + let path = url.to_file_path().map_err(|_| "Invalid album art path")?; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("Album art is not a regular file".into()); + } + bounded(file)? + } + "http" | "https" => { + let response = agent.get(uri).call().map_err(|e| e.to_string())?; + if response.status() != 200 { + return Err("Album art request failed".into()); + } + bounded(response.into_reader())? + } + "data" => { + let (metadata, data) = uri.split_once(',').ok_or("Invalid album art data")?; + if !metadata.starts_with("data:image/") + || !metadata.ends_with(";base64") + || data.len() > MAX_BYTES * 4 / 3 + 4 + { + return Err("Invalid album art data".into()); + } + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| e.to_string())? + } + _ => return Err("Unsupported album art URL".into()), + }; + decode(bytes) +} + +fn decode(bytes: Vec) -> Result { + let mut reader = image_codec::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image_codec::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(16 * 1024 * 1024); + reader.limits(limits); + let image = reader + .decode() + .map_err(|e| e.to_string())? + .thumbnail(SIDE, SIDE) + .to_rgba8(); + Ok(image::Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(uri: &str) -> Key { + Key::from_player(&ferese_ipc::media::Player { + art_url: Some(uri.into()), + ..Default::default() + }) + .unwrap() + } + + #[test] + fn artwork_is_invalidated_when_the_track_changes_or_has_no_art() { + let mut model = crate::media::Model::default(); + let player = ferese_ipc::media::Player { + art_url: Some("file:///nonexistent/ferese-media-test.png".into()), + track_id: Some("/track/one".into()), + ..Default::default() + }; + let art = Artwork { + key: Key::from_player(&player).unwrap(), + handle: Some(image::Handle::from_rgba(1, 1, vec![255; 4])), + }; + model.snapshot = Arc::new(ferese_ipc::media::Snapshot { + selected: Some(player.clone()), + ..Default::default() + }); + model.refresh_art(true); + assert!(model.art.accepts(&art)); + model.artwork = Some(art.clone()); + let mut next = player; + next.track_id = Some("/track/two".into()); + model.snapshot = Arc::new(ferese_ipc::media::Snapshot { + selected: Some(next.clone()), + ..Default::default() + }); + model.refresh_art(true); + assert!(!model.art.accepts(&art)); + assert!(model.artwork.is_none()); + model.artwork = Some(art); + next.art_url = None; + model.snapshot = Arc::new(ferese_ipc::media::Snapshot { + selected: Some(next), + ..Default::default() + }); + model.refresh_art(true); + assert!(model.artwork.is_none()); + } + + #[test] + fn one_decoder_coalesces_replacements_while_an_image_is_loading() { + let queue = Arc::new(( + Mutex::new(Queue { + pending: Some(key("first")), + stopped: false, + }), + Condvar::new(), + )); + let (send, _receive) = tokio::sync::watch::channel(None); + let (started, starts) = std::sync::mpsc::channel(); + let (resume, resumes) = std::sync::mpsc::channel(); + let worker_queue = queue.clone(); + let worker = std::thread::spawn(move || { + work(worker_queue, send, |uri| { + started.send(uri.to_owned()).unwrap(); + if uri == "first" { + resumes.recv().unwrap(); + } + None + }) + }); + assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "first"); + queue.0.lock().unwrap().pending = Some(key("second")); + queue.0.lock().unwrap().pending = Some(key("latest")); + resume.send(()).unwrap(); + assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "latest"); + queue.0.lock().unwrap().stopped = true; + queue.1.notify_one(); + worker.join().unwrap(); + assert!(starts.try_recv().is_err()); + } + + #[test] + fn encoded_and_decoded_art_have_resource_limits() { + assert!(bounded(Cursor::new(vec![0; MAX_BYTES + 1])).is_err()); + let mut bytes = Cursor::new(Vec::new()); + image_codec::DynamicImage::new_rgba8(5000, 1) + .write_to(&mut bytes, image_codec::ImageFormat::Png) + .unwrap(); + assert!(decode(bytes.into_inner()).is_err()); + assert!(decode(vec![0; 40]).is_err()); + } + + #[test] + fn unsupported_urls_do_not_fetch() { + assert!(load(&ureq::Agent::new(), "ftp://example.test/art.png").is_err()); + assert!(load(&ureq::Agent::new(), "data:text/plain;base64,dGVzdA==").is_err()); + } +} diff --git a/shell/ferese-shell/src/media/subscription.rs b/shell/ferese-shell/src/media/subscription.rs new file mode 100644 index 0000000..0e01244 --- /dev/null +++ b/shell/ferese-shell/src/media/subscription.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; +use std::time::Duration; + +use cosmic::iced::Subscription; +use cosmic::iced::futures::SinkExt; +use ferese_ipc::media::Snapshot; +use serde_json::json; + +pub(super) fn subscription() -> Subscription> { + Subscription::run(stream) +} + +fn stream() -> impl cosmic::iced::futures::Stream> { + cosmic::iced::stream::channel(1, async |mut output| { + loop { + let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; + let Ok(Ok(mut connection)) = connection else { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + }; + let Ok(cancellation) = connection.cancellation() else { + return; + }; + let (send, mut receive) = tokio::sync::watch::channel(None); + tokio::task::spawn_blocking(move || { + let Ok(value) = connection.call("media-get", json!({})) else { + return; + }; + let Ok(mut snapshot) = serde_json::from_value::(value) else { + return; + }; + loop { + let revision = snapshot.revision; + if send.send(Some(Arc::new(snapshot))).is_err() { + return; + } + match connection + .wait("media-watch", json!({"since":revision})) + .and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string())) + { + Ok(next) => snapshot = next, + Err(_) => return, + } + } + }); + while receive.changed().await.is_ok() { + let snapshot = receive.borrow_and_update().clone(); + if let Some(snapshot) = snapshot + && output.send(snapshot).await.is_err() + { + return; + } + } + + drop(cancellation); + if output.send(Arc::new(Snapshot::default())).await.is_err() { + return; + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::futures::StreamExt; + use ferese_ipc::{Request, Response, read_frame, write_frame}; + use std::os::unix::net::UnixListener; + + #[test] + fn reconnect_clears_ghost_media_and_drop_cancels_a_watch() { + const CHILD: &str = "FERESE_MEDIA_SUBSCRIPTION_TEST"; + if std::env::var_os(CHILD).is_none() { + let root = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "media::subscription::tests::reconnect_clears_ghost_media_and_drop_cancels_a_watch", + "--nocapture", + ]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", root.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let path = ferese_ipc::theme::socket_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let listener = UnixListener::bind(path).unwrap(); + let (watch_send, watch_receive) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + for revision in [1, 2] { + let (mut connection, _) = listener.accept().unwrap(); + connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap(); + let get: Request = read_frame(&mut connection).unwrap(); + assert_eq!(get.command, "media-get"); + let snapshot = Snapshot { + revision, + ..Default::default() + }; + write_frame( + &mut connection, + &Response::success(get.id, serde_json::to_value(snapshot).unwrap()), + ) + .unwrap(); + let watch: Request = read_frame(&mut connection).unwrap(); + assert_eq!(watch.command, "media-watch"); + assert_eq!(watch.args["since"], revision); + assert_ne!(get.id, watch.id); + if revision == 2 { + watch_send.send(()).unwrap(); + use std::io::Read; + assert_eq!(connection.read(&mut [0]).unwrap(), 0); + } + } + }); + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap() + .block_on(async { + let mut subscription = Box::pin(stream()); + for revision in [1, 0, 2] { + let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next()) + .await + .unwrap() + .unwrap(); + assert_eq!(snapshot.revision, revision); + } + + tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap()) + .await + .unwrap(); + drop(subscription); + }); + server.join().unwrap(); + } +} diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 476a2d2..aef3076 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -133,12 +133,45 @@ pub(crate) fn button<'a, M: Clone + 'a>( selected: bool, opacity: f32, ) -> Element<'a, M> { - let progress = std::rc::Rc::new(std::cell::Cell::new(0.0)); - let active = progress.clone(); - let hovered = progress.clone(); let paint = move |progress: f32, pressed: bool| { ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed) }; + painted_button(button, paint) +} + +pub(crate) fn circular_button<'a, M: Clone + 'a>( + button: cosmic::widget::button::Button<'a, M>, + foreground: cosmic::iced::Color, + fill: Option, + opacity: f32, + diameter: f32, +) -> Element<'a, M> { + let paint = move |progress: f32, pressed: bool| { + let mut style = + ferese_theme::controls::shell_button(foreground, false, opacity, diameter / 2.0, progress, pressed); + style.shape = Some(cosmic::iced::border::Shape::Circular); + if let Some(fill) = fill { + style.background = Some( + cosmic::iced::Color { + a: opacity * if pressed { 0.75 } else { 1.0 - 0.1 * progress }, + ..fill + } + .into(), + ); + } + + style + }; + painted_button(button, paint) +} + +fn painted_button<'a, M: Clone + 'a>( + button: cosmic::widget::button::Button<'a, M>, + paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static, +) -> Element<'a, M> { + let progress = std::rc::Rc::new(std::cell::Cell::new(0.0)); + let active = progress.clone(); + let hovered = progress.clone(); let content = button .class(cosmic::theme::Button::Custom { active: Box::new(move |_, _| paint(active.get(), false)), diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 7fe2082..5ca4398 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -12,7 +12,11 @@ impl FereseShell { let theme = self.config.theme.for_bar(); let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands; let metrics = BarMetrics::from(theme); - let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center); + let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center); + if self.media.snapshot.selected.is_some() { + controls = controls.push(super::media::bar(self, theme, metrics)); + } + for kind in [ Menu::System, Menu::Network, @@ -157,6 +161,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String { }, Menu::Calendar => "Calendar".into(), Menu::Recording => "Screen recording".into(), + Menu::Media => "Now playing".into(), Menu::Battery => s.battery.as_ref().map_or_else( || "Battery: unavailable".into(), |b| format!("Battery: {}%, {}", b.percent, b.status), @@ -203,6 +208,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { }), Menu::Calendar => (ferese_theme::icons::CALENDAR, true), Menu::Recording => (ferese_theme::icons::RECORD, true), + Menu::Media => (ferese_theme::icons::MEDIA, true), Menu::Battery => { let Some(b) = &s.battery else { return (ferese_theme::icons::BATTERY_EMPTY, false); diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index 32f6b7e..1fd1008 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -25,6 +25,11 @@ impl FereseShell { if let Some(menu) = &mut self.menu && menu.motion.closing() { + if kind == Menu::Media { + self.media.now_us = ferese_ipc::media::now_us(); + self.media.refresh_art(true); + } + menu.motion.retarget(1.0, Instant::now()); return Task::none(); } @@ -32,6 +37,16 @@ impl FereseShell { return self.close_menu(); } + if kind == Menu::Media { + self.media.now_us = ferese_ipc::media::now_us(); + self.media.refresh_art(true); + self.media.error = None; + } + + if kind != Menu::Media { + self.refresh_media_art(false); + } + if kind == Menu::Calendar { self.calendar_offset = 0; } @@ -122,6 +137,7 @@ impl FereseShell { let Some(menu) = self.menu.take() else { return Task::none(); }; + self.refresh_media_art(false); if menu.kind == Menu::Notifications { self.notifications.history_open = false; self.notifications.hovered = None; @@ -133,6 +149,7 @@ impl FereseShell { } pub fn close_menu(&mut self) -> Task { + self.refresh_media_art(false); let Some(menu) = &mut self.menu else { return Task::none(); }; diff --git a/shell/ferese-shell/src/status_ui/media.rs b/shell/ferese-shell/src/status_ui/media.rs new file mode 100644 index 0000000..a4d704d --- /dev/null +++ b/shell/ferese-shell/src/status_ui/media.rs @@ -0,0 +1,471 @@ +use cosmic::iced::border::Shape as BorderShape; +use cosmic::iced::{Alignment, Border, Length, Rectangle, mouse}; +use cosmic::widget::{button, column, container, image, mouse_area, row, scrollable, slider}; +use cosmic::{Element, theme}; +use ferese_ipc::media::Playback; +use ferese_theme::icons; +use serde_json::json; + +use super::{Menu, MenuRows, MenuStyle}; +use crate::{ + BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, text, +}; + +const SEEK_THUMB_DIAMETER: f32 = 10.0; + +fn seek_slider_style(style: MenuStyle) -> cosmic::theme::iced::Slider { + let paint = std::rc::Rc::new(move |_: &cosmic::Theme| { + use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style}; + + let accent = color_with_opacity(style.theme.accent, style.opacity); + Style { + rail: Rail { + backgrounds: (accent.into(), style.muted.scale_alpha(0.10).into()), + width: 4.0, + border: cosmic::iced::Border { + shape: BorderShape::Circular, + radius: 2.0.into(), + ..Default::default() + }, + }, + handle: Handle { + corner_shape: BorderShape::Circular, + shape: HandleShape::Circle { + radius: SEEK_THUMB_DIAMETER / 2.0, + }, + background: accent.into(), + border_width: 0.0, + border_color: cosmic::iced::Color::TRANSPARENT, + }, + breakpoint: Breakpoint { + color: cosmic::iced::Color::TRANSPARENT, + }, + } + }); + + cosmic::theme::iced::Slider::Custom { + active: paint.clone(), + hovered: paint.clone(), + dragging: paint, + } +} + +fn seek_pill<'a>( + content: impl Into>>, + style: MenuStyle, +) -> Element<'a, cosmic::Action> { + container(content) + .width(Length::Fill) + .padding([2, 0]) + .class(theme::Container::custom(move |_| container::Style { + background: Some(color_with_opacity(style.theme.border, style.opacity).into()), + border: cosmic::iced::Border { + shape: BorderShape::Circular, + radius: 12.0.into(), + ..Default::default() + }, + ..Default::default() + })) + .into() +} + +pub(super) fn bar<'a>( + shell: &'a FereseShell, + palette: ShellTheme, + metrics: BarMetrics, +) -> Element<'a, cosmic::Action> { + let Some(player) = &shell.media.snapshot.selected else { + return text("").into(); + }; + let selected = shell.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Media); + let foreground = color(if selected { palette.accent } else { palette.text_primary }); + let height = (metrics.group_item_height - 4.0).max(0.0); + let side = height.min(24.0); + let cover: Element<'_, cosmic::Action> = + match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { + Some(handle) => image(handle.clone()) + .width(side) + .height(side) + .content_fit(cosmic::iced::ContentFit::Cover) + .border_radius(motion::radius(6.0)) + .shape(BorderShape::Continuous) + .into(), + None => container(accented_icon( + icons::MEDIA, + side as u16, + foreground, + color(palette.accent), + )) + .center_x(side) + .center_y(side) + .width(side) + .into(), + }; + let label = text(player.label()) + .size(metrics.text_size) + .wrapping(cosmic::iced::widget::text::Wrapping::None) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )) + .width(Length::Shrink) + .height(height) + .align_y(cosmic::iced::alignment::Vertical::Center) + .class(theme::Text::Color(foreground)); + let label = container(label).max_width(112); + let details = button::custom(bar_content( + row![cover, label].spacing(8).align_y(Alignment::Center), + height, + )) + .name(format!("Now playing: {}", player.label())) + .padding([0, 4]) + .height(height) + .on_press_with_rectangle(move |offset, bounds| { + cosmic::Action::App(Message::OpenMenu( + Menu::Media, + Rectangle { + x: (bounds.x - offset.x).round() as i32, + y: (bounds.y - offset.y).round() as i32, + width: bounds.width.round() as i32, + height: bounds.height.round() as i32, + }, + )) + }); + let playing = player.status == Playback::Playing; + let mut toggle = button::custom(bar_content( + accented_icon( + if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, + metrics.icon_size, + foreground, + color(palette.accent), + ), + height, + )) + .name(if playing { "Pause" } else { "Play" }) + .height(height) + .padding([0, 6]); + if player.can_toggle() { + toggle = toggle.on_press(cosmic::Action::App(Message::MediaAction( + shell.media.action("play-pause"), + ))); + } + + let mut bar_style = crate::bar::bar_group_style(palette); + bar_style.border.width(0.0); + let controls = container( + row![ + motion::button(details, foreground, false, 1.0), + motion::button(toggle, foreground, false, 1.0) + ] + .spacing(2) + .align_y(Alignment::Center), + ) + .padding(2) + .height(metrics.group_item_height) + .class(theme::Container::custom(move |_| bar_style)); + + let mut area = mouse_area(controls); + if player.can_raise { + area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(shell.media.action("raise")))); + } + if player.can_control && player.volume.is_some() { + let action = shell.media.action("volume"); + area = area.on_scroll(move |delta| { + let mut action = action.clone(); + action["delta"] = json!(volume_delta(delta)); + cosmic::Action::App(Message::MediaAction(action)) + }); + } + + area.into() +} + +fn volume_delta(delta: mouse::ScrollDelta) -> f64 { + let delta = match delta { + mouse::ScrollDelta::Lines { y, .. } => f64::from(y) * 0.05, + mouse::ScrollDelta::Pixels { y, .. } => f64::from(y) * 0.001, + }; + if delta.is_finite() { delta.clamp(-1.0, 1.0) } else { 0.0 } +} + +fn transport<'a>( + shell: &'a FereseShell, + source: &'static [u8], + label: &'static str, + action: &str, + enabled: bool, + style: MenuStyle, +) -> Element<'a, cosmic::Action> { + let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted }); + let foreground = match fill { + Some(fill) => cosmic::iced::Color { + a: fill.a, + ..ferese_theme::foreground(fill, color(style.theme.surface_popover)) + }, + None => { + if enabled { + style.primary + } else { + style.muted + } + } + }; + let mut control = button::custom( + container(accented_icon(source, 24, foreground, color(style.theme.accent))) + .center_x(24) + .center_y(24), + ) + .padding(10) + .width(44) + .height(44) + .name(label); + if enabled { + control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action)))); + } + + motion::circular_button(control, foreground, fill, style.opacity, 44.0) +} + +pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { + if let Some(player) = &shell.media.snapshot.selected { + let cover: Element<'_, cosmic::Action> = + match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { + Some(handle) => image(handle.clone()) + .width(64) + .height(64) + .content_fit(cosmic::iced::ContentFit::Contain) + .border_radius(motion::radius(12.0)) + .shape(BorderShape::Continuous) + .into(), + None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent))) + .center_x(64) + .center_y(64) + .into(), + }; + let mut info = column![text(player.label()).size(15).width(Length::Fill).ellipsize( + cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2)) + )] + .spacing(4) + .width(Length::Fill); + if !player.artist.is_empty() { + info = info.push( + text(&player.artist) + .size(12) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )) + .class(theme::Text::Color(style.muted)), + ); + } + if !player.album.is_empty() { + info = info.push( + text(&player.album) + .size(11) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )) + .class(theme::Text::Color(style.muted)), + ); + } + + rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center)); + let mut playback = column::with_capacity(2).spacing(6).width(Length::Fill); + if let (Some(length), Some(position)) = ( + player.length_us.filter(|length| *length > 0), + player.position_at(shell.media.now_us), + ) { + let position = shell.media.seeking.unwrap_or(position).min(length); + let mut progress = column::with_capacity(2).spacing(2); + if player.can_control && player.can_seek && player.track_id.is_some() { + let value = (position as f64 / length as f64 * 10_000.0).round() as u32; + progress = progress.push(seek_pill( + container( + slider(0..=10_000_u32, value, move |value| { + cosmic::Action::App(Message::MediaSeek(seek_position(length, value))) + }) + .on_release(cosmic::Action::App(Message::MediaSeekCommit)) + .handle_width(SEEK_THUMB_DIAMETER) + .height(20) + .class(seek_slider_style(style)) + .width(Length::Fill), + ) + // Extend the native slider's layout by its reserved thumb + // radius so its rail and pointer mapping reach both edges. + .padding(cosmic::iced::Padding { + left: -SEEK_THUMB_DIAMETER / 2.0, + right: -SEEK_THUMB_DIAMETER / 2.0, + ..Default::default() + }), + style, + )); + } else { + progress = progress.push(seek_pill( + container( + cosmic::iced::widget::ProgressBar::new(0.0..=1.0, (position as f64 / length as f64) as f32) + .girth(4) + .length(Length::Fill) + .class(cosmic::theme::iced::ProgressBar::custom(move |_| { + cosmic::iced::widget::progress_bar::Style { + background: style.muted.scale_alpha(0.35).into(), + bar: color_with_opacity(style.theme.accent, style.opacity).into(), + border: cosmic::iced::Border { + shape: BorderShape::Circular, + radius: 2.0.into(), + ..Default::default() + }, + } + })), + ) + .center_y(20), + style, + )); + } + + progress = progress.push( + row![ + text(time_label(position)) + .font(cosmic::iced::Font::MONOSPACE) + .size(11) + .class(theme::Text::Color(style.muted)) + .width(Length::Fill), + text(time_label(length)) + .font(cosmic::iced::Font::MONOSPACE) + .size(11) + .class(theme::Text::Color(style.muted)) + ] + .spacing(12), + ); + playback = playback.push(progress); + } + + let playing = player.status == Playback::Playing; + playback = playback.push( + container( + row![ + transport( + shell, + icons::MEDIA_PREVIOUS, + "Previous track", + "previous", + player.can_control && player.can_previous, + style + ), + transport( + shell, + if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, + if playing { "Pause" } else { "Play" }, + "play-pause", + player.can_toggle(), + style + ), + transport( + shell, + icons::MEDIA_NEXT, + "Next track", + "next", + player.can_control && player.can_next, + style + ), + ] + .spacing(12), + ) + .center_x(Length::Fill), + ); + rows = rows.push(playback); + + let label = if shell.media.snapshot.pinned.is_some() { + format!("{} · pinned", player.identity) + } else { + player.identity.clone() + }; + rows = rows.push(motion::button( + button::custom(text(label).size(12)) + .padding([6, 8]) + .on_press(cosmic::Action::App(Message::MediaChoose)), + style.primary, + false, + style.opacity, + )); + } else { + rows = rows.push(text("No active player").size(13).class(theme::Text::Color(style.muted))); + } + + if shell.media.choosing || shell.media.snapshot.selected.is_none() { + let mut choices = column::with_capacity(shell.media.snapshot.players.len() + 1).spacing(4); + choices = choices.push(super::controls::menu_button( + "Choose automatically", + Message::MediaAction(json!({"action":"auto"})), + style.primary, + style.opacity, + )); + for player in &shell.media.snapshot.players { + let args = json!({"player":player.name,"owner":player.owner}); + let mut pin = args.clone(); + pin["action"] = json!("pin"); + let mut ignore = args; + ignore["action"] = json!("ignore"); + ignore["ignored"] = json!(!player.ignored); + let label = if player.identity.is_empty() { + &player.name + } else { + &player.identity + }; + choices = choices.push( + row![ + super::controls::menu_button( + label, + Message::MediaAction(pin), + if player.ignored { style.muted } else { style.primary }, + style.opacity + ), + super::controls::menu_button( + if player.ignored { "Show" } else { "Ignore" }, + Message::MediaAction(ignore), + style.muted, + style.opacity + ), + ] + .spacing(6), + ); + } + + rows = rows.push(scrollable(choices).height(Length::Fixed(140.0))); + } + + if let Some(error) = &shell.media.error { + rows = rows.push(text(error).size(12).class(theme::Text::Color(style.muted))); + } + + rows +} + +fn seek_position(length: u64, value: u32) -> u64 { + (u128::from(length) * u128::from(value.min(10_000)) / 10_000) as u64 +} + +fn time_label(us: u64) -> String { + let seconds = us / 1_000_000; + format!("{}:{:02}", seconds / 60, seconds % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seeking_handles_short_tracks_and_large_durations() { + assert_eq!(seek_position(500_000, 5000), 250_000); + assert_eq!(seek_position(500_000, 10_000), 500_000); + assert_eq!(seek_position(u64::MAX, 10_000), u64::MAX); + assert_eq!(seek_position(1, 100_000), 1); + } + + #[test] + fn wheel_changes_are_bounded_and_pixel_scrolling_is_not_a_full_step() { + assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }), 0.05); + assert_eq!(volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }), 0.001); + assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: f32::NAN }), 0.0); + assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }), 1.0); + } +} diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index 8ab006d..741125a 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -6,6 +6,7 @@ mod bluetooth; mod calendar; mod controls; mod lifecycle; +mod media; mod network; mod system; @@ -37,6 +38,7 @@ pub enum Menu { Battery, Calendar, Recording, + Media, Notifications, System, } @@ -60,7 +62,7 @@ impl Menu { Self::System => 360.0, Self::Battery => 328.0, Self::Calendar => 268.0, - Self::Notifications => 368.0, + Self::Notifications | Self::Media => 368.0, _ => 300.0, } } @@ -78,6 +80,7 @@ impl Menu { Self::Battery => "Battery", Self::Calendar => "Calendar", Self::Recording => "Screen recording", + Self::Media => "Now playing", Self::Notifications => "Notifications", } } @@ -88,7 +91,7 @@ impl Menu { Self::Bluetooth => status.bluetooth.is_some(), Self::Audio => status.audio.is_some(), Self::Battery => status.battery.is_some(), - Self::Calendar | Self::Recording => true, + Self::Calendar | Self::Recording | Self::Media => true, Self::Notifications => status.notifications.is_some(), Self::System => true, } @@ -216,6 +219,7 @@ impl FereseShell { Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)), Menu::Notifications => notification_controls(self, rows, style, false), Menu::Recording => rows, + Menu::Media => media::view(self, rows, style), }; if kind != Menu::Calendar && !kind.available(&self.status) { rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted))); diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 3af0b57..bc287ab 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -12,6 +12,7 @@ impl FereseShell { if matches!(event, wayland::OutputEvent::Removed) { if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { let entry = self.outputs.remove(index); + self.refresh_media_art(false); let menu = if self.bar_surface_id == entry.bar { self.destroy_menu() } else { @@ -101,6 +102,7 @@ impl FereseShell { size, hidden, }); + self.refresh_media_art(false); let wallpaper_action = cosmic::surface::action::app_layer_shell::( |_| Default::default(), move |_| SctkLayerSurfaceSettings {