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
+4026 -227

No files matched your search

+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), &[]));
}