cbd-tui: speak MPRIS, so the media keys reach the server

The recollection that this used to exist is false — `git log --all -S
mpris` finds nothing on any branch. What has always been there is the
desktop *notification* on a track change, which is D-Bus but not MPRIS: a
popup is neither a status-bar entry nor a key target. So this is new, not
a regression.

Everything the protocol needs was already on the wire, so cbd-tui gains a
second front-end onto the two channels it already has: stream updates in,
MessageFromUi out. The MPRIS player is a peer of the UI thread — it
commands the server through the very channel the keybindings use, and it
learns the result the way the UI learns about a keypress from another
client. No proto change, no server change.

The decisions worth knowing (architecture/mpris.md):

- An absolute protocol over a toggling server. Play/Pause/SetShuffle/
  SetLoopStatus consult the last state the server broadcast and send
  nothing when it already matches, or the pause key would start playback
  on a paused player. Volume is the same idea with arithmetic; muting is
  spelled "volume 0", and the setter mutes on a zero target so the level
  survives to be unmuted to.
- No URL reaches the bus. xesam:url would have to be the stream URL,
  which clients never see and which several providers sign with
  credentials, and every peer on a session bus can read properties. The
  trackid is the queue position — also the only spelling that is a valid
  object path.
- mpris:length is omitted when unknown rather than sent as zero, which
  would make consumers draw a full progress bar.
- Unrepresentable requests are refused, not approximated: repeat-one,
  rates other than 1.0, OpenUri, Raise, and Quit — a status-bar button
  has no business closing someone's terminal.
- No session bus is a normal way to run (ssh, a tty, a container): the
  connection carries a timeout and its failure is an info log, after
  which the client behaves exactly as before.

Behind the `mpris` feature, on by default beside `notifications` and
forwarded by `cbd`; the nix package names it in headlessFeatures, since
naming a feature set at all replaces the crate defaults. It costs one
crate and no system library — zbus speaks D-Bus in pure Rust and
notify-rust had already brought it in.

Verified with the real thing, not only a test double: under
dbus-run-session, playerctl lists the player, reads its metadata
("Playing: the artist - the song (4:00)"), and drives play-pause,
`position 30+` and `volume 0.8` into the right commands. It also refuses
`next` when the queue is empty, which is CanGoNext being honest. The
committed bus test covers the round trip and skips where there is no bus.
This commit is contained in:
Test User 2026-07-28 01:27:28 +02:00
parent 621823d930
commit a2e3da6aa5
17 changed files with 1766 additions and 17 deletions

27
Cargo.lock generated
View File

