mpris and bar control impl
gitignore fix import error
This commit is contained in:
1 parent
73f21e20ce
commit
8a0dd7615e
42 files changed
+4026
-227
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -224,6 +224,7 @@ mod tests {
|
||||
pid: Some(42),
|
||||
desktop_entry: Some("player".into()),
|
||||
playing: true,
|
||||
..Default::default()
|
||||
};
|
||||
let window = AppWindow {
|
||||
id: WindowId(1),
|
||||
|
||||
@@ -1,245 +1,124 @@
|
||||
//! MPRIS reads and signal handling run off the compositor thread, without polling.
|
||||
mod bus;
|
||||
mod policy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::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;
|
||||
}
|
||||
}
|
||||
|
||||
if !publish(&players, &mut previous, &mut send) {
|
||||
return Ok(());
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
Err(zbus::Error::Failure("MPRIS signal connection ended".into()))
|
||||
fn update(&mut self, mut snapshot: Snapshot) {
|
||||
snapshot.revision = self.snapshot.revision;
|
||||
if snapshot == self.snapshot {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.revision = snapshot.revision.wrapping_add(1);
|
||||
self.value = serde_json::to_value(&snapshot).expect("serializable media snapshot");
|
||||
self.snapshot = snapshot;
|
||||
for (_, (id, response)) in self.waiters.drain() {
|
||||
let _ = response.try_send(Response::success(id, self.value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<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;
|
||||
state.refresh_idle_inhibition();
|
||||
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;
|
||||
@@ -0,0 +1,492 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use calloop::channel::SyncSender;
|
||||
use ferese_ipc::{
|
||||
Response,
|
||||
media::{Playback, now_us},
|
||||
};
|
||||
use futures_lite::{StreamExt, future, stream};
|
||||
use zbus::zvariant::{Array, OwnedObjectPath, OwnedValue, Value};
|
||||
use zbus::{Connection, MatchRule, MessageStream};
|
||||
|
||||
use super::policy::PlayersState;
|
||||
use super::{Command, Player, Update};
|
||||
|
||||
const PREFIX: &str = "org.mpris.MediaPlayer2";
|
||||
const PATH: &str = "/org/mpris/MediaPlayer2";
|
||||
const PLAYER: &str = "org.mpris.MediaPlayer2.Player";
|
||||
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
|
||||
const MAX_PLAYERS: usize = 64;
|
||||
type Properties = HashMap<String, OwnedValue>;
|
||||
|
||||
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
|
||||
connection: &Connection,
|
||||
member: &str,
|
||||
name: &str,
|
||||
) -> zbus::Result<T> {
|
||||
connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
member,
|
||||
&(name,),
|
||||
)
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()
|
||||
}
|
||||
|
||||
async fn properties(connection: &Connection, owner: &str, interface: &str) -> zbus::Result<Properties> {
|
||||
connection
|
||||
.call_method(Some(owner), PATH, Some(PROPERTIES), "GetAll", &(interface,))
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()
|
||||
}
|
||||
|
||||
fn string<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> {
|
||||
properties.get(name).and_then(|value| <&str>::try_from(value).ok())
|
||||
}
|
||||
|
||||
pub(super) fn clean(value: &str, length: usize) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn boolean(properties: &Properties, name: &str) -> bool {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| bool::try_from(value).ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn integer(properties: &Properties, name: &str) -> Option<u64> {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
.and_then(|value| value.try_into().ok())
|
||||
}
|
||||
|
||||
fn number(properties: &Properties, name: &str) -> Option<f64> {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| f64::try_from(value).ok())
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
async fn refresh(connection: &Connection, player: &mut Player, identity: bool) -> zbus::Result<()> {
|
||||
let mut fields = properties(connection, &player.owner, PLAYER).await?;
|
||||
let view = &mut player.view;
|
||||
view.status = match string(&fields, "PlaybackStatus") {
|
||||
Some("Playing") => Playback::Playing,
|
||||
Some("Paused") => Playback::Paused,
|
||||
_ => Playback::Stopped,
|
||||
};
|
||||
player.playing = view.status == Playback::Playing;
|
||||
view.position_us = integer(&fields, "Position");
|
||||
view.sampled_at_us = now_us();
|
||||
view.rate = number(&fields, "Rate")
|
||||
.filter(|rate| *rate != 0.0 && rate.abs() <= 100.0)
|
||||
.unwrap_or(1.0);
|
||||
view.volume = number(&fields, "Volume").filter(|volume| *volume >= 0.0);
|
||||
view.can_control = boolean(&fields, "CanControl");
|
||||
view.can_play = boolean(&fields, "CanPlay");
|
||||
view.can_pause = boolean(&fields, "CanPause");
|
||||
view.can_next = boolean(&fields, "CanGoNext");
|
||||
view.can_previous = boolean(&fields, "CanGoPrevious");
|
||||
view.can_seek = boolean(&fields, "CanSeek");
|
||||
let metadata = fields
|
||||
.remove("Metadata")
|
||||
.and_then(|value| Properties::try_from(value).ok())
|
||||
.unwrap_or_default();
|
||||
view.title = clean(string(&metadata, "xesam:title").unwrap_or_default(), 256);
|
||||
view.album = clean(string(&metadata, "xesam:album").unwrap_or_default(), 256);
|
||||
view.artist = metadata
|
||||
.get("xesam:artist")
|
||||
.and_then(|value| <&Array>::try_from(value).ok())
|
||||
.map(|array| {
|
||||
array
|
||||
.inner()
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|value| <&str>::try_from(value).ok())
|
||||
.map(|artist| clean(artist, 64))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
view.length_us = integer(&metadata, "mpris:length").filter(|length| *length > 0);
|
||||
view.track_id = metadata
|
||||
.get("mpris:trackid")
|
||||
.and_then(|value| <&zbus::zvariant::ObjectPath>::try_from(value).ok())
|
||||
.map(|path| path.to_string());
|
||||
view.art_url = string(&metadata, "mpris:artUrl")
|
||||
.filter(|url| !url.is_empty() && url.len() <= 256 * 1024)
|
||||
.map(str::to_owned);
|
||||
|
||||
if identity || player.pid.is_none() {
|
||||
player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner)
|
||||
.await
|
||||
.ok();
|
||||
if let Ok(root) = properties(connection, &player.owner, PREFIX).await {
|
||||
view.identity = clean(string(&root, "Identity").unwrap_or(&player.name), 80);
|
||||
view.can_raise = boolean(&root, "CanRaise");
|
||||
player.desktop_entry = string(&root, "DesktopEntry")
|
||||
.map(crate::window_rules::normalize_app_id)
|
||||
.filter(|value| !value.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discover(connection: &Connection, name: String, owner: String) -> Player {
|
||||
let mut player = Player {
|
||||
view: ferese_ipc::media::Player {
|
||||
name: name.clone(),
|
||||
owner: owner.clone(),
|
||||
identity: clean(&name, 80),
|
||||
..Default::default()
|
||||
},
|
||||
name,
|
||||
owner,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = refresh(connection, &mut player, true).await;
|
||||
player
|
||||
}
|
||||
|
||||
async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_json::Value) -> Result<(), String> {
|
||||
let kind = args["action"].as_str().ok_or("Missing media action")?;
|
||||
if kind == "auto" {
|
||||
state.pinned = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let player = match args["player"].as_str() {
|
||||
Some(name) => state.players.get(name),
|
||||
None => state.selected(),
|
||||
}
|
||||
.cloned()
|
||||
.ok_or("No active media player")?;
|
||||
if args["owner"].as_str().is_some_and(|owner| owner != player.owner) {
|
||||
return Err("Media player was replaced".into());
|
||||
}
|
||||
|
||||
match kind {
|
||||
"pin" => {
|
||||
state.ignored.remove(&player.name);
|
||||
state.pinned = Some(player.name);
|
||||
return Ok(());
|
||||
}
|
||||
"ignore" => {
|
||||
let ignored = args["ignored"].as_bool().ok_or("Missing ignore state")?;
|
||||
if ignored {
|
||||
state.ignored.insert(player.name.clone());
|
||||
} else {
|
||||
state.ignored.remove(&player.name);
|
||||
}
|
||||
if ignored && state.pinned.as_deref() == Some(&player.name) {
|
||||
state.pinned = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let view = &player.view;
|
||||
let supported = match kind {
|
||||
"play-pause" => view.can_toggle(),
|
||||
"next" => view.can_control && view.can_next,
|
||||
"previous" => view.can_control && view.can_previous,
|
||||
"raise" => view.can_raise,
|
||||
"seek" => view.can_control && view.can_seek && view.track_id.is_some(),
|
||||
"volume" => view.can_control && view.volume.is_some(),
|
||||
_ => false,
|
||||
};
|
||||
if !supported {
|
||||
return Err("Media action is unavailable for this player".into());
|
||||
}
|
||||
|
||||
let method = match kind {
|
||||
"play-pause" => "PlayPause",
|
||||
"next" => "Next",
|
||||
"previous" => "Previous",
|
||||
"raise" => "Raise",
|
||||
_ => "",
|
||||
};
|
||||
let response = match kind {
|
||||
"seek" => {
|
||||
if args["track_id"].as_str() != view.track_id.as_deref() {
|
||||
return Err("Media track changed".into());
|
||||
}
|
||||
let position = args["position_us"].as_u64().ok_or("Invalid media position")?;
|
||||
let position = view
|
||||
.length_us
|
||||
.map_or(position, |length| position.min(length))
|
||||
.min(i64::MAX as u64) as i64;
|
||||
let track = OwnedObjectPath::try_from(view.track_id.clone().unwrap()).map_err(|error| error.to_string())?;
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(PLAYER),
|
||||
"SetPosition",
|
||||
&(track, position),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"volume" => {
|
||||
let delta = args["delta"]
|
||||
.as_f64()
|
||||
.filter(|delta| delta.is_finite() && delta.abs() <= 1.0)
|
||||
.ok_or("Invalid volume change")?;
|
||||
let volume = (view.volume.unwrap() + delta).clamp(0.0, 1.0);
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(PROPERTIES),
|
||||
"Set",
|
||||
&(PLAYER, "Volume", Value::F64(volume)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(if kind == "raise" { PREFIX } else { PLAYER }),
|
||||
method,
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
response.map_err(|error| error.to_string())?;
|
||||
let mut player = player;
|
||||
if refresh(connection, &mut player, false).await.is_ok() {
|
||||
state.update(player);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish(state: &PlayersState, previous: &mut Option<Update>, sender: &SyncSender<Update>) -> bool {
|
||||
let update = state.update_snapshot();
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|old| old.players == update.players && old.snapshot == update.snapshot)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
*previous = Some(Update {
|
||||
players: update.players.clone(),
|
||||
snapshot: update.snapshot.clone(),
|
||||
});
|
||||
sender.send(update).is_ok()
|
||||
}
|
||||
|
||||
async fn monitor(
|
||||
connection: Connection,
|
||||
commands: &async_channel::Receiver<Command>,
|
||||
sender: &SyncSender<Update>,
|
||||
) -> zbus::Result<()> {
|
||||
let properties = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.path(PATH)?
|
||||
.interface(PROPERTIES)?
|
||||
.member("PropertiesChanged")?
|
||||
.build();
|
||||
let owners = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender("org.freedesktop.DBus")?
|
||||
.interface("org.freedesktop.DBus")?
|
||||
.member("NameOwnerChanged")?
|
||||
.arg0ns(PREFIX)?
|
||||
.build();
|
||||
let seeks = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.path(PATH)?
|
||||
.interface(PLAYER)?
|
||||
.member("Seeked")?
|
||||
.build();
|
||||
let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?;
|
||||
let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?;
|
||||
let seeks = MessageStream::for_match_rule(seeks, &connection, Some(64)).await?;
|
||||
let mut signals = stream::or(stream::or(properties, owners), seeks);
|
||||
let mut state = PlayersState::default();
|
||||
let names: Vec<String> = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
"ListNames",
|
||||
&(),
|
||||
)
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()?;
|
||||
let mut names: Vec<_> = names
|
||||
.into_iter()
|
||||
.filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.')))
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
for name in names.into_iter().take(MAX_PLAYERS) {
|
||||
if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await {
|
||||
let player = discover(&connection, name.clone(), owner).await;
|
||||
// Existing players have no known start order; bus names break ties.
|
||||
state.players.insert(name, player);
|
||||
}
|
||||
}
|
||||
|
||||
let mut previous = None;
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
enum Input {
|
||||
Signal(Option<zbus::Result<zbus::Message>>),
|
||||
Action(Option<Command>),
|
||||
}
|
||||
loop {
|
||||
let input = match commands.try_recv() {
|
||||
Ok(command) => Input::Action(Some(command)),
|
||||
Err(_) => {
|
||||
future::or(async { Input::Signal(signals.next().await) }, async {
|
||||
Input::Action(commands.recv().await.ok())
|
||||
})
|
||||
.await
|
||||
}
|
||||
};
|
||||
match input {
|
||||
Input::Action(None) => return Ok(()),
|
||||
Input::Signal(None) => return Err(zbus::Error::Failure("MPRIS signal stream ended".into())),
|
||||
Input::Action(Some(command)) => {
|
||||
let result = if command.sent.elapsed() > Duration::from_secs(2) {
|
||||
Err("Media command expired".into())
|
||||
} else {
|
||||
action(&connection, &mut state, &command.args).await
|
||||
};
|
||||
if let Some((id, response)) = command.reply {
|
||||
let reply = match result {
|
||||
Ok(()) => Response::success(id, serde_json::json!({})),
|
||||
Err(error) => Response::error(id, "media_unavailable", error),
|
||||
};
|
||||
let _ = response.try_send(reply);
|
||||
}
|
||||
}
|
||||
Input::Signal(Some(message)) => {
|
||||
let message = message?;
|
||||
let header = message.header();
|
||||
if header
|
||||
.member()
|
||||
.is_some_and(|member| member.as_str() == "NameOwnerChanged")
|
||||
{
|
||||
let (name, _, owner): (String, String, String) = message.body().deserialize()?;
|
||||
if !name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
state.remove(&name);
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
if !owner.is_empty() && state.players.len() < MAX_PLAYERS {
|
||||
let player = discover(&connection, name, owner).await;
|
||||
state.update(player);
|
||||
}
|
||||
} else if let Some(owner) = header.sender() {
|
||||
if header.member().is_some_and(|member| member.as_str() == "Seeked") {
|
||||
let names: Vec<_> = state
|
||||
.players
|
||||
.values()
|
||||
.filter(|player| player.owner == owner.as_str())
|
||||
.map(|player| player.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let mut player = state.players[&name].clone();
|
||||
if refresh(&connection, &mut player, false).await.is_ok() {
|
||||
state.update(player);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Ok((interface, changed, invalidated)) =
|
||||
message.body().deserialize::<(String, Properties, Vec<String>)>()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let identity = interface == PREFIX
|
||||
&& ["Identity", "DesktopEntry", "CanRaise"]
|
||||
.iter()
|
||||
.any(|key| changed.contains_key(*key) || invalidated.iter().any(|field| field == key));
|
||||
let playback = interface == PLAYER
|
||||
&& [
|
||||
"PlaybackStatus",
|
||||
"Metadata",
|
||||
"Rate",
|
||||
"Volume",
|
||||
"CanGoNext",
|
||||
"CanGoPrevious",
|
||||
"CanPlay",
|
||||
"CanPause",
|
||||
"CanSeek",
|
||||
"CanControl",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| changed.contains_key(*key) || invalidated.iter().any(|field| field == key));
|
||||
if !identity && !playback {
|
||||
continue;
|
||||
}
|
||||
let names: Vec<_> = state
|
||||
.players
|
||||
.values()
|
||||
.filter(|player| player.owner == owner.as_str())
|
||||
.map(|player| player.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let mut player = state.players[&name].clone();
|
||||
// Re-read after a signal so queued startup messages cannot regress state.
|
||||
if refresh(&connection, &mut player, identity).await.is_ok() {
|
||||
state.update(player);
|
||||
} else {
|
||||
player.playing = false;
|
||||
player.view.status = Playback::Stopped;
|
||||
state.update(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(sender: SyncSender<Update>, commands: async_channel::Receiver<Command>) {
|
||||
loop {
|
||||
let result = future::block_on(async {
|
||||
let connection = zbus::connection::Builder::session()?
|
||||
.method_timeout(Duration::from_millis(500))
|
||||
.build()
|
||||
.await?;
|
||||
monitor(connection, &commands, &sender).await
|
||||
});
|
||||
if matches!(result, Ok(())) || commands.is_closed() {
|
||||
break;
|
||||
}
|
||||
if sender.send(PlayersState::default().update_snapshot()).is_err() {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(?result, "MPRIS disconnected; clearing playback and retrying");
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use ferese_ipc::media::{Choice, Playback, Snapshot};
|
||||
|
||||
use super::{Player, Update};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PlayersState {
|
||||
pub players: BTreeMap<String, Player>,
|
||||
pub pinned: Option<String>,
|
||||
pub ignored: HashSet<String>,
|
||||
activity: u64,
|
||||
}
|
||||
|
||||
impl PlayersState {
|
||||
pub fn update(&mut self, mut player: Player) {
|
||||
if let Some(old) = self.players.get(&player.name) {
|
||||
player.started = old.started;
|
||||
player.active = old.active;
|
||||
if player.view.status != old.view.status
|
||||
|| player.view.track_id != old.view.track_id
|
||||
|| player.view.title != old.view.title
|
||||
|| player.view.artist != old.view.artist
|
||||
{
|
||||
self.activity += 1;
|
||||
player.active = self.activity;
|
||||
if player.playing && !old.playing {
|
||||
player.started = self.activity;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.activity += 1;
|
||||
player.active = self.activity;
|
||||
if player.playing {
|
||||
player.started = self.activity;
|
||||
}
|
||||
}
|
||||
self.players.insert(player.name.clone(), player);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, name: &str) {
|
||||
self.players.remove(name);
|
||||
self.ignored.remove(name);
|
||||
if self.pinned.as_deref() == Some(name) {
|
||||
self.pinned = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<&Player> {
|
||||
let eligible =
|
||||
|player: &&Player| player.view.status != Playback::Stopped && !self.ignored.contains(&player.name);
|
||||
if let Some(player) = self
|
||||
.pinned
|
||||
.as_ref()
|
||||
.and_then(|name| self.players.get(name))
|
||||
.filter(eligible)
|
||||
{
|
||||
return Some(player);
|
||||
}
|
||||
|
||||
self.players.values().filter(eligible).max_by(|a, b| {
|
||||
(a.playing, if a.playing { a.started } else { a.active })
|
||||
.cmp(&(b.playing, if b.playing { b.started } else { b.active }))
|
||||
.then_with(|| b.name.cmp(&a.name))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_snapshot(&self) -> Update {
|
||||
Update {
|
||||
players: self.players.values().cloned().collect(),
|
||||
snapshot: Snapshot {
|
||||
revision: 0,
|
||||
selected: self.selected().map(|player| player.view.clone()),
|
||||
pinned: self.pinned.clone(),
|
||||
players: self
|
||||
.players
|
||||
.values()
|
||||
.map(|player| Choice {
|
||||
name: player.name.clone(),
|
||||
owner: player.owner.clone(),
|
||||
identity: player.view.identity.clone(),
|
||||
status: player.view.status,
|
||||
ignored: self.ignored.contains(&player.name),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::*;
|
||||
use ferese_ipc::media::Playback;
|
||||
use policy::PlayersState;
|
||||
|
||||
fn player(name: &str, status: Playback) -> Player {
|
||||
Player {
|
||||
name: name.into(),
|
||||
owner: format!(":{name}"),
|
||||
playing: status == Playback::Playing,
|
||||
view: ferese_ipc::media::Player {
|
||||
name: name.into(),
|
||||
status,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tracks_starts_not_metadata_or_volume_changes() {
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Playing));
|
||||
state.update(player("second", Playback::Playing));
|
||||
let mut first = state.players["first"].clone();
|
||||
first.view.title = "New track".into();
|
||||
first.view.volume = Some(0.5);
|
||||
state.update(first);
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
|
||||
state.update(player("second", Playback::Paused));
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.update(player("first", Playback::Paused));
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.update(player("second", Playback::Playing));
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_ignore_and_departure_do_not_leave_a_ghost_player() {
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Paused));
|
||||
state.update(player("second", Playback::Playing));
|
||||
state.pinned = Some("first".into());
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.ignored.insert("first".into());
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
state.remove("first");
|
||||
assert!(state.pinned.is_none());
|
||||
assert!(state.ignored.is_empty());
|
||||
state.remove("second");
|
||||
assert!(state.selected().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_ties_are_deterministic_and_stopped_players_are_hidden() {
|
||||
let mut state = PlayersState::default();
|
||||
for name in ["z", "a"] {
|
||||
state.players.insert(name.into(), player(name, Playback::Playing));
|
||||
}
|
||||
assert_eq!(state.selected().unwrap().name, "a");
|
||||
state.update(player("a", Playback::Stopped));
|
||||
state.update(player("z", Playback::Stopped));
|
||||
assert!(state.selected().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_waits_for_changes_deduplicates_and_drops_disconnected_clients() {
|
||||
let mut engine = Engine::default();
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, 0, send);
|
||||
engine.update(Snapshot::default());
|
||||
assert!(receive.try_recv().is_err());
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Playing));
|
||||
engine.update(state.update_snapshot().snapshot);
|
||||
let response = receive.try_recv().unwrap();
|
||||
assert_eq!(response.id, 42);
|
||||
assert_eq!(response.result.unwrap()["revision"], 1);
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, 1, send);
|
||||
engine.remove(2);
|
||||
state.remove("first");
|
||||
engine.update(state.update_snapshot().snapshot);
|
||||
assert!(receive.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_is_bounded_and_control_characters_are_removed() {
|
||||
assert_eq!(bus::clean(" Track\nname\0 ", 80), "Trackname");
|
||||
assert_eq!(bus::clean(&"é".repeat(500), 80).chars().count(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_position_updates_do_not_recompute_idle_policy() {
|
||||
let before = player("first", Playback::Playing);
|
||||
let mut after = before.clone();
|
||||
after.view.title = "New track".into();
|
||||
after.view.position_us = Some(10);
|
||||
after.view.volume = Some(0.8);
|
||||
assert!(!inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
after.playing = false;
|
||||
assert!(inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
after.playing = true;
|
||||
after.pid = Some(42);
|
||||
assert!(inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
|
||||
}
|
||||
@@ -1098,6 +1098,11 @@ impl Ferese {
|
||||
BindingAction::Expel => self.expel_focused_window(),
|
||||
BindingAction::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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user