mpris and bar control impl
gitignore fix import error
This commit is contained in:
1 parent
73f21e20ce
commit
8a0dd7615e
42 files changed
+4022
-223
No files matched your search
@@ -1,7 +1,6 @@
|
|||||||
/target/
|
/target/
|
||||||
/tools/
|
/tools/
|
||||||
*.log
|
*.log
|
||||||
ChatGPT Image*.png
|
|
||||||
|
|
||||||
# Local architecture decision notes
|
# Local architecture decision notes
|
||||||
/docs/adr/
|
/docs/adr/
|
||||||
|
|||||||
Generated
+101
-1
@@ -902,7 +902,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1648,6 +1648,7 @@ name = "ferese"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
|
"async-channel",
|
||||||
"calloop 0.14.4",
|
"calloop 0.14.4",
|
||||||
"ferese-animation",
|
"ferese-animation",
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
@@ -1706,6 +1707,7 @@ name = "ferese-ipc"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-theme-model",
|
"ferese-theme-model",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -1793,6 +1795,7 @@ source = "git+https://github.com/ferese-wm/ferese-shape?tag=v0.1.0#63d7b6dfd520d
|
|||||||
name = "ferese-shell"
|
name = "ferese-shell"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"ferese-animation",
|
"ferese-animation",
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
@@ -1800,6 +1803,7 @@ dependencies = [
|
|||||||
"ferese-theme",
|
"ferese-theme",
|
||||||
"ferese-theme-client",
|
"ferese-theme-client",
|
||||||
"iced_accessibility",
|
"iced_accessibility",
|
||||||
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libc",
|
"libc",
|
||||||
"libcosmic",
|
"libcosmic",
|
||||||
@@ -1808,6 +1812,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"ureq",
|
||||||
|
"url",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
@@ -5003,6 +5009,20 @@ dependencies = [
|
|||||||
"bytemuck",
|
"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]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
@@ -5105,6 +5125,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -5577,6 +5632,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "svg_fmt"
|
name = "svg_fmt"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@@ -6143,6 +6204,27 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -6499,6 +6581,24 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ Shortcuts** whenever you want to change a shortcut or gesture.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Super + Enter | Open a terminal |
|
| Super + Enter | Open a terminal |
|
||||||
| Display Fn key (`XF86Display`) | Choose the display mode |
|
| 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 + H / J / K / L | Focus left / down / up / right |
|
||||||
| Super + Backspace | Return to the last focused window |
|
| Super + Backspace | Return to the last focused window |
|
||||||
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
|
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
|
|||||||
ferese-layout = { path = "../crates/ferese-layout" }
|
ferese-layout = { path = "../crates/ferese-layout" }
|
||||||
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
|
async-channel = "2.5.0"
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
jiff.workspace = true
|
jiff.workspace = true
|
||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
|
|||||||
@@ -971,13 +971,37 @@ mod tests {
|
|||||||
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
|
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]
|
#[test]
|
||||||
fn supplies_complete_v0_bindings_and_terminal_command() {
|
fn supplies_complete_v0_bindings_and_terminal_command() {
|
||||||
let config = parse("");
|
let config = parse("");
|
||||||
let input = config.input_settings().unwrap();
|
let input = config.input_settings().unwrap();
|
||||||
let bindings = config.bindings(&input).unwrap();
|
let bindings = config.bindings(&input).unwrap();
|
||||||
|
|
||||||
assert_eq!(bindings.len(), 54);
|
assert_eq!(bindings.len(), 58);
|
||||||
for (shift, action) in [
|
for (shift, action) in [
|
||||||
(false, BindingAction::ToggleMaximized),
|
(false, BindingAction::ToggleMaximized),
|
||||||
(true, BindingAction::ToggleFullscreen),
|
(true, BindingAction::ToggleFullscreen),
|
||||||
@@ -1021,7 +1045,7 @@ mod tests {
|
|||||||
|
|
||||||
let input = replaced.input_settings().unwrap();
|
let input = replaced.input_settings().unwrap();
|
||||||
let bindings = replaced.bindings(&input).unwrap();
|
let bindings = replaced.bindings(&input).unwrap();
|
||||||
assert_eq!(bindings.len(), 54);
|
assert_eq!(bindings.len(), 58);
|
||||||
assert!(bindings.iter().any(|binding| {
|
assert!(bindings.iter().any(|binding| {
|
||||||
binding.action
|
binding.action
|
||||||
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
== 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 input = unbound.input_settings().unwrap();
|
||||||
let bindings = unbound.bindings(&input).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));
|
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,14 @@ pub(super) fn parse_action(
|
|||||||
no_argument()?;
|
no_argument()?;
|
||||||
Ok(BindingAction::ToggleOverview)
|
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" => {
|
"toggle-display-mode" => {
|
||||||
no_argument()?;
|
no_argument()?;
|
||||||
Ok(BindingAction::ToggleDisplayMode)
|
Ok(BindingAction::ToggleDisplayMode)
|
||||||
@@ -385,6 +393,10 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
|||||||
binding("Super+Shift+Space", "toggle-floating", None),
|
binding("Super+Shift+Space", "toggle-floating", None),
|
||||||
binding("Super+Tab", "toggle-overview", None),
|
binding("Super+Tab", "toggle-overview", None),
|
||||||
binding("XF86Display", "toggle-display-mode", 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+F1", "toggle-keybinding-guide", None),
|
||||||
binding("Super+Escape", "workspace-back-and-forth", None),
|
binding("Super+Escape", "workspace-back-and-forth", None),
|
||||||
binding("Super+BackSpace", "focus-last-window", None),
|
binding("Super+BackSpace", "focus-last-window", None),
|
||||||
@@ -473,6 +485,7 @@ pub enum BindingAction {
|
|||||||
ToggleOverview,
|
ToggleOverview,
|
||||||
ToggleKeybindingGuide,
|
ToggleKeybindingGuide,
|
||||||
ToggleDisplayMode,
|
ToggleDisplayMode,
|
||||||
|
Media(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Binding {
|
impl Binding {
|
||||||
@@ -581,6 +594,12 @@ impl Binding {
|
|||||||
BindingAction::Consume => "Join window into column".into(),
|
BindingAction::Consume => "Join window into column".into(),
|
||||||
BindingAction::Expel => "Move window out of column".into(),
|
BindingAction::Expel => "Move window out of column".into(),
|
||||||
BindingAction::ToggleFloating => "Toggle floating window".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::ToggleOverview => "Open or close overview".into(),
|
||||||
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
|
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
|
||||||
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
|
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ mod tests {
|
|||||||
pid: Some(42),
|
pid: Some(42),
|
||||||
desktop_entry: Some("player".into()),
|
desktop_entry: Some("player".into()),
|
||||||
playing: true,
|
playing: true,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let window = AppWindow {
|
let window = AppWindow {
|
||||||
id: WindowId(1),
|
id: WindowId(1),
|
||||||
|
|||||||
@@ -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::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::sync::mpsc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use calloop::EventLoop;
|
use calloop::EventLoop;
|
||||||
use calloop::channel::{Event, channel};
|
use calloop::channel::{Event, sync_channel};
|
||||||
use futures_lite::{StreamExt, future, stream};
|
use ferese_ipc::{Response, media::Snapshot};
|
||||||
use zbus::zvariant::OwnedValue;
|
use serde_json::Value;
|
||||||
use zbus::{Connection, MatchRule, MessageStream};
|
|
||||||
|
|
||||||
use crate::Ferese;
|
use crate::Ferese;
|
||||||
|
|
||||||
const PREFIX: &str = "org.mpris.MediaPlayer2";
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
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)]
|
|
||||||
pub(crate) struct Player {
|
pub(crate) struct Player {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub owner: String,
|
pub owner: String,
|
||||||
pub pid: Option<u32>,
|
pub pid: Option<u32>,
|
||||||
pub desktop_entry: Option<String>,
|
pub desktop_entry: Option<String>,
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
|
pub view: ferese_ipc::media::Player,
|
||||||
|
pub(super) started: u64,
|
||||||
|
pub(super) active: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
|
pub(crate) struct Command {
|
||||||
connection: &Connection,
|
args: Value,
|
||||||
member: &str,
|
sent: Instant,
|
||||||
name: &str,
|
reply: Option<(u64, mpsc::SyncSender<Response>)>,
|
||||||
) -> zbus::Result<T> {
|
|
||||||
connection
|
|
||||||
.call_method(
|
|
||||||
Some("org.freedesktop.DBus"),
|
|
||||||
"/org/freedesktop/DBus",
|
|
||||||
Some("org.freedesktop.DBus"),
|
|
||||||
member,
|
|
||||||
&(name,),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.body()
|
|
||||||
.deserialize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn property(connection: &Connection, owner: &str, interface: &str, name: &str) -> zbus::Result<OwnedValue> {
|
struct Update {
|
||||||
connection
|
players: Vec<Player>,
|
||||||
.call_method(Some(owner), PATH, Some(PROPERTIES), "Get", &(interface, name))
|
snapshot: Snapshot,
|
||||||
.await?
|
|
||||||
.body()
|
|
||||||
.deserialize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_player(connection: &Connection, player: &mut Player, identity: bool) {
|
fn inhibition_changed(before: &[Player], after: &[Player]) -> bool {
|
||||||
// Bind reads to the unique owner, never a name that can change mid-query.
|
before.len() != after.len()
|
||||||
player.playing = property(connection, &player.owner, PLAYER, "PlaybackStatus")
|
|| before.iter().zip(after).any(|(a, b)| {
|
||||||
.await
|
(&a.name, &a.owner, a.pid, &a.desktop_entry, a.playing)
|
||||||
.ok()
|
!= (&b.name, &b.owner, b.pid, &b.desktop_entry, b.playing)
|
||||||
.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 publish(
|
#[derive(Default)]
|
||||||
players: &HashMap<String, Player>,
|
pub(crate) struct Engine {
|
||||||
previous: &mut Vec<Player>,
|
snapshot: Snapshot,
|
||||||
send: &mut impl FnMut(Vec<Player>) -> bool,
|
value: Value,
|
||||||
) -> bool {
|
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||||
let mut snapshot: Vec<_> = players.values().cloned().collect();
|
commands: Option<async_channel::Sender<Command>>,
|
||||||
snapshot.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
if snapshot == *previous {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
*previous = snapshot.clone();
|
|
||||||
send(snapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn monitor(connection: Connection, mut send: impl FnMut(Vec<Player>) -> bool) -> zbus::Result<()> {
|
impl Engine {
|
||||||
// Subscribe before discovery so startup and owner replacement cannot lose
|
pub(crate) fn value(&self) -> Value {
|
||||||
// a pause/stop event. Ignore unrelated property traffic such as position.
|
if self.value.is_null() {
|
||||||
let properties = MatchRule::builder()
|
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
|
||||||
.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<String> = 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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let Ok((interface, changed, invalidated)) =
|
self.value.clone()
|
||||||
message
|
|
||||||
.body()
|
|
||||||
.deserialize::<(String, HashMap<String, OwnedValue>, Vec<String>)>()
|
|
||||||
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) {
|
fn update(&mut self, mut snapshot: Snapshot) {
|
||||||
return Ok(());
|
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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(zbus::Error::Failure("MPRIS signal connection ended".into()))
|
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||||
|
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<Response>)>) -> 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<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
|
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (sender, receiver) = channel();
|
let (sender, receiver) = sync_channel::<Update>(1);
|
||||||
event_loop.handle().insert_source(receiver, |event, _, state| {
|
let (commands, receive_commands) = async_channel::bounded(16);
|
||||||
if let Event::Msg(players) = event {
|
event_loop.handle().insert_source(receiver, move |event, _, state| {
|
||||||
state.media_players = players;
|
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();
|
state.refresh_idle_inhibition();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
event_loop
|
||||||
|
.handle()
|
||||||
|
.insert_idle(move |state| state.media_engine.commands = Some(commands));
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ferese-playback".into())
|
.name("ferese-playback".into())
|
||||||
.spawn(move || {
|
.spawn(move || bus::run(sender, receive_commands))?;
|
||||||
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));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -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<String, OwnedValue>;
|
||||||
|
|
||||||
|
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
|
||||||
|
connection: &Connection,
|
||||||
|
member: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> zbus::Result<T> {
|
||||||
|
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<Properties> {
|
||||||
|
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::<String>()
|
||||||
|
.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<u64> {
|
||||||
|
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<f64> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<Update>, sender: &SyncSender<Update>) -> 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<Command>,
|
||||||
|
sender: &SyncSender<Update>,
|
||||||
|
) -> 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<String> = 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<zbus::Result<zbus::Message>>),
|
||||||
|
Action(Option<Command>),
|
||||||
|
}
|
||||||
|
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<String>)>()
|
||||||
|
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<Update>, commands: async_channel::Receiver<Command>) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Player>,
|
||||||
|
pub pinned: Option<String>,
|
||||||
|
pub ignored: HashSet<String>,
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), &[]));
|
||||||
|
}
|
||||||
@@ -1098,6 +1098,11 @@ impl Ferese {
|
|||||||
BindingAction::Expel => self.expel_focused_window(),
|
BindingAction::Expel => self.expel_focused_window(),
|
||||||
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
||||||
BindingAction::ToggleOverview => self.toggle_overview(),
|
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 => {
|
BindingAction::ToggleDisplayMode => {
|
||||||
self.toggle_display_mode();
|
self.toggle_display_mode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
state.portal_shortcuts.remove(owner);
|
state.portal_shortcuts.remove(owner);
|
||||||
state.portal_session.remove(owner);
|
state.portal_session.remove(owner);
|
||||||
state.theme_engine.remove(owner);
|
state.theme_engine.remove(owner);
|
||||||
|
state.media_engine.remove(owner);
|
||||||
state.refresh_idle_inhibition();
|
state.refresh_idle_inhibition();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,37 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
"Missing capture session",
|
"Missing capture session",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
} else if matches!(call.request.command.as_str(), "media-watch" | "media-action") {
|
||||||
|
if let Err(error) = validate_request(&call.request) {
|
||||||
|
let _ = call
|
||||||
|
.response
|
||||||
|
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||||
|
} else if state.session_lock.active() {
|
||||||
|
let _ = call.response.try_send(Response::error(
|
||||||
|
call.request.id,
|
||||||
|
"session_locked",
|
||||||
|
"IPC unavailable while session is locked",
|
||||||
|
));
|
||||||
|
} else if call.request.command == "media-watch" {
|
||||||
|
if let Some(since) = call.request.args["since"].as_u64() {
|
||||||
|
state
|
||||||
|
.media_engine
|
||||||
|
.watch(call.owner, call.request.id, since, call.response);
|
||||||
|
} else {
|
||||||
|
let _ = call.response.try_send(Response::error(
|
||||||
|
call.request.id,
|
||||||
|
"invalid_argument",
|
||||||
|
"Missing media revision",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if let Err(error) = state
|
||||||
|
.media_engine
|
||||||
|
.action(call.request.args, Some((call.request.id, call.response.clone())))
|
||||||
|
{
|
||||||
|
let _ = call
|
||||||
|
.response
|
||||||
|
.try_send(Response::error(call.request.id, "media_unavailable", error));
|
||||||
|
}
|
||||||
} else if call.request.command == "theme-watch" {
|
} else if call.request.command == "theme-watch" {
|
||||||
if let Err(error) = validate_request(&call.request) {
|
if let Err(error) = validate_request(&call.request) {
|
||||||
let _ = call
|
let _ = call
|
||||||
@@ -741,6 +773,7 @@ impl Ferese {
|
|||||||
"close" => self.close_focused_window(),
|
"close" => self.close_focused_window(),
|
||||||
"get-focused-window" => return Ok(self.focused_window_json()),
|
"get-focused-window" => return Ok(self.focused_window_json()),
|
||||||
"get-windows" => return Ok(self.windows_json()),
|
"get-windows" => return Ok(self.windows_json()),
|
||||||
|
"media-get" => return Ok(self.media_engine.value()),
|
||||||
"get-idle-inhibition" => {
|
"get-idle-inhibition" => {
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"inhibited": self.idle_notifier_state.is_inhibited(),
|
"inhibited": self.idle_notifier_state.is_inhibited(),
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ pub struct Ferese {
|
|||||||
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
||||||
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
|
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
|
||||||
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
|
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
|
||||||
|
pub(crate) media_engine: crate::idle_inhibition::media::Engine,
|
||||||
pub(crate) automatic_idle_inhibited: bool,
|
pub(crate) automatic_idle_inhibited: bool,
|
||||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||||
pub direct_backend: Option<DirectBackendState>,
|
pub direct_backend: Option<DirectBackendState>,
|
||||||
@@ -719,6 +720,7 @@ impl Ferese {
|
|||||||
idle_inhibitors: HashMap::new(),
|
idle_inhibitors: HashMap::new(),
|
||||||
idle_inhibit: config.idle_inhibit,
|
idle_inhibit: config.idle_inhibit,
|
||||||
media_players: Vec::new(),
|
media_players: Vec::new(),
|
||||||
|
media_engine: Default::default(),
|
||||||
automatic_idle_inhibited: false,
|
automatic_idle_inhibited: false,
|
||||||
active_shortcuts_inhibitor: None,
|
active_shortcuts_inhibitor: None,
|
||||||
direct_backend: None,
|
direct_backend: None,
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ rust-version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||||
@@ -5,6 +5,7 @@ use std::{fmt, io};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub mod media;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
pub const VERSION: u32 = 1;
|
pub const VERSION: u32 = 1;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub track_id: Option<String>,
|
||||||
|
pub status: Playback,
|
||||||
|
pub position_us: Option<u64>,
|
||||||
|
pub sampled_at_us: u64,
|
||||||
|
pub length_us: Option<u64>,
|
||||||
|
pub rate: f64,
|
||||||
|
pub volume: Option<f64>,
|
||||||
|
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<u64> {
|
||||||
|
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<Player>,
|
||||||
|
pub players: Vec<Choice>,
|
||||||
|
pub pinned: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,9 +78,16 @@ impl Connection {
|
|||||||
command: command.into(),
|
command: command.into(),
|
||||||
args,
|
args,
|
||||||
};
|
};
|
||||||
write_frame(&mut self.stream, &request).map_err(|e| e.to_string())?;
|
let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::<Response>(&mut self.stream));
|
||||||
let response: Response = read_frame(&mut self.stream).map_err(|e| e.to_string())?;
|
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 {
|
if response.id != self.id || response.version != VERSION {
|
||||||
|
let _ = self.stream.shutdown(Shutdown::Both);
|
||||||
return Err("Unexpected theme IPC response".into());
|
return Err("Unexpected theme IPC response".into());
|
||||||
}
|
}
|
||||||
if let Some(error) = response.error {
|
if let Some(error) = response.error {
|
||||||
@@ -98,8 +105,17 @@ impl Connection {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
fallback_families: impl FnOnce() -> Vec<Family>,
|
fallback_families: impl FnOnce() -> Vec<Family>,
|
||||||
) -> Result<Snapshot, String> {
|
) -> Result<Snapshot, String> {
|
||||||
|
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<Value, String> {
|
||||||
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Next">
|
||||||
|
<path d="M21 4v16"/>
|
||||||
|
<path d="M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Pause">
|
||||||
|
<rect x="15" y="12" width="10" height="36" rx="3" fill="currentColor"/><rect x="35" y="12" width="10" height="36" rx="3" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Play">
|
||||||
|
<path d="M21 12q-3-2-3 2v32q0 4 3 2l27-16q3-2 0-4Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 171 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Previous">
|
||||||
|
<path d="M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z"/>
|
||||||
|
<path d="M3 20V4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Media">
|
||||||
|
<path d="M22 39V14l25-5v25M22 23l25-5" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/><ellipse cx="15" cy="41" rx="8" ry="6" fill="#3d7be6"/><ellipse cx="40" cy="36" rx="8" ry="6" fill="#3d7be6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
@@ -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 DND: &[u8] = include_bytes!("../assets/icons/status/dnd.svg");
|
||||||
pub const ETHERNET: &[u8] = include_bytes!("../assets/icons/status/ethernet.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 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_MUTE: &[u8] = include_bytes!("../assets/icons/status/mic-mute.svg");
|
||||||
pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg");
|
pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg");
|
||||||
pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg");
|
pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg");
|
||||||
|
|||||||
@@ -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.
|
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
|
## Login items and locking
|
||||||
|
|
||||||
| `autostart` key | Type | Default | Meaning |
|
| `autostart` key | Type | Default | Meaning |
|
||||||
|
|||||||
+34
-1
@@ -152,6 +152,19 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
|||||||
.map_err(|_| format!("{} requires a positive workspace index", command))?;
|
.map_err(|_| format!("{} requires a positive workspace index", command))?;
|
||||||
json!({ "index": index })
|
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() {
|
"screenshot" => match positional.as_slice() {
|
||||||
[] => json!({}),
|
[] => json!({}),
|
||||||
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
|
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
|
||||||
@@ -220,13 +233,33 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".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 <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn display_mode_commands_validate_layouts() {
|
fn display_mode_commands_validate_layouts() {
|
||||||
for layout in ["internal-only", "external-only", "extend", "mirror"] {
|
for layout in ["internal-only", "external-only", "extend", "mirror"] {
|
||||||
|
|||||||
+103
@@ -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 <stdbool.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#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,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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 <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#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
|
||||||
|
* <pre>
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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
|
||||||
@@ -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 <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#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
|
||||||
|
* <pre>
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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
|
||||||
@@ -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'<node><interface name="{ROOT}">' + ''.join(
|
||||||
|
f'<property name="{key}" type="{kind}" access="read"/>'
|
||||||
|
for key, kind in [('Identity', 's'), ('DesktopEntry', 's'), ('CanRaise', 'b')])
|
||||||
|
xml += '<method name="Raise"/></interface>' + f'<interface name="{PLAYER}">'
|
||||||
|
xml += ''.join(f'<property name="{key}" type="{kind}" access="{ "readwrite" if key == "Volume" else "read" }"/>' for key, kind in properties)
|
||||||
|
xml += '''<method name="PlayPause"/><method name="Next"/><method name="Previous"/>
|
||||||
|
<method name="SetPosition"><arg type="o" direction="in"/><arg type="x" direction="in"/></method>
|
||||||
|
<signal name="Seeked"><arg type="x"/></signal></interface></node>'''
|
||||||
|
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()
|
||||||
@@ -25,3 +25,7 @@ tokio = { version = "1", features = ["time", "rt", "sync"] }
|
|||||||
wayland-client.workspace = true
|
wayland-client.workspace = true
|
||||||
zbus = "5.19.0"
|
zbus = "5.19.0"
|
||||||
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
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"] }
|
||||||
@@ -225,7 +225,11 @@ impl FereseShell {
|
|||||||
.and_then(|output| output.size)
|
.and_then(|output| output.size)
|
||||||
.map_or(0., |(width, _)| width as f32)
|
.map_or(0., |(width, _)| width as f32)
|
||||||
- 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal 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. {
|
let title = if self.config.status.window_title && title_width > 0. {
|
||||||
focused_bar_title(&self.snapshot, focused_output)
|
focused_bar_title(&self.snapshot, focused_output)
|
||||||
} else {
|
} else {
|
||||||
@@ -270,7 +274,7 @@ impl FereseShell {
|
|||||||
container(left).width(Length::Fill),
|
container(left).width(Length::Fill),
|
||||||
center,
|
center,
|
||||||
container(right)
|
container(right)
|
||||||
.width(Length::Fill)
|
.width(Length::FillPortion(right_portion))
|
||||||
.align_x(alignment::Horizontal::Right),
|
.align_x(alignment::Horizontal::Right),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod config;
|
|||||||
mod control;
|
mod control;
|
||||||
mod display_mode;
|
mod display_mode;
|
||||||
mod keybinding_guide;
|
mod keybinding_guide;
|
||||||
|
mod media;
|
||||||
mod motion;
|
mod motion;
|
||||||
mod note_store;
|
mod note_store;
|
||||||
mod notification_ui;
|
mod notification_ui;
|
||||||
@@ -178,6 +179,7 @@ struct FereseShell {
|
|||||||
status_service: status::Service,
|
status_service: status::Service,
|
||||||
status: status::Snapshot,
|
status: status::Snapshot,
|
||||||
recorder: recording::Recorder,
|
recorder: recording::Recorder,
|
||||||
|
media: media::Model,
|
||||||
calendar_offset: i32,
|
calendar_offset: i32,
|
||||||
status_error: Option<status::ActionError>,
|
status_error: Option<status::ActionError>,
|
||||||
theme_error: Option<String>,
|
theme_error: Option<String>,
|
||||||
@@ -246,6 +248,14 @@ enum Message {
|
|||||||
ClockChanged(clock::Labels),
|
ClockChanged(clock::Labels),
|
||||||
RecorderEvent(recording::Event),
|
RecorderEvent(recording::Event),
|
||||||
RecorderElapsed,
|
RecorderElapsed,
|
||||||
|
MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>),
|
||||||
|
MediaAction(serde_json::Value),
|
||||||
|
MediaCompleted(Result<(), String>),
|
||||||
|
MediaTick,
|
||||||
|
MediaChoose,
|
||||||
|
MediaSeek(u64),
|
||||||
|
MediaSeekCommit,
|
||||||
|
MediaArt(media::Artwork),
|
||||||
ControlReady,
|
ControlReady,
|
||||||
ActivateWorkspace(u64),
|
ActivateWorkspace(u64),
|
||||||
ToggleOverview,
|
ToggleOverview,
|
||||||
@@ -316,6 +326,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||||
status: status::Snapshot::default(),
|
status: status::Snapshot::default(),
|
||||||
recorder: recording::Recorder::default(),
|
recorder: recording::Recorder::default(),
|
||||||
|
media: media::Model::default(),
|
||||||
calendar_offset: 0,
|
calendar_offset: 0,
|
||||||
status_error: None,
|
status_error: None,
|
||||||
theme_error: None,
|
theme_error: None,
|
||||||
@@ -393,6 +404,9 @@ impl cosmic::Application for FereseShell {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.map(Message::ClockChanged),
|
.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.subscription().map(Message::RecorderEvent),
|
||||||
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
|
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
|
||||||
self.status_service.subscription().map(Message::StatusUpdated),
|
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) => {
|
Message::ThemeChanged(snapshot) => {
|
||||||
let mut config = self.config.clone();
|
let mut config = self.config.clone();
|
||||||
config.apply_theme(&snapshot.presented);
|
config.apply_theme(&snapshot.presented);
|
||||||
@@ -787,6 +840,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
if let Some(snapshot) = poll.snapshot {
|
if let Some(snapshot) = poll.snapshot {
|
||||||
self.snapshot = snapshot;
|
self.snapshot = snapshot;
|
||||||
let mut tasks = vec![reload_task];
|
let mut tasks = vec![reload_task];
|
||||||
|
let mut visibility_changed = false;
|
||||||
|
|
||||||
for entry in &mut self.outputs {
|
for entry in &mut self.outputs {
|
||||||
let output = self
|
let output = self
|
||||||
@@ -802,6 +856,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry.hidden = hidden;
|
entry.hidden = hidden;
|
||||||
|
visibility_changed = true;
|
||||||
if let Some(effects) = &entry.effects
|
if let Some(effects) = &entry.effects
|
||||||
&& let Err(error) = effects.set_visible(!hidden)
|
&& let Err(error) = effects.set_visible(!hidden)
|
||||||
{
|
{
|
||||||
@@ -815,6 +870,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
if self.bar_hidden(self.bar_surface_id) {
|
if self.bar_hidden(self.bar_surface_id) {
|
||||||
tasks.push(self.destroy_menu());
|
tasks.push(self.destroy_menu());
|
||||||
}
|
}
|
||||||
|
if visibility_changed {
|
||||||
|
self.refresh_media_art(false);
|
||||||
|
}
|
||||||
|
|
||||||
reload_task = Task::batch(tasks);
|
reload_task = Task::batch(tasks);
|
||||||
}
|
}
|
||||||
for command in poll.commands {
|
for command in poll.commands {
|
||||||
|
|||||||
@@ -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<Snapshot>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub choosing: bool,
|
||||||
|
pub seeking: Option<u64>,
|
||||||
|
pub now_us: u64,
|
||||||
|
pub art: art::Service,
|
||||||
|
pub artwork: Option<Artwork>,
|
||||||
|
busy: bool,
|
||||||
|
pending: VecDeque<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Arc<Snapshot>> {
|
||||||
|
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<Snapshot>) {
|
||||||
|
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<Message> {
|
||||||
|
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<Message> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<image::Handle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct Key {
|
||||||
|
uri: String,
|
||||||
|
owner: String,
|
||||||
|
track_id: Option<String>,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
fn from_player(player: &ferese_ipc::media::Player) -> Option<Self> {
|
||||||
|
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<Key>,
|
||||||
|
stopped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Service {
|
||||||
|
queue: Arc<(Mutex<Queue>, Condvar)>,
|
||||||
|
receive: Updates,
|
||||||
|
requested: Option<Key>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Artwork> {
|
||||||
|
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<Queue>, Condvar)>,
|
||||||
|
send: tokio::sync::watch::Sender<Option<Artwork>>,
|
||||||
|
mut load: impl FnMut(&str) -> Option<image::Handle>,
|
||||||
|
) {
|
||||||
|
let mut cache: VecDeque<Artwork> = 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<tokio::sync::watch::Receiver<Option<Artwork>>>);
|
||||||
|
|
||||||
|
impl std::hash::Hash for Updates {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
std::ptr::hash(Arc::as_ptr(&self.0), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(receive: &Updates) -> impl cosmic::iced::futures::Stream<Item = Artwork> + 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<Vec<u8>, 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<image::Handle, String> {
|
||||||
|
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<u8>) -> Result<image::Handle, String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Arc<Snapshot>> {
|
||||||
|
Subscription::run(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
|
||||||
|
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::<Snapshot>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,12 +133,45 @@ pub(crate) fn button<'a, M: Clone + 'a>(
|
|||||||
selected: bool,
|
selected: bool,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
) -> Element<'a, M> {
|
) -> 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| {
|
let paint = move |progress: f32, pressed: bool| {
|
||||||
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
|
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<cosmic::iced::Color>,
|
||||||
|
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
|
let content = button
|
||||||
.class(cosmic::theme::Button::Custom {
|
.class(cosmic::theme::Button::Custom {
|
||||||
active: Box::new(move |_, _| paint(active.get(), false)),
|
active: Box::new(move |_, _| paint(active.get(), false)),
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ impl FereseShell {
|
|||||||
let theme = self.config.theme.for_bar();
|
let theme = self.config.theme.for_bar();
|
||||||
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
|
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
|
||||||
let metrics = BarMetrics::from(theme);
|
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 [
|
for kind in [
|
||||||
Menu::System,
|
Menu::System,
|
||||||
Menu::Network,
|
Menu::Network,
|
||||||
@@ -157,6 +161,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|
|||||||
},
|
},
|
||||||
Menu::Calendar => "Calendar".into(),
|
Menu::Calendar => "Calendar".into(),
|
||||||
Menu::Recording => "Screen recording".into(),
|
Menu::Recording => "Screen recording".into(),
|
||||||
|
Menu::Media => "Now playing".into(),
|
||||||
Menu::Battery => s.battery.as_ref().map_or_else(
|
Menu::Battery => s.battery.as_ref().map_or_else(
|
||||||
|| "Battery: unavailable".into(),
|
|| "Battery: unavailable".into(),
|
||||||
|b| format!("Battery: {}%, {}", b.percent, b.status),
|
|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::Calendar => (ferese_theme::icons::CALENDAR, true),
|
||||||
Menu::Recording => (ferese_theme::icons::RECORD, true),
|
Menu::Recording => (ferese_theme::icons::RECORD, true),
|
||||||
|
Menu::Media => (ferese_theme::icons::MEDIA, true),
|
||||||
Menu::Battery => {
|
Menu::Battery => {
|
||||||
let Some(b) = &s.battery else {
|
let Some(b) = &s.battery else {
|
||||||
return (ferese_theme::icons::BATTERY_EMPTY, false);
|
return (ferese_theme::icons::BATTERY_EMPTY, false);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ impl FereseShell {
|
|||||||
if let Some(menu) = &mut self.menu
|
if let Some(menu) = &mut self.menu
|
||||||
&& menu.motion.closing()
|
&& 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());
|
menu.motion.retarget(1.0, Instant::now());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -32,6 +37,16 @@ impl FereseShell {
|
|||||||
return self.close_menu();
|
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 {
|
if kind == Menu::Calendar {
|
||||||
self.calendar_offset = 0;
|
self.calendar_offset = 0;
|
||||||
}
|
}
|
||||||
@@ -122,6 +137,7 @@ impl FereseShell {
|
|||||||
let Some(menu) = self.menu.take() else {
|
let Some(menu) = self.menu.take() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
self.refresh_media_art(false);
|
||||||
if menu.kind == Menu::Notifications {
|
if menu.kind == Menu::Notifications {
|
||||||
self.notifications.history_open = false;
|
self.notifications.history_open = false;
|
||||||
self.notifications.hovered = None;
|
self.notifications.hovered = None;
|
||||||
@@ -133,6 +149,7 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn close_menu(&mut self) -> Task<Message> {
|
pub fn close_menu(&mut self) -> Task<Message> {
|
||||||
|
self.refresh_media_art(false);
|
||||||
let Some(menu) = &mut self.menu else {
|
let Some(menu) = &mut self.menu else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<Element<'a, cosmic::Action<Message>>>,
|
||||||
|
style: MenuStyle,
|
||||||
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
|
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<Message>> {
|
||||||
|
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<Message>> =
|
||||||
|
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<Message>> {
|
||||||
|
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<Message>> =
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod bluetooth;
|
|||||||
mod calendar;
|
mod calendar;
|
||||||
mod controls;
|
mod controls;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod media;
|
||||||
mod network;
|
mod network;
|
||||||
mod system;
|
mod system;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ pub enum Menu {
|
|||||||
Battery,
|
Battery,
|
||||||
Calendar,
|
Calendar,
|
||||||
Recording,
|
Recording,
|
||||||
|
Media,
|
||||||
Notifications,
|
Notifications,
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ impl Menu {
|
|||||||
Self::System => 360.0,
|
Self::System => 360.0,
|
||||||
Self::Battery => 328.0,
|
Self::Battery => 328.0,
|
||||||
Self::Calendar => 268.0,
|
Self::Calendar => 268.0,
|
||||||
Self::Notifications => 368.0,
|
Self::Notifications | Self::Media => 368.0,
|
||||||
_ => 300.0,
|
_ => 300.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,7 @@ impl Menu {
|
|||||||
Self::Battery => "Battery",
|
Self::Battery => "Battery",
|
||||||
Self::Calendar => "Calendar",
|
Self::Calendar => "Calendar",
|
||||||
Self::Recording => "Screen recording",
|
Self::Recording => "Screen recording",
|
||||||
|
Self::Media => "Now playing",
|
||||||
Self::Notifications => "Notifications",
|
Self::Notifications => "Notifications",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +91,7 @@ impl Menu {
|
|||||||
Self::Bluetooth => status.bluetooth.is_some(),
|
Self::Bluetooth => status.bluetooth.is_some(),
|
||||||
Self::Audio => status.audio.is_some(),
|
Self::Audio => status.audio.is_some(),
|
||||||
Self::Battery => status.battery.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::Notifications => status.notifications.is_some(),
|
||||||
Self::System => true,
|
Self::System => true,
|
||||||
}
|
}
|
||||||
@@ -216,6 +219,7 @@ impl FereseShell {
|
|||||||
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
|
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
|
||||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||||
Menu::Recording => rows,
|
Menu::Recording => rows,
|
||||||
|
Menu::Media => media::view(self, rows, style),
|
||||||
};
|
};
|
||||||
if kind != Menu::Calendar && !kind.available(&self.status) {
|
if kind != Menu::Calendar && !kind.available(&self.status) {
|
||||||
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
|
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ impl FereseShell {
|
|||||||
if matches!(event, wayland::OutputEvent::Removed) {
|
if matches!(event, wayland::OutputEvent::Removed) {
|
||||||
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
||||||
let entry = self.outputs.remove(index);
|
let entry = self.outputs.remove(index);
|
||||||
|
self.refresh_media_art(false);
|
||||||
let menu = if self.bar_surface_id == entry.bar {
|
let menu = if self.bar_surface_id == entry.bar {
|
||||||
self.destroy_menu()
|
self.destroy_menu()
|
||||||
} else {
|
} else {
|
||||||
@@ -101,6 +102,7 @@ impl FereseShell {
|
|||||||
size,
|
size,
|
||||||
hidden,
|
hidden,
|
||||||
});
|
});
|
||||||
|
self.refresh_media_art(false);
|
||||||
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||||
|_| Default::default(),
|
|_| Default::default(),
|
||||||
move |_| SctkLayerSurfaceSettings {
|
move |_| SctkLayerSurfaceSettings {
|
||||||
|
|||||||
Reference in new issue
Block a user