123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 | //! MPRIS media player integration over D-Bus. Watches for players
//! (org.mpris.MediaPlayer2.*), tracks the active one (playing preferred),
//! and emits `divebar://media` events with title/artist/status/album art.
//! Property changes and player appearance/disappearance both trigger
//! immediate refreshes. Playback is controlled via the media_control
//! command.
use base64::Engine;
use futures_util::StreamExt;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use tauri::AppHandle;
use zbus::{zvariant, Connection, MatchRule, MessageStream, Proxy};
static ACTIVE_PLAYER: Mutex<Option<String>> = Mutex::new(None);
static CONN: OnceLock<Connection> = OnceLock::new();
static ART_CACHE: Mutex<Option<HashMap<String, Option<String>>>> = Mutex::new(None);
const MPRIS_PREFIX: &str = "org.mpris.MediaPlayer2.";
const MPRIS_PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER_IFACE: &str = "org.mpris.MediaPlayer2.Player";
pub fn spawn_media(app: AppHandle) {
tauri::async_runtime::spawn(async move {
if let Err(err) = run(app).await {
eprintln!("divebar: media watcher failed: {err}");
}
});
}
pub async fn control(action: &str) -> Result<(), String> {
let conn = CONN.get().ok_or("media watcher not running")?;
let player = ACTIVE_PLAYER
.lock()
.unwrap()
.clone()
.ok_or("no active player")?;
let method = match action {
"playPause" => "PlayPause",
"next" => "Next",
"previous" => "Previous",
_ => return Err(format!("unknown media action: {action}")),
};
let proxy = Proxy::new(conn, player, MPRIS_PATH, PLAYER_IFACE)
.await
.map_err(|e| e.to_string())?;
proxy
.call_method(method, &())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn run(app: AppHandle) -> zbus::Result<()> {
let conn = Connection::session().await?;
let _ = CONN.set(conn.clone());
// Property changes on any player.
let props_rule = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.interface("org.freedesktop.DBus.Properties")?
.member("PropertiesChanged")?
.path(MPRIS_PATH)?
.build();
let props_stream = MessageStream::for_match_rule(props_rule, &conn, Some(32)).await?;
// Players appearing/disappearing.
let dbus = zbus::fdo::DBusProxy::new(&conn).await?;
let names_stream = dbus.receive_name_owner_changed().await?;
refresh(&app, &conn).await;
{
let app = app.clone();
let conn = conn.clone();
tauri::async_runtime::spawn(async move {
let mut stream = props_stream;
while stream.next().await.is_some() {
refresh(&app, &conn).await;
}
});
}
{
let app = app.clone();
let conn = conn.clone();
tauri::async_runtime::spawn(async move {
let mut stream = names_stream;
while let Some(signal) = stream.next().await {
let is_mpris = signal
.args()
.map(|a| a.name().starts_with(MPRIS_PREFIX))
.unwrap_or(false);
if is_mpris {
refresh(&app, &conn).await;
}
}
});
}
Ok(())
}
async fn refresh(app: &AppHandle, conn: &Connection) {
let payload = gather(conn).await;
crate::events::emit(app, "media", payload);
}
async fn gather(conn: &Connection) -> Value {
let Ok(dbus) = zbus::fdo::DBusProxy::new(conn).await else {
return Value::Null;
};
let Ok(names) = dbus.list_names().await else {
return Value::Null;
};
let players: Vec<String> = names
.iter()
.map(|n| n.to_string())
.filter(|n| n.starts_with(MPRIS_PREFIX))
.collect();
// Prefer a playing player; otherwise take the first one.
let mut best: Option<(String, String)> = None; // (name, status)
for name in &players {
let Ok(proxy) = Proxy::new(conn, name.clone(), MPRIS_PATH, PLAYER_IFACE).await else {
continue;
};
let status: String = proxy
.get_property("PlaybackStatus")
.await
.unwrap_or_default();
let is_playing = status == "Playing";
if best.is_none() || is_playing {
best = Some((name.clone(), status));
if is_playing {
break;
}
}
}
let Some((name, status)) = best else {
*ACTIVE_PLAYER.lock().unwrap() = None;
return Value::Null;
};
*ACTIVE_PLAYER.lock().unwrap() = Some(name.clone());
let Ok(proxy) = Proxy::new(conn, name.clone(), MPRIS_PATH, PLAYER_IFACE).await else {
return Value::Null;
};
let metadata: HashMap<String, zvariant::OwnedValue> =
proxy.get_property("Metadata").await.unwrap_or_default();
let title = metadata
.get("xesam:title")
.and_then(value_str)
.unwrap_or_default();
let artist = metadata
.get("xesam:artist")
.map(value_str_array)
.unwrap_or_default()
.join(", ");
let art = metadata
.get("mpris:artUrl")
.and_then(value_str)
.and_then(|url| art_data_uri(&url));
let fallback_identity = name.trim_start_matches(MPRIS_PREFIX).to_string();
let identity: String =
match Proxy::new(conn, name.clone(), MPRIS_PATH, "org.mpris.MediaPlayer2").await {
Ok(p) => p
.get_property("Identity")
.await
.unwrap_or(fallback_identity),
Err(_) => fallback_identity,
};
json!({
"player": identity,
"status": status,
"title": title,
"artist": artist,
"art": art,
})
}
fn value_str(v: &zvariant::OwnedValue) -> Option<String> {
v.downcast_ref::<&str>().ok().map(str::to_string)
}
fn value_str_array(v: &zvariant::OwnedValue) -> Vec<String> {
let Ok(arr) = v.downcast_ref::<&zvariant::Array>() else {
return Vec::new();
};
arr.iter()
.filter_map(|item| item.downcast_ref::<&str>().ok().map(str::to_string))
.collect()
}
/// Resolves an artUrl (file:// or http(s)://) to a data URI, cached per URL.
fn art_data_uri(url: &str) -> Option<String> {
{
let mut cache = ART_CACHE.lock().unwrap();
let cache = cache.get_or_insert_with(HashMap::new);
if let Some(cached) = cache.get(url) {
return cached.clone();
}
if cache.len() > 32 {
cache.clear();
}
}
let bytes: Option<Vec<u8>> = if let Some(path) = url.strip_prefix("file://") {
std::fs::read(percent_decode(path)).ok()
} else if url.starts_with("http://") || url.starts_with("https://") {
crate::readers::fetch_url(url, 5)
} else {
None
};
let uri = bytes
.filter(|b| !b.is_empty() && b.len() < 1_000_000)
.map(|b| {
let mime = if b.starts_with(b"\x89PNG") {
"image/png"
} else {
"image/jpeg"
};
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(&b)
)
});
ART_CACHE
.lock()
.unwrap()
.get_or_insert_with(HashMap::new)
.insert(url.to_string(), uri.clone());
uri
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
|