@ -706,6 +706,7 @@ dependencies = [
"crossterm", "crossterm",
"dirs", "dirs",
"flume", "flume",
"mpris-server",
"notify-rust", "notify-rust",
"ratatui", "ratatui",
"serde", "serde",
@ -2947,6 +2948,19 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "mpris-server"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70cf358e9a9516cc35ed40eebd56cbe80f5190a7a2b3cd0c2d358c86b879132b"
dependencies = [
"async-channel",
"futures-channel",
"serde",
"trait-variant",
"zbus",
]
[[package]] [[package]]
name = "multimap" name = "multimap"
version = "0.10.1" version = "0.10.1"
@ -5494,6 +5508,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -5834,6 +5849,17 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "trait-variant"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "transpose" name = "transpose"
version = "0.2.3" version = "0.2.3"
@ -6782,6 +6808,7 @@ dependencies = [
"rustix", "rustix",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid", "uuid",

View File

@ -45,6 +45,9 @@ http = "1"
include_dir = "0.7" include_dir = "0.7"
leptos = { version = "0.8", default-features = false, features = ["csr"] } leptos = { version = "0.8", default-features = false, features = ["csr"] }
notify-rust = "4" notify-rust = "4"
# The MPRIS D-Bus surface of cbd-tui. Built on zbus, which speaks the
# protocol in pure Rust — no libdbus, so nothing to install or link.
mpris-server = "0.10"
feed-rs = "2" feed-rs = "2"
percent-encoding = "2" percent-encoding = "2"
prost = "0.14" prost = "0.14"

View File

@ -58,7 +58,8 @@ another terminal, `cargo run -p cbd-tui`.
### Tailored builds ### Tailored builds
Every provider — plus Opus decoding, the spectrum bars, the embedded web Every provider — plus Opus decoding, the spectrum bars, the embedded web
UI, and the TUI's desktop notifications — sits behind a Cargo feature, UI, and the TUI's desktop notifications and MPRIS player — sits behind a
Cargo feature,
all on by default. Drop what you do not need and the dependencies go with all on by default. Drop what you do not need and the dependencies go with
it (`opus-bundled` is the one to drop if you would rather link the system it (`opus-bundled` is the one to drop if you would rather link the system
libopus than compile the vendored copy): libopus than compile the vendored copy):

250
architecture/mpris.md Normal file
View File

@ -0,0 +1,250 @@
# 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.

View File

@ -7,8 +7,13 @@ edition.workspace = true
# D-Bus stack. On by default; off for a terminal-only client # D-Bus stack. On by default; off for a terminal-only client
# (architecture/build-features.md D1). # (architecture/build-features.md D1).
[features] [features]
default = ["notifications"] default = ["notifications", "mpris"]
notifications = ["dep:notify-rust"] notifications = ["dep:notify-rust"]
# The MPRIS player on the session bus: media keys and a "now playing"
# entry in the desktop's status bar (architecture/mpris.md). `zbus/tokio`
# so the bus connection runs on the client's own runtime instead of a
# second reactor (architecture/mpris.md, Risks).
mpris = ["dep:mpris-server", "mpris-server/tokio"]
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
@ -19,6 +24,7 @@ clap.workspace = true
dirs.workspace = true dirs.workspace = true
toml.workspace = true toml.workspace = true
flume.workspace = true flume.workspace = true
mpris-server = { workspace = true, optional = true }
notify-rust = { workspace = true, optional = true } notify-rust = { workspace = true, optional = true }
ratatui.workspace = true ratatui.workspace = true
serde.workspace = true serde.workspace = true

View File

@ -109,6 +109,7 @@ pub enum MessageToUi {
} }
// FIXME: Rename this // FIXME: Rename this
#[derive(Debug)]
pub enum MessageFromUi { pub enum MessageFromUi {
GetLibraryNode(String), GetLibraryNode(String),
/// Create a child node (e.g. a search term) under a creatable parent; /// Create a child node (e.g. a search term) under a creatable parent;
@ -156,6 +157,10 @@ pub enum MessageFromUi {
Seek(i64), Seek(i64),
SetCurrentTrack(usize), SetCurrentTrack(usize),
TogglePlay, TogglePlay,
/// Stop playback outright, rather than pausing it. No binding sends this
/// — it exists for the desktop's `Stop` media key, which MPRIS must not
/// quietly turn into a pause (architecture/mpris.md D5).
Stop,
ChangeVolume(f32), ChangeVolume(f32),
ToggleMute, ToggleMute,
ToggleShuffle, ToggleShuffle,

View File

@ -8,6 +8,35 @@ pub mod app;
pub mod config; pub mod config;
pub mod rpc; pub mod rpc;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Built without the `mpris` feature: the same shape, doing nothing, so the
/// orchestrator needs no `cfg` of its own (architecture/build-features.md D1).
#[cfg(not(feature = "mpris"))]
pub mod mpris {
use crabidy_core::proto::crabidy::{
get_update_stream_response::Update as StreamUpdate, InitResponse,
};
use flume::Sender;
use crate::app::MessageFromUi;
/// There is no player to feed. The orchestrator holds an `Option` of this
/// and never gets a `Some`.
#[derive(Debug)]
pub struct Feed;
impl Feed {
pub fn publish(&self, _update: &StreamUpdate) {}
pub fn publish_init(&self, _init: &InitResponse) {}
}
pub async fn start(_commands: Sender<MessageFromUi>) -> Option<Feed> {
None
}
}
use std::{ use std::{
error::Error, error::Error,
io, io,
@ -39,8 +68,12 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded(); let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded(); let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
// The MPRIS player commands the server through the same channel the
// keybindings use, so it is just another producer of `MessageFromUi`
// (architecture/mpris.md D1).
let commands = ui_tx.clone();
// FIXME: unwrap // FIXME: unwrap
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); tokio::spawn(async move { orchestrate(config, (tx, rx), commands).await.unwrap() });
let spectrum_enabled = config.server.spectrum; let spectrum_enabled = config.server.spectrum;
// Resolved here rather than in the UI thread so a bad config string is // Resolved here rather than in the UI thread so a bad config string is
@ -57,6 +90,7 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
async fn orchestrate( async fn orchestrate(
config: &'static Config, config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>), (tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
commands: Sender<MessageFromUi>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server"); info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server).await?; let mut rpc_client = rpc::RpcClient::connect(&config.server).await?;
@ -65,12 +99,19 @@ async fn orchestrate(
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
} }
// A desktop that cannot host the player (no session bus) leaves this
// `None` and changes nothing else (architecture/mpris.md D13).
let mpris = mpris::start(commands).await;
let init_data = rpc_client.init().await?; let init_data = rpc_client.init().await?;
info!("received initial state from server"); info!("received initial state from server");
if let Some(mpris) = &mpris {
mpris.publish_init(&init_data);
}
tx.send_async(MessageToUi::Init(init_data)).await?; tx.send_async(MessageToUi::Init(init_data)).await?;
loop { loop {
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await { if let Err(err) = poll(&mut rpc_client, &rx, &tx, &mpris).await {
error!("request to server failed: {err}"); error!("request to server failed: {err}");
} }
} }
@ -80,6 +121,7 @@ async fn poll(
rpc_client: &mut RpcClient, rpc_client: &mut RpcClient,
rx: &Receiver<MessageFromUi>, rx: &Receiver<MessageFromUi>,
tx: &Sender<MessageToUi>, tx: &Sender<MessageToUi>,
mpris: &Option<mpris::Feed>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
select! { select! {
Ok(msg) = &mut rx.recv_async() => { Ok(msg) = &mut rx.recv_async() => {
@ -158,6 +200,9 @@ async fn poll(
MessageFromUi::TogglePlay => { MessageFromUi::TogglePlay => {
rpc_client.toggle_play().await? rpc_client.toggle_play().await?
} }
MessageFromUi::Stop => {
rpc_client.stop().await?
}
MessageFromUi::ChangeVolume(delta) => { MessageFromUi::ChangeVolume(delta) => {
rpc_client.change_volume(delta).await? rpc_client.change_volume(delta).await?
} }
@ -196,6 +241,11 @@ async fn poll(
match resp { match resp {
Ok(resp) => { Ok(resp) => {
if let Some(update) = resp.update { if let Some(update) = resp.update {
// The UI thread and the MPRIS player are peers on one
// stream; neither learns anything the other does not.
if let Some(mpris) = mpris {
mpris.publish(&update);
}
tx.send_async(MessageToUi::Update(update)).await?; tx.send_async(MessageToUi::Update(update)).await?;
} }
} }

1052
cbd-tui/src/mpris.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@ use crabidy_core::proto::crabidy::{
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
SeekRequest, SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
ToggleShuffleRequest, ToggleRepeatRequest, ToggleShuffleRequest,
}; };
use std::{collections::HashMap, error::Error, fmt, time::Duration}; use std::{collections::HashMap, error::Error, fmt, time::Duration};
@ -350,6 +350,14 @@ impl RpcClient {
Ok(()) Ok(())
} }
/// Stops playback (as opposed to pausing it). Sent by the MPRIS `Stop`
/// method; no keybinding reaches it (architecture/mpris.md D5).
pub async fn stop(&mut self) -> Result<(), Box<dyn Error>> {
let stop_request = Request::new(StopRequest {});
self.client.stop(stop_request).await?;
Ok(())
}
pub async fn toggle_shuffle(&mut self) -> Result<(), Box<dyn Error>> { pub async fn toggle_shuffle(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_shuffle_request = Request::new(ToggleShuffleRequest {}); let toggle_shuffle_request = Request::new(ToggleShuffleRequest {});
self.client.toggle_shuffle(toggle_shuffle_request).await?; self.client.toggle_shuffle(toggle_shuffle_request).await?;

145
cbd-tui/tests/mpris_bus.rs Normal file
View File

@ -0,0 +1,145 @@
//! The MPRIS player as the desktop sees it: over a real session bus, through
//! a real D-Bus client (architecture/mpris.md).
//!
//! The unit tests in `src/mpris.rs` cover the mapping decisions; this one
//! covers the parts only a bus can answer — that the name is claimed, that
//! the interfaces are served where the spec says, and that a method call
//! becomes a command for the server.
//!
//! Skipped when there is no session bus, which is the normal state of a CI
//! runner. To run it:
//!
//! ```sh
//! devenv shell -- dbus-run-session -- cargo test -p cbd-tui --test mpris_bus
//! ```
#![cfg(feature = "mpris")]
use std::time::Duration;
use cbd_tui::{app::MessageFromUi, mpris};
use crabidy_core::proto::crabidy::{
Album, InitResponse, PlayState, Queue, QueueModifiers, QueueTrack, Track, TrackPosition,
};
use mpris_server::zbus::{zvariant::OwnedValue, Connection, Proxy};
const OBJECT_PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER_INTERFACE: &str = "org.mpris.MediaPlayer2.Player";
const ROOT_INTERFACE: &str = "org.mpris.MediaPlayer2";
fn playing_state() -> InitResponse {
InitResponse {
queue: Some(Queue {
timestamp: 0,
current_position: 1,
tracks: vec![],
resolving: false,
}),
mods: Some(QueueModifiers {
shuffle: false,
repeat: false,
}),
queue_track: Some(QueueTrack {
queue_position: 1,
track: Some(Track {
path: "/fs/music/song.flac".to_string(),
artist: "the artist".to_string(),
title: "the song".to_string(),
duration: Some(240),
album: Some(Album {
title: "the album".to_string(),
release_date: None,
}),
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}),
}),
play_state: PlayState::Playing.into(),
volume: 0.5,
mute: false,
position: Some(TrackPosition {
position: 12_000,
duration: 240_000,
}),
auth_enabled: false,
}
}
/// Waits for the published mirror to catch up with the feed: the state
/// travels a channel and a task, so a property read straight after a publish
/// may still see the previous value.
async fn await_property(proxy: &Proxy<'_>, name: &str, expected: &str) -> OwnedValue {
for _ in 0..100 {
let value: OwnedValue = proxy.get_property(name).await.expect("read property");
if format!("{value:?}").contains(expected) {
return value;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("{name} never became {expected}");
}
#[tokio::test]
async fn the_desktop_sees_the_track_and_its_keys_reach_the_server() {
if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() {
eprintln!("no session bus; skipping (see this file's docs)");
return;
}
let (commands_tx, commands) = flume::unbounded();
let feed = mpris::start(commands_tx)
.await
.expect("a session bus is available, so the player must register");
feed.publish_init(&playing_state());
let connection = Connection::session().await.expect("connect to the bus");
let bus_name = format!(
"org.mpris.MediaPlayer2.crabidy.instance{}",
std::process::id()
);
let player = Proxy::new(&connection, bus_name.clone(), OBJECT_PATH, PLAYER_INTERFACE)
.await
.expect("the player interface is served where the spec says");
let root = Proxy::new(&connection, bus_name, OBJECT_PATH, ROOT_INTERFACE)
.await
.expect("the root interface too");
// What a status bar reads.
let identity: String = root.get_property("Identity").await.expect("Identity");
assert_eq!(identity, "crabidy");
await_property(&player, "PlaybackStatus", "Playing").await;
let metadata = await_property(&player, "Metadata", "the song").await;
let metadata = format!("{metadata:?}");
assert!(metadata.contains("the artist"), "{metadata}");
assert!(metadata.contains("the album"), "{metadata}");
assert!(
metadata.contains("/org/crabidy/queue/1"),
"the trackid is the queue position: {metadata}"
);
assert!(
!metadata.contains("/fs/music/song.flac"),
"no library path and no URL may reach the bus: {metadata}"
);
// What a media key does. Pause, because it is the mapping with a
// condition on it: the server only has a toggle.
player
.call::<_, _, ()>("Pause", &())
.await
.expect("Pause is callable");
assert!(
matches!(
commands.recv_timeout(Duration::from_secs(1)),
Ok(MessageFromUi::TogglePlay)
),
"the pause key must reach the server as a playback command"
);
// And a control the desktop is told it does not have.
let can_quit: bool = root.get_property("CanQuit").await.expect("CanQuit");
assert!(!can_quit);
assert!(
root.call::<_, _, ()>("Quit", &()).await.is_err(),
"Quit must be refused, not close the user's terminal"
);
}

View File

@ -14,6 +14,7 @@ default = [
"spectrum", "spectrum",
"web-ui", "web-ui",
"notifications", "notifications",
"mpris",
] ]
all-providers = ["crabidy-server/all-providers"] all-providers = ["crabidy-server/all-providers"]
@ -30,6 +31,7 @@ opus-bundled = ["crabidy-server/opus-bundled"]
spectrum = ["crabidy-server/spectrum"] spectrum = ["crabidy-server/spectrum"]
web-ui = ["crabidy-server/web-ui"] web-ui = ["crabidy-server/web-ui"]
notifications = ["cbd-tui/notifications"] notifications = ["cbd-tui/notifications"]
mpris = ["cbd-tui/mpris"]
[dependencies] [dependencies]
cbd-cli = { workspace = true, features = ["client"] } cbd-cli = { workspace = true, features = ["client"] }

View File

@ -148,8 +148,14 @@ in
clippy -p audio-player --no-default-features --features opus clippy -p audio-player --no-default-features --features opus
clippy -p cbd-tui --no-default-features clippy -p cbd-tui --no-default-features
test_it -p cbd-tui --no-default-features test_it -p cbd-tui --no-default-features
# The desktop axes, each on its own: the MPRIS player and the
# notifications share a D-Bus stack but no code.
clippy -p cbd-tui --no-default-features --features mpris
test_it -p cbd-tui --no-default-features --features mpris
clippy -p cbd-tui --no-default-features --features notifications
clippy -p cbd --no-default-features clippy -p cbd --no-default-features
clippy -p cbd --no-default-features --features fs,opus,notifications clippy -p cbd --no-default-features --features fs,opus,notifications
clippy -p cbd --no-default-features --features fs,opus,mpris
echo "all feature combinations are clean" echo "all feature combinations are clean"
''; '';

View File

@ -3,7 +3,7 @@
<!-- toc --> <!-- toc -->
Every provider, Opus decoding, the spectrum bars, the embedded web UI and Every provider, Opus decoding, the spectrum bars, the embedded web UI and
the TUI's desktop notifications sit behind a Cargo **feature**. All of them the TUI's two desktop integrations sit behind a Cargo **feature**. All of them
are **on by default**, so a plain `cargo build` gives you the full player. are **on by default**, so a plain `cargo build` gives you the full player.
Turning some off gives you a smaller binary that pulls fewer dependencies — Turning some off gives you a smaller binary that pulls fewer dependencies —
useful for a single-purpose box (a Raspberry Pi playing a local flac useful for a single-purpose box (a Raspberry Pi playing a local flac
@ -43,10 +43,28 @@ The same list goes into the server's startup log, so a support question
| `spectrum` | the server-side FFT feeding clients' spectrum bars | | `spectrum` | the server-side FFT feeding clients' spectrum bars |
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) | | `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
Plus two conveniences: `all-providers` enables the eight provider features Plus `all-providers`, which enables the eight provider features at once,
at once, and `cbd` (the bundle) mirrors every feature above and adds and the TUI's two desktop features — `notifications` and `mpris` (below).
`notifications` for the TUI's desktop "now playing" popups (`notify-rust`, `cbd` (the bundle) mirrors all of them.
which on Linux pulls a D-Bus stack).
### `notifications` and `mpris`
Both belong to `cbd-tui` and both are on by default; both put the client
on the session bus, and neither costs the server anything.
| Feature | Turning it off drops |
| --------------- | ------------------------------------------------- |
| `notifications` | the desktop "now playing" popups (`notify-rust`) |
| `mpris` | the MPRIS player: media keys, status bars |
`mpris` publishes the MPRIS2 interfaces so the `XF86Audio*` keys and
status-bar modules drive the server — see
[Terminal UI](./clients/tui.md#media-keys-and-the-desktop). It costs one
crate (`mpris-server`) and no system library: the `zbus` underneath it
speaks D-Bus in pure Rust, and `notify-rust` already brought it in. Drop
it for a client on a machine with no desktop session; it also stands down
by itself when there is no session bus to sit on, so a client over ssh
needs no separate build.
### `fs` is more than `/fs` ### `fs` is more than `/fs`
@ -131,12 +149,19 @@ The bundle, tailored the same way (its features forward to the server):
cargo build --release -p cbd --no-default-features --features fs,opus cargo build --release -p cbd --no-default-features --features fs,opus
``` ```
A terminal client with no D-Bus dependency: A terminal client with no D-Bus dependency at all — no popups, no media
keys:
```sh ```sh
cargo build --release -p cbd-tui --no-default-features cargo build --release -p cbd-tui --no-default-features
``` ```
Or one that keeps the media keys and drops the popups:
```sh
cargo build --release -p cbd-tui --no-default-features --features mpris
```
`--no-default-features` on its own is legal and compiles: you get a server `--no-default-features` on its own is legal and compiles: you get a server
that starts, serves an empty library and plays nothing. It is the base case that starts, serves an empty library and plays nothing. It is the base case
the feature matrix checks, not a useful deployment. the feature matrix checks, not a useful deployment.

View File

@ -212,6 +212,47 @@ holds one glyph, so a shadow there would eat bar to repeat what the bar's top
edge already shows. Shadows fall away when playback stops, since the server edge already shows. Shadows fall away when playback stops, since the server
keeps streaming zeroed bars while the audio is idle. keeps streaming zeroed bars while the audio is idle.
## Media keys and the desktop
While the TUI runs it also **is** a media player as far as the desktop is
concerned: it publishes the MPRIS2 interfaces on the session bus, so the
`XF86Audio*` keys on your keyboard control the server, and status bars can
show what is playing. GNOME and KDE route the keys themselves; on sway/i3
they are usually bound to `playerctl`.
```sh
playerctl -p crabidy status # Playing
playerctl -p crabidy metadata --format \
'{{artist}} - {{title}} ({{duration(mpris:length)}})'
playerctl -p crabidy play-pause # same as Space
playerctl -p crabidy position 30+ # same as pressing . twice
```
Play, pause, stop, next, previous, seek, shuffle, repeat and the volume
are all wired to the same RPCs the keys in the table below send, so it
does not matter which one you reach for — and a change made from either
shows up in both. Some details worth knowing:
- The player appears as `crabidy.instance<pid>`, the per-instance name the
spec asks for. `playerctl -p crabidy` matches it anyway (it ignores the
instance suffix), as does anything built on playerctl — waybar's mpris
module among them. Two TUIs on one desktop therefore show up as two
players instead of fighting over one name.
- **Muting reads as volume 0**, since MPRIS has no mute of its own.
Setting the volume to 0 mutes (so unmuting restores your level); setting
any other value unmutes and moves to it.
- Repeat-one is not offered: the server repeats the queue, so the loop
status is `None` or `Playlist` and asking for `Track` is refused.
- The desktop cannot quit the client. Closing a terminal UI from a
status-bar button is not something a status bar should be able to do.
- No metadata leaves the session other than title, artist, album,
duration and a queue identifier — in particular no URLs.
It exists only while the TUI does: close the client and the media keys go
quiet. The feature is `mpris`, on by default; a build without it
(`--no-default-features`) touches the bus only for desktop notifications,
if those are compiled in.
## Key bindings ## Key bindings
Global keys work in either pane. Pane keys apply only while that pane is Global keys work in either pane. Pane keys apply only while that pane is

View File

@ -62,11 +62,13 @@
# all* (see docs/src/build-features.md) — the features must be named # all* (see docs/src/build-features.md) — the features must be named
# or this package ships an empty server. The # or this package ships an empty server. The
# aarch64 server (below) takes the full defaults and stages the wasm # aarch64 server (below) takes the full defaults and stages the wasm
# bundle. `notifications` is cbd-tui's own default and needs no # bundle. `notifications` and `mpris` are cbd-tui's own defaults, but
# mention here. `opus-bundled` is deliberately absent: Opus decoding # naming the feature set at all replaces them, so they have to be
# is on, but libopus comes from nixpkgs rather than a cmake build of # listed or the installed client loses its desktop integration; both
# the vendored source. # are pure-Rust D-Bus and add no buildInput. `opus-bundled` is
headlessFeatures = "all-providers,opus,spectrum,notifications"; # deliberately absent: Opus decoding is on, but libopus comes from
# nixpkgs rather than a cmake build of the vendored source.
headlessFeatures = "all-providers,opus,spectrum,notifications,mpris";
# --- Web UI (wasm) bundle --------------------------------------------- # --- Web UI (wasm) bundle ---------------------------------------------
# cbd-web is compiled to wasm by trunk; the output dist/ is what # cbd-web is compiled to wasm by trunk; the output dist/ is what

View File

@ -1870,3 +1870,53 @@ default block was already the default. The renderer's `SpectrumStyle::default`
and the config's `#[default]` are two spellings of one thing, so the config and the config's `#[default]` are two spellings of one thing, so the config
test now asserts the whole resolved style equals `SpectrumStyle::default()` test now asserts the whole resolved style equals `SpectrumStyle::default()`
rather than field-by-field — the pair cannot drift apart silently. rather than field-by-field — the pair cannot drift apart silently.
## MPRIS: the desktop's media player (2026-07-28)
The recollection that the TUI used to be hooked into the Linux media system
turned out to be false — `git log --all -S mpris` finds nothing, on any
branch. What exists, and has since before `fable`, is the TUI's desktop
*notification* on every track change, which is D-Bus but not MPRIS: a
transient popup is neither a status-bar entry nor a media-key target. So this
is a new feature, not a regression.
It went in as a translation layer, not new player state
(`architecture/mpris.md`). Everything MPRIS needs was already on the wire, so
`cbd-tui` gained a second front-end onto the two channels it already has:
stream updates in, `MessageFromUi` out. The MPRIS player is therefore a peer
of the UI thread — it commands the server through the very channel the
keybindings use, and it learns the result the same way the UI learns about a
keypress from another client.
The interesting decisions are all about a protocol that is *absolute* meeting
a server that offers *toggles*. `Play`/`Pause`/`SetShuffle`/`SetLoopStatus`
consult the last state the server broadcast and send nothing when it already
matches — otherwise the pause media key would start playback on a paused
player, which is the whole bug class here. Volume is the same idea with
arithmetic: MPRIS has no mute, so a muted server publishes volume 0 (what a
status bar should show) while the setter works off the true level, muting on a
zero target so unmuting restores it. Two commands can be needed for one call
(unmute, then move); they travel one ordered channel into one sequential RPC
loop, so they cannot cross.
What is deliberately *not* published: any URL. `xesam:url` would have to be
the stream URL, which clients never see and which several providers sign with
credentials — and every peer on a session bus can read properties. The
trackid is the queue position instead of the library path, which is also the
only spelling that is a valid D-Bus object path. `mpris:length` prefers the
live millisecond duration and is *omitted* when unknown, because a zero
length makes consumers draw a full progress bar.
Two things the unit tests could not have told me. `playerctl` — the tool the
keys are actually bound to — matched `-p crabidy` against the
`crabidy.instance<pid>` bus name, confirming the spec's per-instance naming
costs nothing in usability, and drove play-pause, `position 30+` and
`volume 0.8` into the right commands. It also *refused* `next` with "no
player could handle this command", because the probe published an empty
queue and `CanGoNext` is honest about that — a real client reading the
capability flags, which is why they are computed rather than hardcoded true.
The first probe run hung, and the cause was my probe: blocking
`std::process::Command` on a current-thread test runtime starves the runtime
zbus dispatches on. The shipped binaries are multi-threaded, and the UI runs
on `spawn_blocking`, so the shape is fine — but it is a real trap for anyone
writing another bus test.

76
quality/mpris.md Normal file
View File

@ -0,0 +1,76 @@
# Quality gates — MPRIS
Criteria an implementation of `architecture/mpris.md` must satisfy.
Automated coverage lives in `cbd-tui/src/mpris.rs` (the mapping, as unit
tests) and `cbd-tui/tests/mpris_bus.rs` (the round trip over a real
session bus, skipped when there is none).
## Hard rules (highest priority)
- [x] **G1 — No panic, whatever the bus does.** A missing session bus, a
bus that never answers, a refused name, a failed property emission and a
dropped connection are all logged and survived (D13). The connection
attempt carries a timeout, as an external call must. *(A missing bus is
exercised by every test run: `start` returns `None` and the client runs
on.)*
- [x] **G2 — No secret and no credential-bearing URL reaches the bus.**
Published metadata is title, artist, album, length and a synthetic
trackid — no `xesam:url`, no `mpris:artUrl`, no library path (D8). Every
peer on a session bus can read properties, so this is a redaction
boundary, not a matter of taste. *(tests:
`metadata_names_the_track_and_never_a_url`, and the bus test asserts the
library path is absent from what a client actually receives.)*
- [x] **G3 — The state feed is bounded.** 64 slots, `try_send`, a
`debug!` on overflow; the orchestrator never awaits the bus (D12).
*(test: `a_full_feed_drops_instead_of_blocking`.)*
- [x] **G4 — No lock is held across an `await`.** The published mirror
sits behind a `std::sync::Mutex` whose guard never crosses a suspension
point; a poisoned lock is recovered rather than propagated as a panic.
- [x] **G5 — Nothing is published that the server did not say.** No
optimistic local write on a method call, no interpolated position, no
cached duration carried across a track change (D2).
## Behaviour
- [x] **G6 — A pause key cannot start playback.** `Play`, `Pause` and the
modifier setters consult the published state and send nothing when the
target is already the current value; only `PlayPause` toggles
unconditionally (D3). *(tests:
`pause_never_starts_playback_and_play_never_stops_it`,
`play_pause_always_toggles`, `the_modifiers_toggle_only_when_they_differ`.)*
- [x] **G7 — Mute round-trips.** Volume 0 mutes rather than turning the
level down, so unmuting restores it; a non-zero target unmutes and then
moves by the delta (D4). *(tests:
`volume_is_muted_as_zero_and_restored_by_unmuting`,
`setting_a_volume_sends_the_delta_to_it`.)*
- [x] **G8 — Unrepresentable requests are refused, not approximated.**
`LoopStatus::Track`, a playback rate other than 1.0, `OpenUri`, `Quit`,
`Raise` and `SetFullscreen` answer `NotSupported` and change nothing
(D6, D14). *(test: `a_single_track_loop_is_refused_not_faked`.)*
- [x] **G9 — An unknown duration is omitted, not zero.** A zero
`mpris:length` draws a full progress bar in consumers (D9). *(tests:
`an_unknown_duration_is_omitted_not_zero`,
`the_live_duration_wins_over_the_tracks_own`.)*
- [x] **G10 — `Seeked` fires on a discontinuity and not on the clock or a
track change** (D10). *(test:
`a_position_jump_is_a_seek_but_a_new_track_is_not`.)*
- [x] **G11 — Only real changes are announced.** A `PropertiesChanged`
for a value that did not change wakes every listener for nothing.
*(test: `only_real_changes_are_announced`.)*
- [x] **G12 — A stale `SetPosition` is ignored.** The spec's trackid
argument exists so a seek aimed at a track that has since changed does
not move the one now playing. *(test:
`set_position_becomes_a_delta_and_ignores_a_stale_track`.)*
## Build and shape
- [x] **G13 — The feature carries its own weight and nothing else's.**
`mpris` adds one crate and no system library; `--no-default-features`
compiles, tests and clippies clean through a stub of the same shape, and
the matrix in `check-features` covers `mpris` alone, `notifications`
alone, and neither.
- [x] **G14 — The server is untouched.** No proto change, no server
change; the feature is a client-side translation or it is misplaced.
- [x] **G15 — Verified against a real client, not only a test double.**
`playerctl` must list the player, read its metadata, and drive
play-pause, seek and volume. Re-check on a `mpris-server`/`zbus` bump.