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.mdA2): 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-tuiover 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, featurempris, on by default. Same shape asnotifications(architecture/build-features.mdD1): the feature pays for itself in a dependency (mpris-server), so it earns a flag; it is indefaultbecause a desktop build wants it, andcbdforwards it.--no-default-featureson 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:PlaysendsTogglePlayonly if not already playing,Pauseonly if playing,PlayPauseunconditionally. So a "pause" key can never start playback — the bug a bare toggle would have.SetVolume(v)sendsChangeVolume(v - published).SetShuffle/SetLoopStatustoggle 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
Volumeis 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 —
Stopgets its own client message. The RPC has always existed and no client used it. MPRIS'sCanControlimpliesStop, and mapping it onto pause would be a lie the desktop cannot see through, soMessageFromUi::StopandRpcClient::stopare added. -
D6 —
LoopStatus:Trackis refused. The server's repeat is aboolover the queue, soNone⇄ off andPlaylist⇄ on. SettingTrackreturnsNotSupportedrather than silently doing something else. A per-track repeat would be a server feature, not a mapping trick. -
D7 —
PlayState::Loadingpublishes asPlaying. Loading is the gap while a track opens; the desktop has only three states, andPausedwould make a status bar flicker "paused" on every track change. -
D8 — Metadata carries no URLs. Only
mpris:trackid,xesam:title,xesam:artist,xesam:albumandmpris:length.xesam:urlwould 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:artUrlhas no source: theTrackmessage carries no cover art.mpris:trackidis 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:lengthprefers the live duration.TrackPositioncarries milliseconds and is refreshed by the server;Track.durationis 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 —
Seekedis emitted on a discontinuity, heuristically. The spec forbids announcingPositionthroughPropertiesChanged; consumers extrapolate and rely on theSeekedsignal 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 crabidyselects 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, adebug!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::newis wrapped in a 2-second timeout (a hung bus must not delay startup) and its failure is aninfo!, after which the client runs exactly as it does today (A4). The same for the D-Bus writes afterwards: a failed emission is logged atdebug!and the loop continues. -
D14 —
CanQuit/CanRaiseare 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 answerNotSupported.
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::mprisowns the translation and the published mirror. Its only inputs are aSender<MessageFromUi>and stream updates; it never touches the RPC client, the terminal, or the config.cbd-tui::libstarts 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 patternnotify_now_playingalready uses.cbd-tui::appgains 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
zbusfeature unification.notify-rustandmpris-servershare the crate; ours enableszbus/tokioso the connection runs on the client's runtime instead of a secondasync-ioreactor. If a futurenotify-rustdemandedasync-ioexclusively 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
Positionsees 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-mprisbridge (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:contentCreatedfromAlbum.release_date: the provider strings are not all ISO 8601, and MPRIS wants a strict one.mpris:artUrl— needs cover art in theTrackmessage first.