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:
parent
621823d930
commit
a2e3da6aa5
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
12
flake.nix
12
flake.nix
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue