crabidy/architecture/mpris.md

251 lines
12 KiB
Markdown

# 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: `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/<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
```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<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.