251 lines
12 KiB
Markdown
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.
|