Download
253 lines 7.8 KiBRustModified 2026-08-01 raw
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()
}