mpris and bar control impl

gitignore

fix import error
This commit is contained in:
Samuel Onoja committed 2026-10-05 16:12:24 +01:00
1 parent 73f21e20ce
commit 8a0dd7615e
42 files changed
+4022 -223

No files matched your search

-1
View File
@@ -1,7 +1,6 @@
/target/
/tools/
*.log
ChatGPT Image*.png
# Local architecture decision notes
/docs/adr/
Generated
+101 -1
View File
@@ -902,7 +902,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [
"serde",
"termcolor",
"unicode-width 0.1.14",
"unicode-width 0.2.2",
]
[[package]]
@@ -1648,6 +1648,7 @@ name = "ferese"
version = "0.1.0"
dependencies = [
"ab_glyph",
"async-channel",
"calloop 0.14.4",
"ferese-animation",
"ferese-config",
@@ -1706,6 +1707,7 @@ name = "ferese-ipc"
version = "0.1.0"
dependencies = [
"ferese-theme-model",
"libc",
"serde",
"serde_json",
]
@@ -1793,6 +1795,7 @@ source = "git+https://github.com/ferese-wm/ferese-shape?tag=v0.1.0#63d7b6dfd520d
name = "ferese-shell"
version = "0.1.0"
dependencies = [
"base64",
"ferese-animation",
"ferese-config",
"ferese-ipc",
@@ -1800,6 +1803,7 @@ dependencies = [
"ferese-theme",
"ferese-theme-client",
"iced_accessibility",
"image",
"jiff",
"libc",
"libcosmic",
@@ -1808,6 +1812,8 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"ureq",
"url",
"wayland-client",
"zbus",
]
@@ -5003,6 +5009,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.2"
@@ -5105,6 +5125,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -5577,6 +5632,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svg_fmt"
version = "0.4.5"
@@ -6143,6 +6204,27 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -6499,6 +6581,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
+1
View File
@@ -92,6 +92,7 @@ Shortcuts** whenever you want to change a shortcut or gesture.
| --- | --- |
| Super + Enter | Open a terminal |
| Display Fn key (`XF86Display`) | Choose the display mode |
| Media Play / Pause, Next, Previous | Control the selected MPRIS player |
| Super + H / J / K / L | Focus left / down / up / right |
| Super + Backspace | Return to the last focused window |
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
+1
View File
@@ -17,6 +17,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
futures-lite = "2.6.1"
async-channel = "2.5.0"
libc.workspace = true
jiff.workspace = true
notify = "8.2.0"
+27 -3
View File
@@ -971,13 +971,37 @@ mod tests {
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
}
#[test]
fn native_media_keys_share_the_selected_player_and_reject_arguments() {
let defaults = Config::default().runtime_config().unwrap();
for (key, name, action) in [
(keysyms::KEY_XF86AudioPlay, "media-play-pause", "play-pause"),
(keysyms::KEY_XF86AudioPause, "media-play-pause", "play-pause"),
(keysyms::KEY_XF86AudioNext, "media-next", "next"),
(keysyms::KEY_XF86AudioPrev, "media-previous", "previous"),
] {
assert!(
defaults
.bindings
.iter()
.any(|binding| binding.trigger == BindingTrigger::Keysym(key)
&& binding.action == BindingAction::Media(action))
);
assert_eq!(
parse_action(name, None, &HashMap::new()).unwrap(),
BindingAction::Media(action)
);
assert!(parse_action(name, Some("unexpected"), &HashMap::new()).is_err());
}
}
#[test]
fn supplies_complete_v0_bindings_and_terminal_command() {
let config = parse("");
let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 54);
assert_eq!(bindings.len(), 58);
for (shift, action) in [
(false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen),
@@ -1021,7 +1045,7 @@ mod tests {
let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 54);
assert_eq!(bindings.len(), 58);
assert!(bindings.iter().any(|binding| {
binding.action
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
@@ -1029,7 +1053,7 @@ mod tests {
let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 53);
assert_eq!(bindings.len(), 57);
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
}
+19
View File
@@ -336,6 +336,14 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::ToggleOverview)
}
"media-play-pause" | "media-next" | "media-previous" => {
no_argument()?;
Ok(BindingAction::Media(match action {
"media-next" => "next",
"media-previous" => "previous",
_ => "play-pause",
}))
}
"toggle-display-mode" => {
no_argument()?;
Ok(BindingAction::ToggleDisplayMode)
@@ -385,6 +393,10 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
binding("Super+Shift+Space", "toggle-floating", None),
binding("Super+Tab", "toggle-overview", None),
binding("XF86Display", "toggle-display-mode", None),
binding("XF86AudioPlay", "media-play-pause", None),
binding("XF86AudioPause", "media-play-pause", None),
binding("XF86AudioNext", "media-next", None),
binding("XF86AudioPrev", "media-previous", None),
binding("Super+F1", "toggle-keybinding-guide", None),
binding("Super+Escape", "workspace-back-and-forth", None),
binding("Super+BackSpace", "focus-last-window", None),
@@ -473,6 +485,7 @@ pub enum BindingAction {
ToggleOverview,
ToggleKeybindingGuide,
ToggleDisplayMode,
Media(&'static str),
}
impl Binding {
@@ -581,6 +594,12 @@ impl Binding {
BindingAction::Consume => "Join window into column".into(),
BindingAction::Expel => "Move window out of column".into(),
BindingAction::ToggleFloating => "Toggle floating window".into(),
BindingAction::Media(action) => match *action {
"next" => "Next track",
"previous" => "Previous track",
_ => "Play or pause media",
}
.into(),
BindingAction::ToggleOverview => "Open or close overview".into(),
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
+1
View File
@@ -224,6 +224,7 @@ mod tests {
pid: Some(42),
desktop_entry: Some("player".into()),
playing: true,
..Default::default()
};
let window = AppWindow {
id: WindowId(1),
+85 -206
View File
@@ -1,245 +1,124 @@
//! MPRIS reads and signal handling run off the compositor thread, without polling.
mod bus;
mod policy;
use std::collections::HashMap;
use std::time::Duration;
use std::sync::mpsc;
use std::time::Instant;
use calloop::EventLoop;
use calloop::channel::{Event, channel};
use futures_lite::{StreamExt, future, stream};
use zbus::zvariant::OwnedValue;
use zbus::{Connection, MatchRule, MessageStream};
use calloop::channel::{Event, sync_channel};
use ferese_ipc::{Response, media::Snapshot};
use serde_json::Value;
use crate::Ferese;
const PREFIX: &str = "org.mpris.MediaPlayer2";
const PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER: &str = "org.mpris.MediaPlayer2.Player";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
const MAX_PLAYERS: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Player {
pub name: String,
pub owner: String,
pub pid: Option<u32>,
pub desktop_entry: Option<String>,
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>(
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()
pub(crate) struct Command {
args: Value,
sent: Instant,
reply: Option<(u64, mpsc::SyncSender<Response>)>,
}
async fn property(connection: &Connection, owner: &str, interface: &str, name: &str) -> zbus::Result<OwnedValue> {
connection
.call_method(Some(owner), PATH, Some(PROPERTIES), "Get", &(interface, name))
.await?
.body()
.deserialize()
struct Update {
players: Vec<Player>,
snapshot: Snapshot,
}
async fn refresh_player(connection: &Connection, player: &mut Player, identity: bool) {
// Bind reads to the unique owner, never a name that can change mid-query.
player.playing = property(connection, &player.owner, PLAYER, "PlaybackStatus")
.await
.ok()
.and_then(|value| String::try_from(value).ok())
.is_some_and(|status| status == "Playing");
if identity {
player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner)
.await
.ok();
player.desktop_entry = property(connection, &player.owner, PREFIX, "DesktopEntry")
.await
.ok()
.and_then(|value| String::try_from(value).ok())
.map(|value| crate::window_rules::normalize_app_id(&value))
.filter(|value| !value.is_empty());
}
fn inhibition_changed(before: &[Player], after: &[Player]) -> bool {
before.len() != after.len()
|| before.iter().zip(after).any(|(a, b)| {
(&a.name, &a.owner, a.pid, &a.desktop_entry, a.playing)
!= (&b.name, &b.owner, b.pid, &b.desktop_entry, b.playing)
})
}
fn publish(
players: &HashMap<String, Player>,
previous: &mut Vec<Player>,
send: &mut impl FnMut(Vec<Player>) -> bool,
) -> bool {
let mut snapshot: Vec<_> = players.values().cloned().collect();
snapshot.sort_unstable_by(|a, b| a.name.cmp(&b.name));
if snapshot == *previous {
return true;
}
*previous = snapshot.clone();
send(snapshot)
#[derive(Default)]
pub(crate) struct Engine {
snapshot: Snapshot,
value: Value,
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
commands: Option<async_channel::Sender<Command>>,
}
async fn monitor(connection: Connection, mut send: impl FnMut(Vec<Player>) -> bool) -> zbus::Result<()> {
// Subscribe before discovery so startup and owner replacement cannot lose
// a pause/stop event. Ignore unrelated property traffic such as position.
let properties = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.path(PATH)?
.interface(PROPERTIES)?
.member("PropertiesChanged")?
.build();
let owners = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")?
.interface("org.freedesktop.DBus")?
.member("NameOwnerChanged")?
.arg0ns(PREFIX)?
.build();
let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?;
let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?;
let mut signals = stream::or(properties, owners);
let mut players = HashMap::new();
let mut previous = Vec::new();
let names: Vec<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);
}
impl Engine {
pub(crate) fn value(&self) -> Value {
if self.value.is_null() {
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
} else {
let Ok((interface, changed, invalidated)) =
message
.body()
.deserialize::<(String, HashMap<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;
self.value.clone()
}
}
if !publish(&players, &mut previous, &mut send) {
return Ok(());
fn update(&mut self, mut snapshot: Snapshot) {
snapshot.revision = self.snapshot.revision;
if snapshot == self.snapshot {
return;
}
snapshot.revision = snapshot.revision.wrapping_add(1);
self.value = serde_json::to_value(&snapshot).expect("serializable media snapshot");
self.snapshot = snapshot;
for (_, (id, response)) in self.waiters.drain() {
let _ = response.try_send(Response::success(id, self.value.clone()));
}
}
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>> {
let (sender, receiver) = channel();
event_loop.handle().insert_source(receiver, |event, _, state| {
if let Event::Msg(players) = event {
state.media_players = players;
let (sender, receiver) = sync_channel::<Update>(1);
let (commands, receive_commands) = async_channel::bounded(16);
event_loop.handle().insert_source(receiver, move |event, _, state| {
if let Event::Msg(update) = event {
let changed = inhibition_changed(&state.media_players, &update.players);
state.media_players = update.players;
state.media_engine.update(update.snapshot);
if changed {
state.refresh_idle_inhibition();
}
}
})?;
event_loop
.handle()
.insert_idle(move |state| state.media_engine.commands = Some(commands));
std::thread::Builder::new()
.name("ferese-playback".into())
.spawn(move || {
let mut had_players = false;
loop {
let result = future::block_on(async {
let connection = zbus::connection::Builder::session()?
.method_timeout(Duration::from_millis(500))
.build()
.await?;
monitor(connection, |players| {
had_players = !players.is_empty();
sender.send(players).is_ok()
})
.await
});
match result {
Ok(()) => break,
Err(error) => {
tracing::debug!(%error, "playback monitor disconnected; releasing automatic inhibition")
}
}
if had_players && sender.send(Vec::new()).is_err() {
break;
}
had_players = false;
// Reconnect only after failure; healthy operation sleeps on signals.
std::thread::sleep(Duration::from_secs(5));
}
})?;
.spawn(move || bus::run(sender, receive_commands))?;
Ok(())
}
#[cfg(test)]
mod tests;
+492
View File
@@ -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), &[]));
}
+5
View File
@@ -1098,6 +1098,11 @@ impl Ferese {
BindingAction::Expel => self.expel_focused_window(),
BindingAction::ToggleFloating => self.toggle_focused_floating(),
BindingAction::ToggleOverview => self.toggle_overview(),
BindingAction::Media(action) => {
if let Err(error) = self.media_engine.action(serde_json::json!({"action": action}), None) {
tracing::debug!(%error, "media action unavailable");
}
}
BindingAction::ToggleDisplayMode => {
self.toggle_display_mode();
}
+33
View File
@@ -121,6 +121,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
state.portal_shortcuts.remove(owner);
state.portal_session.remove(owner);
state.theme_engine.remove(owner);
state.media_engine.remove(owner);
state.refresh_idle_inhibition();
return;
}
@@ -147,6 +148,37 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
"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" {
if let Err(error) = validate_request(&call.request) {
let _ = call
@@ -741,6 +773,7 @@ impl Ferese {
"close" => self.close_focused_window(),
"get-focused-window" => return Ok(self.focused_window_json()),
"get-windows" => return Ok(self.windows_json()),
"media-get" => return Ok(self.media_engine.value()),
"get-idle-inhibition" => {
return Ok(json!({
"inhibited": self.idle_notifier_state.is_inhibited(),
+2
View File
@@ -474,6 +474,7 @@ pub struct Ferese {
pub idle_inhibitors: HashMap<WlSurface, usize>,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
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 active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>,
@@ -719,6 +720,7 @@ impl Ferese {
idle_inhibitors: HashMap::new(),
idle_inhibit: config.idle_inhibit,
media_players: Vec::new(),
media_engine: Default::default(),
automatic_idle_inhibited: false,
active_shortcuts_inhibitor: None,
direct_backend: None,
+1
View File
@@ -8,4 +8,5 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
libc.workspace = true
ferese-theme-model = { path = "../ferese-theme-model" }
+1
View File
@@ -5,6 +5,7 @@ use std::{fmt, io};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub mod media;
pub mod theme;
pub const VERSION: u32 = 1;
+146
View File
@@ -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);
}
}
+19 -3
View File
@@ -78,9 +78,16 @@ impl Connection {
command: command.into(),
args,
};
write_frame(&mut self.stream, &request).map_err(|e| e.to_string())?;
let response: Response = read_frame(&mut self.stream).map_err(|e| e.to_string())?;
let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::<Response>(&mut self.stream));
let response = match response {
Ok(response) => response,
Err(error) => {
let _ = self.stream.shutdown(Shutdown::Both);
return Err(error.to_string());
}
};
if response.id != self.id || response.version != VERSION {
let _ = self.stream.shutdown(Shutdown::Both);
return Err("Unexpected theme IPC response".into());
}
if let Some(error) = response.error {
@@ -98,8 +105,17 @@ impl Connection {
revision: u64,
fallback_families: impl FnOnce() -> Vec<Family>,
) -> 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())?;
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

+5
View File
@@ -32,6 +32,11 @@ pub const CONTROL_CENTER: &[u8] = include_bytes!("../assets/icons/status/control
pub const DND: &[u8] = include_bytes!("../assets/icons/status/dnd.svg");
pub const ETHERNET: &[u8] = include_bytes!("../assets/icons/status/ethernet.svg");
pub const LOCK: &[u8] = include_bytes!("../assets/icons/status/lock.svg");
pub const MEDIA: &[u8] = include_bytes!("../assets/icons/status/media.svg");
pub const MEDIA_PLAY: &[u8] = include_bytes!("../assets/icons/status/media-play.svg");
pub const MEDIA_PAUSE: &[u8] = include_bytes!("../assets/icons/status/media-pause.svg");
pub const MEDIA_NEXT: &[u8] = include_bytes!("../assets/icons/status/media-next.svg");
pub const MEDIA_PREVIOUS: &[u8] = include_bytes!("../assets/icons/status/media-previous.svg");
pub const MIC_MUTE: &[u8] = include_bytes!("../assets/icons/status/mic-mute.svg");
pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg");
pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg");
+46
View File
@@ -802,6 +802,52 @@ The gaps between islands are transparent and let clicks pass through. Each islan
Use **Island side padding** in Settings → Bar to tighten the space around each section. It applies only to islands; continuous bars keep their existing padding.
### Now Playing
The media control on the right shows artwork, the track title and a play/pause
button. Click the artwork or title to open playback controls for MPRIS players.
Scroll over the control to change the player's volume, or
middle-click to raise its window when supported. The menu has previous/next
buttons, a seek slider when supported, and a player chooser. It uses the same
colors, transparency and animations as the other bar menus.
Automatic selection prefers the player that most recently started playing.
When none is playing, it selects the most recently active paused player. Players
already running at startup have no known activity order; ties use their bus names.
Stopped players hide the control. Pin a player through the chooser to keep it
selected, or ignore it to exclude it. These choices last until that player leaves
the bus or the media service reconnects. `Choose automatically` clears the pin.
The bar and media keys share one MPRIS monitor. Playback updates come from D-Bus
signals. Progress updates only while the menu is open and playback is running:
once a second, or every three seconds on battery. Album art loads while the
bar or menu is visible, on a worker with download, decode and cache limits. A placeholder
keeps its space while loading or when art is unavailable.
The default Play/Pause, Next and Previous keys use `media-play-pause`,
`media-next` and `media-previous`. These actions take no arguments. Custom
bindings can use them too:
```kdl
binding "Super+P" "media-play-pause"
```
Inspect players or control the selected one from the terminal:
```sh
feresectl media
feresectl media play-pause
feresectl media next
feresectl media previous
feresectl media pin org.mpris.MediaPlayer2.example
feresectl media ignore org.mpris.MediaPlayer2.example
feresectl media unignore org.mpris.MediaPlayer2.example
feresectl media auto
```
`unignore` restores an ignored player even when the bar control is hidden. Apps
without MPRIS support do not appear here.
## Login items and locking
| `autostart` key | Type | Default | Meaning |
+34 -1
View File
@@ -152,6 +152,19 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
.map_err(|_| format!("{} requires a positive workspace index", command))?;
json!({ "index": index })
}
"media" => {
return match positional.as_slice() {
[] => Ok(("media-get".into(), json!({}))),
[mode] if mode == "get" => Ok(("media-get".into(), json!({}))),
[action] if ["play-pause", "next", "previous", "raise", "auto"].contains(&action.as_str()) => {
Ok(("media-action".into(), json!({"action": action})))
}
[action, player] if ["pin", "ignore", "unignore"].contains(&action.as_str()) => {
Ok(("media-action".into(), json!({"action":if action == "pin" { "pin" } else { "ignore" },"player":player,"ignored":action == "ignore"})))
}
_ => Err("usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]".into()),
};
}
"screenshot" => match positional.as_slice() {
[] => json!({}),
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
@@ -220,13 +233,33 @@ fn socket_path() -> Result<PathBuf, io::Error> {
}
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)]
mod tests {
use super::*;
#[test]
fn media_commands_route_transport_and_player_preferences() {
assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get");
for action in ["play-pause", "next", "previous", "raise", "auto"] {
let (command, args) = parse_args(["media".into(), action.into()]).unwrap();
assert_eq!(command, "media-action");
assert_eq!(args["action"], action);
}
for (action, ignored) in [("ignore", true), ("unignore", false)] {
let (_, args) = parse_args(["media".into(), action.into(), "player".into()]).unwrap();
assert_eq!(args["action"], "ignore");
assert_eq!(args["ignored"], ignored);
assert_eq!(args["player"], "player");
}
assert!(parse_args(["media".into(), "pin".into()]).is_err());
assert!(parse_args(["media".into(), "next".into(), "extra".into()]).is_err());
}
#[test]
fn display_mode_commands_validate_layouts() {
for layout in ["internal-only", "external-only", "extend", "mirror"] {
+103
View File
@@ -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,
};
+560
View File
@@ -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
+561
View File
@@ -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
+270
View File
@@ -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()
+4
View File
@@ -25,3 +25,7 @@ tokio = { version = "1", features = ["time", "rt", "sync"] }
wayland-client.workspace = true
zbus = "5.19.0"
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
url = "2.5.8"
base64 = "0.22.1"
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
+6 -2
View File
@@ -225,7 +225,11 @@ impl FereseShell {
.and_then(|output| output.size)
.map_or(0., |(width, _)| width as f32)
- 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32);
let title_width = (available - 920.).clamp(0., 360.);
let media_visible = self.media.snapshot.selected.is_some();
let title_width = (available - 920. - if media_visible { 400. } else { 0. }).clamp(0., 360.);
// Without a center title, give media controls more of a narrow bar.
// Keep equal sides when the title is shown so it stays centered.
let right_portion = if media_visible && title_width == 0. { 2 } else { 1 };
let title = if self.config.status.window_title && title_width > 0. {
focused_bar_title(&self.snapshot, focused_output)
} else {
@@ -270,7 +274,7 @@ impl FereseShell {
container(left).width(Length::Fill),
center,
container(right)
.width(Length::Fill)
.width(Length::FillPortion(right_portion))
.align_x(alignment::Horizontal::Right),
]
.spacing(8)
+59
View File
@@ -12,6 +12,7 @@ mod config;
mod control;
mod display_mode;
mod keybinding_guide;
mod media;
mod motion;
mod note_store;
mod notification_ui;
@@ -178,6 +179,7 @@ struct FereseShell {
status_service: status::Service,
status: status::Snapshot,
recorder: recording::Recorder,
media: media::Model,
calendar_offset: i32,
status_error: Option<status::ActionError>,
theme_error: Option<String>,
@@ -246,6 +248,14 @@ enum Message {
ClockChanged(clock::Labels),
RecorderEvent(recording::Event),
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,
ActivateWorkspace(u64),
ToggleOverview,
@@ -316,6 +326,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
media: media::Model::default(),
calendar_offset: 0,
status_error: None,
theme_error: None,
@@ -393,6 +404,9 @@ impl cosmic::Application for FereseShell {
}),
)
.map(Message::ClockChanged),
self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt),
self.recorder.subscription().map(Message::RecorderEvent),
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
self.status_service.subscription().map(Message::StatusUpdated),
@@ -427,6 +441,45 @@ impl cosmic::Application for FereseShell {
)
}
Message::MediaUpdated(snapshot) => {
self.media.receive(snapshot);
self.refresh_media_art(
self.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()),
);
Task::none()
}
Message::MediaAction(args) => self.media.command(args),
Message::MediaCompleted(result) => self.media.completed(result),
Message::MediaTick => {
self.media.now_us = ferese_ipc::media::now_us();
Task::none()
}
Message::MediaChoose => {
self.media.choosing = !self.media.choosing;
Task::none()
}
Message::MediaSeek(position) => {
self.media.seeking = Some(position);
Task::none()
}
Message::MediaSeekCommit => {
let Some(position) = self.media.seeking.take() else {
return Task::none();
};
let mut args = self.media.action("seek");
args["position_us"] = serde_json::json!(position);
args["track_id"] =
serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone()));
self.media.command(args)
}
Message::MediaArt(art) => {
if self.media.art.accepts(&art) {
self.media.artwork = Some(art);
}
Task::none()
}
Message::ThemeChanged(snapshot) => {
let mut config = self.config.clone();
config.apply_theme(&snapshot.presented);
@@ -787,6 +840,7 @@ impl cosmic::Application for FereseShell {
if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot;
let mut tasks = vec![reload_task];
let mut visibility_changed = false;
for entry in &mut self.outputs {
let output = self
@@ -802,6 +856,7 @@ impl cosmic::Application for FereseShell {
}
entry.hidden = hidden;
visibility_changed = true;
if let Some(effects) = &entry.effects
&& let Err(error) = effects.set_visible(!hidden)
{
@@ -815,6 +870,10 @@ impl cosmic::Application for FereseShell {
if self.bar_hidden(self.bar_surface_id) {
tasks.push(self.destroy_menu());
}
if visibility_changed {
self.refresh_media_art(false);
}
reload_task = Task::batch(tasks);
}
for command in poll.commands {
+199
View File
@@ -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));
}
}
+334
View File
@@ -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();
}
}
+36 -3
View File
@@ -133,12 +133,45 @@ pub(crate) fn button<'a, M: Clone + 'a>(
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
};
painted_button(button, paint)
}
pub(crate) fn circular_button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
fill: Option<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
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
+7 -1
View File
@@ -12,7 +12,11 @@ impl FereseShell {
let theme = self.config.theme.for_bar();
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
let metrics = BarMetrics::from(theme);
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center);
if self.media.snapshot.selected.is_some() {
controls = controls.push(super::media::bar(self, theme, metrics));
}
for kind in [
Menu::System,
Menu::Network,
@@ -157,6 +161,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
},
Menu::Calendar => "Calendar".into(),
Menu::Recording => "Screen recording".into(),
Menu::Media => "Now playing".into(),
Menu::Battery => s.battery.as_ref().map_or_else(
|| "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status),
@@ -203,6 +208,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
}),
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
Menu::Recording => (ferese_theme::icons::RECORD, true),
Menu::Media => (ferese_theme::icons::MEDIA, true),
Menu::Battery => {
let Some(b) = &s.battery else {
return (ferese_theme::icons::BATTERY_EMPTY, false);
@@ -25,6 +25,11 @@ impl FereseShell {
if let Some(menu) = &mut self.menu
&& menu.motion.closing()
{
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
}
menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
@@ -32,6 +37,16 @@ impl FereseShell {
return self.close_menu();
}
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
self.media.error = None;
}
if kind != Menu::Media {
self.refresh_media_art(false);
}
if kind == Menu::Calendar {
self.calendar_offset = 0;
}
@@ -122,6 +137,7 @@ impl FereseShell {
let Some(menu) = self.menu.take() else {
return Task::none();
};
self.refresh_media_art(false);
if menu.kind == Menu::Notifications {
self.notifications.history_open = false;
self.notifications.hovered = None;
@@ -133,6 +149,7 @@ impl FereseShell {
}
pub fn close_menu(&mut self) -> Task<Message> {
self.refresh_media_art(false);
let Some(menu) = &mut self.menu else {
return Task::none();
};
+471
View File
@@ -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 -2
View File
@@ -6,6 +6,7 @@ mod bluetooth;
mod calendar;
mod controls;
mod lifecycle;
mod media;
mod network;
mod system;
@@ -37,6 +38,7 @@ pub enum Menu {
Battery,
Calendar,
Recording,
Media,
Notifications,
System,
}
@@ -60,7 +62,7 @@ impl Menu {
Self::System => 360.0,
Self::Battery => 328.0,
Self::Calendar => 268.0,
Self::Notifications => 368.0,
Self::Notifications | Self::Media => 368.0,
_ => 300.0,
}
}
@@ -78,6 +80,7 @@ impl Menu {
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::Media => "Now playing",
Self::Notifications => "Notifications",
}
}
@@ -88,7 +91,7 @@ impl Menu {
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording => true,
Self::Calendar | Self::Recording | Self::Media => true,
Self::Notifications => status.notifications.is_some(),
Self::System => true,
}
@@ -216,6 +219,7 @@ impl FereseShell {
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Recording => rows,
Menu::Media => media::view(self, rows, style),
};
if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
+2
View File
@@ -12,6 +12,7 @@ impl FereseShell {
if matches!(event, wayland::OutputEvent::Removed) {
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
let entry = self.outputs.remove(index);
self.refresh_media_art(false);
let menu = if self.bar_surface_id == entry.bar {
self.destroy_menu()
} else {
@@ -101,6 +102,7 @@ impl FereseShell {
size,
hidden,
});
self.refresh_media_art(false);
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(),
move |_| SctkLayerSurfaceSettings {