import dbus from "dbus-next"; const MPRIS_PREFIX = "org.mpris.MediaPlayer2."; const PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"; const PROPS_IFACE = "org.freedesktop.DBus.Properties"; // Lowercase substrings matched against the bus name suffix. // Only players matching this whitelist will be used. const ALLOWED_PLAYERS = [ // local players "gapless", "elisa", "rhythmbox", "g4music", "lollypop", "amberol", "gnome-music", "strawberry", "clementine", "audacious", "quodlibet", "deadbeef", "cmus", "musikcube", "aimp", "spotify", "tidal", "deezer", "youtube-music", "youtubemusic", "nuclear", "cider", "feishin", "sonixd", "navidrome", "plexamp", ]; /** * Return the session bus connection. */ export function sessionBus() { return dbus.sessionBus(); } /** * List all MPRIS player bus names currently registered. */ export async function listPlayers(bus) { const dbusProxy = await bus.getProxyObject( "org.freedesktop.DBus", "/org/freedesktop/DBus", ); const iface = dbusProxy.getInterface("org.freedesktop.DBus"); const names = await iface.ListNames(); return names.filter((n) => n.startsWith(MPRIS_PREFIX)); } /** * Pick the best player from a list of bus names based on the list mode. * "off" – return the first player found (ignore the list) * "priority" – prefer listed players, fall back to any player * "on" – only allow listed players (strict whitelist) */ export function selectPlayer(players, mode = "on") { if (mode === "off") { return players[0] ?? null; } // Find the first player that matches the list for (const name of players) { const suffix = name.slice(MPRIS_PREFIX.length).toLowerCase(); if (ALLOWED_PLAYERS.some((p) => suffix.includes(p))) { return name; } } // In priority mode, fall back to any player if (mode === "priority") { return players[0] ?? null; } return null; } /** * Read Metadata, PlaybackStatus, and Position from a player. */ export async function getPlayerData(bus, playerName) { const proxy = await bus.getProxyObject(playerName, "/org/mpris/MediaPlayer2"); const props = proxy.getInterface(PROPS_IFACE); const [metadata, playbackStatus, position] = await Promise.all([ props.Get(PLAYER_IFACE, "Metadata"), props.Get(PLAYER_IFACE, "PlaybackStatus"), props.Get(PLAYER_IFACE, "Position"), ]); return { metadata: metadata.value, // dict of Variants playbackStatus: playbackStatus.value, // string positionUs: Number(position.value), // microseconds }; } /** * Subscribe to PropertiesChanged and Seeked signals on the player interface. * `onChange` receives (changedProperties: object, invalidated: string[]). * `onSeeked` receives (positionUs: number). * Returns a cleanup function. */ export async function watchPlayer(bus, playerName, onChange, onSeeked) { const proxy = await bus.getProxyObject(playerName, "/org/mpris/MediaPlayer2"); const props = proxy.getInterface(PROPS_IFACE); const player = proxy.getInterface(PLAYER_IFACE); const propsHandler = (ifaceName, changed, invalidated) => { if (ifaceName === PLAYER_IFACE) { onChange(changed, invalidated); } }; const seekHandler = (positionUs) => { onSeeked(Number(positionUs)); }; props.on("PropertiesChanged", propsHandler); player.on("Seeked", seekHandler); return () => { props.off("PropertiesChanged", propsHandler); player.off("Seeked", seekHandler); }; } /** * Watch for new or removed MPRIS players on the bus. * `onPlayerChange` is called with no arguments whenever the set of players changes. */ export async function watchBus(bus, onPlayerChange) { const dbusProxy = await bus.getProxyObject( "org.freedesktop.DBus", "/org/freedesktop/DBus", ); const iface = dbusProxy.getInterface("org.freedesktop.DBus"); iface.on("NameOwnerChanged", (name, _oldOwner, _newOwner) => { if (name.startsWith(MPRIS_PREFIX)) { onPlayerChange(); } }); }