# MPRIS: the desktop's media player ## Context On a Linux desktop the media keys (`XF86AudioPlay`, `XF86AudioNext`, …) and every "now playing" status-bar module speak one protocol: **MPRIS2**, a pair of D-Bus interfaces (`org.mpris.MediaPlayer2` and `org.mpris.MediaPlayer2.Player`) served at `/org/mpris/MediaPlayer2` under a bus name `org.mpris.MediaPlayer2.`. GNOME and KDE route the keys themselves; sway/i3 users bind them to `playerctl`; waybar, polybar and friends poll the same interfaces for the track. crabidy has never spoken it. The TUI's desktop *notifications* (`notify-rust`, feature `notifications`) are the only thing it puts on the bus, and a transient popup is neither a status-bar entry nor a key target — which is why the player looks absent to the desktop even though a notification appears on every track change. Everything the protocol needs is already on the wire: `TogglePlay`, `Stop`, `Next`, `Prev`, `Seek`, `ChangeVolume`, `ToggleMute`, `ToggleShuffle`, `ToggleRepeat` as RPCs, and `GetUpdateStream` pushing track, play state, position, volume, mute and the queue modifiers. So this feature is a **translation layer**, not new player state. Goal: media keys control the server, and the desktop can show what is playing, without a second copy of the truth anywhere. ## Assumptions - **A1 — MPRIS is a desktop-session concern.** It is meaningful only where the session bus is, which is where a *client* runs, not necessarily where the server runs (a Pi in the hallway has no session bus and nobody's media keys). - **A2 — The server owns all state.** Same rule as every other client surface (`architecture/seek.md` A2): a client renders what the update stream told it and predicts nothing. - **A3 — Fire-and-forget.** A media key becomes an ordinary playback RPC. If it is refused the server logs it; MPRIS reports success, because the gesture *was* delivered. - **A4 — A missing session bus is normal.** `cbd-tui` over ssh, in a tty, in a container. It must lose MPRIS and nothing else. ## Options considered ### Where the MPRIS player lives **Option A — in `crabidy-server`.** The server genuinely *is* the player, so position and state need no round trip. But it is the wrong machine: the server is routinely headless or remote (A1), where the interface would have no bus to sit on and no keys to serve; and it would put a desktop-integration dependency in the daemon. **Option B — in `cbd-tui`, behind a feature *(chosen)*.** The TUI already holds a live update stream and a command channel; MPRIS becomes a second front-end onto both, ~400 lines and no new state. It exists exactly while a TUI does, which is also its limit: close the TUI and the media keys go quiet. **Option C — a separate `cbd-mpris` bridge binary.** A headless client that registers MPRIS and proxies gRPC, run as a systemd user service, so the keys work whether or not a TUI is open. Strictly more capable than B and strictly more machinery: another binary, another config and auth path, a service unit to install. **Decision: Option B**, with the translation kept in one module whose only inputs are an update stream and a command sender — the two things a future Option C bridge would also have. C stays a wrapper away (Deferred). ### Which D-Bus crate `mpris-server` (on `zbus`) against `souvlaki` (cross-platform, on `dbus-rs`). `zbus` speaks the protocol in pure Rust, needs no `libdbus` and no `pkg-config`, and is **already in the dependency graph** via `notify-rust` — so on Linux the whole feature costs one small crate and nothing to install. `souvlaki`'s portability buys nothing here: MPRIS is the Linux desktop, and the Windows/macOS backends have no crabidy to talk to. `mpris-server` also models the spec directly (`RootInterface` + `PlayerInterface`), so the mapping decisions below are visible in the code instead of buried in a helper. ## Decisions - **D1 — `cbd-tui`, feature `mpris`, on by default.** Same shape as `notifications` (`architecture/build-features.md` D1): the feature pays for itself in a dependency (`mpris-server`), so it earns a flag; it is in `default` because a desktop build wants it, and `cbd` forwards it. `--no-default-features` on the TUI drops the bus entirely. - **D2 — Publish, never predict.** The MPRIS state is a mirror of the update stream: `QueueTrack` → `Metadata`, `PlayState` → `PlaybackStatus`, `TrackPosition` → `Position`, `Volume`/`Mute` → `Volume`, `Mods` → `Shuffle`/`LoopStatus`, `Queue` → its *length* only. Nothing is written locally on a method call and un-written if the server disagrees. - **D3 — Absolute MPRIS calls become deltas against the published state.** The protocol says `Play`, `Pause`, `SetVolume`, `SetShuffle`, `SetLoopStatus`; the server offers toggles and a volume delta. The last state the server broadcast is the base: - `Play` sends `TogglePlay` **only if** not already playing, `Pause` only if playing, `PlayPause` unconditionally. So a "pause" key can never start playback — the bug a bare toggle would have. - `SetVolume(v)` sends `ChangeVolume(v - published)`. - `SetShuffle`/`SetLoopStatus` toggle only when the target differs. The base can be one broadcast stale, so a command racing a change may become a no-op or a double toggle; the next broadcast repairs the published state either way. Predicting instead (A2) would trade a rare no-op for a permanently possible lie. - **D4 — Mute is expressed as volume 0.** MPRIS has no mute property, so the published `Volume` is 0.0 while the server is muted — which is what a status bar should show. The *setter* works off the true level: `SetVolume(0)` mutes (preserving the level, so unmuting restores it), and a non-zero target unmutes first and then sends the delta. The two commands travel one ordered channel into one sequential RPC loop, so they cannot arrive reversed. - **D5 — `Stop` gets its own client message.** The RPC has always existed and no client used it. MPRIS's `CanControl` implies `Stop`, and mapping it onto pause would be a lie the desktop cannot see through, so `MessageFromUi::Stop` and `RpcClient::stop` are added. - **D6 — `LoopStatus`: `Track` is refused.** The server's repeat is a `bool` over the queue, so `None` ⇄ off and `Playlist` ⇄ on. Setting `Track` returns `NotSupported` rather than silently doing something else. A per-track repeat would be a server feature, not a mapping trick. - **D7 — `PlayState::Loading` publishes as `Playing`.** Loading is the gap while a track opens; the desktop has only three states, and `Paused` would make a status bar flicker "paused" on every track change. - **D8 — Metadata carries no URLs.** Only `mpris:trackid`, `xesam:title`, `xesam:artist`, `xesam:album` and `mpris:length`. `xesam:url` would have to be the *stream* URL, which clients never see and which for several providers is a signed, credential-bearing URL — publishing it to every peer on the session bus is exactly the leak the redaction rule forbids. `mpris:artUrl` has no source: the `Track` message carries no cover art. `mpris:trackid` is synthesized from the queue position (`/org/crabidy/queue/`), which is both the identity the queue already uses and a *valid object path* — a library path is not one (an object path element is `[A-Za-z0-9_]` only, and paths hold spaces and unicode). - **D9 — `mpris:length` prefers the live duration.** `TrackPosition` carries milliseconds and is refreshed by the server; `Track.duration` is coarse seconds and absent for streams. Use the former when non-zero, fall back to the latter, and omit the field when neither knows — omitted is how MPRIS says "unknown", and a zero length makes progress bars draw a full track. - **D10 — `Seeked` is emitted on a discontinuity, heuristically.** The spec forbids announcing `Position` through `PropertiesChanged`; consumers extrapolate and rely on the `Seeked` signal for jumps. The server broadcasts positions on a 250 ms tick, so a broadcast that moves the position by more than a second is a seek, not the clock — that is the test, with one suppression: the reset to 0 that follows a track change is not a seek (and the spec does not want one there). - **D11 — The bus name carries the pid:** `org.mpris.MediaPlayer2.crabidy.instance`, the unique-identifier form the spec recommends. Two TUIs on one desktop then coexist instead of one silently failing to claim the name, and the suffix costs nothing in usability: `playerctl -p crabidy` selects it regardless (verified), and so does anything built on playerctl's library, waybar included. - **D12 — The update channel is bounded and drops on overflow.** 64 slots, `try_send`, a `debug!` when full. Spectrum frames and queue contents never enter it (D2) — the only high-rate update is the position, and a wedged bus is not worth stalling the orchestrator for. - **D13 — No bus, no MPRIS, no error.** `Server::new` is wrapped in a 2-second timeout (a hung bus must not delay startup) and its failure is an `info!`, after which the client runs exactly as it does today (A4). The same for the D-Bus writes afterwards: a failed emission is logged at `debug!` and the loop continues. - **D14 — `CanQuit`/`CanRaise` are false.** There is no window to raise, and letting a status-bar button close the user's terminal UI — possibly the terminal itself — is not a courtesy. Both methods answer `NotSupported`. ## Structure ```d2 direction: right desktop: Desktop session { keys: media keys\nXF86Audio* bar: status bar\nwaybar / polybar pctl: playerctl } bus: session bus { name: org.mpris.MediaPlayer2\n.crabidy.instance } tui: cbd-tui { mpris: mpris::Feed\nCrabidyPlayer orch: orchestrate loop ui: terminal UI thread } server: crabidy-server desktop.keys -> bus.name: Play / Next / Seek desktop.bar -> bus.name: Metadata, PlaybackStatus desktop.pctl -> bus.name bus.name -> tui.mpris: method call tui.mpris -> tui.orch: MessageFromUi\n(the keybindings' own channel) tui.orch -> server: playback RPC server -> tui.orch: update stream tui.orch -> tui.mpris: bounded feed\n(state only) tui.orch -> tui.ui: MessageToUi ``` The two arrows out of `orchestrate` are the whole design: the UI thread and the MPRIS task are peers, fed from one stream, and both send commands through one channel. ## Boundaries - **`cbd-tui::mpris`** owns the translation and the published mirror. Its only inputs are a `Sender` and stream updates; it never touches the RPC client, the terminal, or the config. - **`cbd-tui::lib`** starts it and forwards updates. Two call sites, one per direction, plus a no-op stub of the same shape when the feature is off — the pattern `notify_now_playing` already uses. - **`cbd-tui::app`** gains one variant (`Stop`, D5) and is otherwise untouched: MPRIS does not talk to the UI, it talks to the server, and the UI learns the result the same way it learns about a keypress from another client. - **`crabidy-core`, `crabidy-server`**: unchanged. No proto change, no server change. ## Risks - **`zbus` feature unification.** `notify-rust` and `mpris-server` share the crate; ours enables `zbus/tokio` so the connection runs on the client's runtime instead of a second `async-io` reactor. If a future `notify-rust` demanded `async-io` exclusively this would need revisiting. - **The published state is a broadcast behind.** Inherent to D3; bounded by the 250 ms tick. - **A status bar that polls `Position` sees 250 ms granularity.** No interpolation is done, deliberately: the alternative is a local clock that drifts against the server and lies while a track stalls. - **The name goes away when the TUI exits**, mid-track and without ceremony. Consumers handle `NameOwnerChanged`; this is the ordinary lifecycle of a per-instance MPRIS player. ## Deferred - **The `cbd-mpris` bridge** (Option C) — media keys without a TUI open. - **`TrackList`** — the queue as an MPRIS track list. It is a real fit (we have a queue with positions) but a third interface with its own signals, wanted by few consumers. - **`xesam:contentCreated`** from `Album.release_date`: the provider strings are not all ISO 8601, and MPRIS wants a strict one. - **`mpris:artUrl`** — needs cover art in the `Track` message first.