crabidy/architecture/mpris.md

12 KiB

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.<something>. 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: QueueTrackMetadata, PlayStatePlaybackStatus, TrackPositionPosition, Volume/MuteVolume, ModsShuffle/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/<n>), 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<pid>, 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

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<pid>
}

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<MessageFromUi> 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.