From a2e3da6aa5cb8d75c3dbc4f0d092cb23ecf6f632 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 28 Jul 2026 01:27:28 +0200 Subject: [PATCH] cbd-tui: speak MPRIS, so the media keys reach the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 27 + Cargo.toml | 3 + README.md | 3 +- architecture/mpris.md | 250 +++++++++ cbd-tui/Cargo.toml | 8 +- cbd-tui/src/app/mod.rs | 5 + cbd-tui/src/lib.rs | 54 +- cbd-tui/src/mpris.rs | 1052 ++++++++++++++++++++++++++++++++++++ cbd-tui/src/rpc.rs | 12 +- cbd-tui/tests/mpris_bus.rs | 145 +++++ cbd/Cargo.toml | 2 + devenv.nix | 6 + docs/src/build-features.md | 37 +- docs/src/clients/tui.md | 41 ++ flake.nix | 12 +- plan/summary.md | 50 ++ quality/mpris.md | 76 +++ 17 files changed, 1766 insertions(+), 17 deletions(-) create mode 100644 architecture/mpris.md create mode 100644 cbd-tui/src/mpris.rs create mode 100644 cbd-tui/tests/mpris_bus.rs create mode 100644 quality/mpris.md diff --git a/Cargo.lock b/Cargo.lock index af15b06..e390e62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,7 @@ dependencies = [ "crossterm", "dirs", "flume", + "mpris-server", "notify-rust", "ratatui", "serde", @@ -2947,6 +2948,19 @@ dependencies = [ "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]] name = "multimap" version = "0.10.1" @@ -5494,6 +5508,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -5834,6 +5849,17 @@ dependencies = [ "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]] name = "transpose" version = "0.2.3" @@ -6782,6 +6808,7 @@ dependencies = [ "rustix", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 7ea378d..8525d98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ http = "1" include_dir = "0.7" leptos = { version = "0.8", default-features = false, features = ["csr"] } 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" percent-encoding = "2" prost = "0.14" diff --git a/README.md b/README.md index 2e8319d..00fa287 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ another terminal, `cargo run -p cbd-tui`. ### Tailored builds 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 it (`opus-bundled` is the one to drop if you would rather link the system libopus than compile the vendored copy): diff --git a/architecture/mpris.md b/architecture/mpris.md new file mode 100644 index 0000000..6b68ee7 --- /dev/null +++ b/architecture/mpris.md @@ -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.`. 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/`), 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`, 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 +} + +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` 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. diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index 8bcafcb..e371c88 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -7,8 +7,13 @@ edition.workspace = true # D-Bus stack. On by default; off for a terminal-only client # (architecture/build-features.md D1). [features] -default = ["notifications"] +default = ["notifications", "mpris"] 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] base64.workspace = true @@ -19,6 +24,7 @@ clap.workspace = true dirs.workspace = true toml.workspace = true flume.workspace = true +mpris-server = { workspace = true, optional = true } notify-rust = { workspace = true, optional = true } ratatui.workspace = true serde.workspace = true diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 597ffec..a1059b6 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -109,6 +109,7 @@ pub enum MessageToUi { } // FIXME: Rename this +#[derive(Debug)] pub enum MessageFromUi { GetLibraryNode(String), /// Create a child node (e.g. a search term) under a creatable parent; @@ -156,6 +157,10 @@ pub enum MessageFromUi { Seek(i64), SetCurrentTrack(usize), 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), ToggleMute, ToggleShuffle, diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index dca7c14..1a44ce9 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -8,6 +8,35 @@ pub mod app; pub mod config; 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) -> Option { + None + } +} + use std::{ error::Error, io, @@ -39,8 +68,12 @@ pub async fn run(config: &'static Config) -> Result<(), Box> { let (ui_tx, rx): (Sender, Receiver) = flume::unbounded(); let (tx, ui_rx): (Sender, Receiver) = 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 - 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; // 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> { async fn orchestrate( config: &'static Config, (tx, rx): (Sender, Receiver), + commands: Sender, ) -> Result<(), Box> { info!(address = config.server.address, "connecting to server"); let mut rpc_client = rpc::RpcClient::connect(&config.server).await?; @@ -65,12 +99,19 @@ async fn orchestrate( 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?; info!("received initial state from server"); + if let Some(mpris) = &mpris { + mpris.publish_init(&init_data); + } tx.send_async(MessageToUi::Init(init_data)).await?; 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}"); } } @@ -80,6 +121,7 @@ async fn poll( rpc_client: &mut RpcClient, rx: &Receiver, tx: &Sender, + mpris: &Option, ) -> Result<(), Box> { select! { Ok(msg) = &mut rx.recv_async() => { @@ -158,6 +200,9 @@ async fn poll( MessageFromUi::TogglePlay => { rpc_client.toggle_play().await? } + MessageFromUi::Stop => { + rpc_client.stop().await? + } MessageFromUi::ChangeVolume(delta) => { rpc_client.change_volume(delta).await? } @@ -196,6 +241,11 @@ async fn poll( match resp { Ok(resp) => { 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?; } } diff --git a/cbd-tui/src/mpris.rs b/cbd-tui/src/mpris.rs new file mode 100644 index 0000000..1d4345e --- /dev/null +++ b/cbd-tui/src/mpris.rs @@ -0,0 +1,1052 @@ +//! The MPRIS player on the session bus (architecture/mpris.md). +//! +//! What a Linux desktop calls a media player: `org.mpris.MediaPlayer2` and +//! `org.mpris.MediaPlayer2.Player` served at `/org/mpris/MediaPlayer2`, so +//! the media keys, `playerctl` and status-bar modules reach the server this +//! TUI is looking at. +//! +//! This is a second front-end, not a second source of truth (D2). Every +//! method turns into the same [`MessageFromUi`] a keybinding sends, and every +//! published property is a mirror of the server's update stream — nothing is +//! written locally on a method call and un-written when the server disagrees. + +use std::sync::{Mutex, MutexGuard}; + +use crabidy_core::proto::crabidy::{ + get_update_stream_response::Update as StreamUpdate, InitResponse, PlayState, QueueModifiers, + Track, TrackPosition, +}; +use flume::{Receiver, Sender, TrySendError}; +use mpris_server::{ + zbus::{fdo, Result as ZbusResult}, + LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface, + Server, Signal, Time, TrackId, Uri, Volume, +}; +use tracing::{debug, info}; + +use crate::app::MessageFromUi; + +/// Slots in the state feed. Bounded, and dropped rather than awaited when +/// full: a wedged bus must not stall the orchestrator (D12). +const FEED_CAPACITY: usize = 64; + +/// How long to wait for the session bus before giving up on MPRIS (D13). +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// A position that moves further than this between two broadcasts is a seek +/// rather than the clock: the server ticks positions four times a second +/// (D10). +const SEEK_DISCONTINUITY_MS: i64 = 1_000; + +/// Object-path prefix for `mpris:trackid`. The queue position is the +/// identity, because a library path is not a valid object path (D8). +const TRACK_ID_PREFIX: &str = "/org/crabidy/queue/"; + +/// Starts the MPRIS player and returns the feed the orchestrator publishes +/// server state through. +/// +/// Returns `None` — and logs why at `info` — when there is no session bus to +/// sit on: a client over ssh, in a tty or in a container is an ordinary way +/// to run, not an error (D13). +pub async fn start(commands: Sender) -> Option { + let suffix = format!("crabidy.instance{}", std::process::id()); + let player = CrabidyPlayer::new(commands); + // A local socket, but still an external call: a bus that never answers + // must not hold up the client's startup. + let server = match tokio::time::timeout(CONNECT_TIMEOUT, Server::new(&suffix, player)).await { + Ok(Ok(server)) => server, + Ok(Err(err)) => { + info!("no MPRIS player on the session bus: {err}"); + return None; + } + Err(_) => { + info!("no MPRIS player: the session bus did not answer in time"); + return None; + } + }; + info!(bus_name = %server.bus_name(), "MPRIS player registered"); + + let (tx, rx) = flume::bounded(FEED_CAPACITY); + tokio::spawn(publish_loop(server, rx)); + Some(Feed(tx)) +} + +/// The orchestrator's end of the state feed. +/// +/// Deliberately shaped after the update stream rather than after MPRIS: the +/// caller forwards what the server said and this module decides what of it +/// the desktop can see. +#[derive(Debug)] +pub struct Feed(Sender); + +impl Feed { + /// Forwards one stream update. Updates MPRIS has no use for — spectrum + /// frames, capture progress, queue *contents* — are dropped here, so the + /// feed only ever carries player state (D2, D12). + pub fn publish(&self, update: &StreamUpdate) { + let state = match update { + StreamUpdate::QueueTrack(queue_track) => StateUpdate::Track { + track: queue_track.track.clone(), + queue_position: queue_track.queue_position, + }, + StreamUpdate::PlayState(play_state) => match PlayState::try_from(*play_state) { + Ok(play_state) => StateUpdate::PlayState(play_state), + Err(_) => return, + }, + StreamUpdate::Position(position) => StateUpdate::Position(*position), + StreamUpdate::Volume(volume) => StateUpdate::Volume(*volume), + StreamUpdate::Mute(muted) => StateUpdate::Mute(*muted), + StreamUpdate::Mods(mods) => StateUpdate::Mods(*mods), + // The length decides CanGoNext/CanGoPrevious; the tracks + // themselves are none of the desktop's business. + StreamUpdate::Queue(queue) => StateUpdate::QueueLen(queue.tracks.len()), + StreamUpdate::CaptureProgress(_) | StreamUpdate::Spectrum(_) => return, + }; + self.send(state); + } + + /// Publishes the initial state, so a status bar shows the current track + /// from the moment the client connects rather than from the next change. + pub fn publish_init(&self, init: &InitResponse) { + if let Some(queue) = &init.queue { + self.send(StateUpdate::QueueLen(queue.tracks.len())); + } + if let Some(queue_track) = &init.queue_track { + self.send(StateUpdate::Track { + track: queue_track.track.clone(), + queue_position: queue_track.queue_position, + }); + } + if let Ok(play_state) = PlayState::try_from(init.play_state) { + self.send(StateUpdate::PlayState(play_state)); + } + if let Some(position) = init.position { + self.send(StateUpdate::Position(position)); + } + if let Some(mods) = init.mods { + self.send(StateUpdate::Mods(mods)); + } + self.send(StateUpdate::Volume(init.volume)); + self.send(StateUpdate::Mute(init.mute)); + } + + fn send(&self, update: StateUpdate) { + match self.0.try_send(update) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + debug!("MPRIS feed is full, dropping a state update"); + } + Err(TrySendError::Disconnected(_)) => { + debug!("MPRIS player is gone, dropping a state update"); + } + } + } +} + +/// The slice of server state MPRIS republishes. +#[derive(Debug)] +enum StateUpdate { + Track { + track: Option, + queue_position: u32, + }, + PlayState(PlayState), + Position(TrackPosition), + Volume(f32), + Mute(bool), + Mods(QueueModifiers), + QueueLen(usize), +} + +/// Applies state updates to the published mirror and announces what changed. +/// +/// Ends when the orchestrator drops the feed, which is when the client is +/// shutting down; dropping the [`Server`] releases the bus name. +async fn publish_loop(server: Server, rx: Receiver) { + while let Ok(update) = rx.recv_async().await { + let (properties, signal) = server.imp().apply(update); + if !properties.is_empty() { + if let Err(err) = server.properties_changed(properties).await { + debug!("could not announce MPRIS properties: {err}"); + } + } + if let Some(signal) = signal { + if let Err(err) = server.emit(signal).await { + debug!("could not emit an MPRIS signal: {err}"); + } + } + } +} + +/// The published mirror of the server's player state (D2). +#[derive(Debug)] +struct PlayerState { + track: Option, + queue_position: u32, + queue_len: usize, + play_state: PlayState, + position_ms: u32, + /// The live duration in milliseconds; 0 when the server has not said + /// (a stream, or nothing loaded). + duration_ms: u32, + volume: f32, + muted: bool, + mods: QueueModifiers, + /// The next position update follows a track change, so its jump to the + /// head of the new track is not a seek (D10). + track_just_changed: bool, +} + +impl Default for PlayerState { + fn default() -> Self { + Self { + track: None, + queue_position: 0, + queue_len: 0, + play_state: PlayState::Unspecified, + position_ms: 0, + duration_ms: 0, + // Not 0.0: an unknown volume must not read as silence. + volume: 1.0, + muted: false, + mods: QueueModifiers::default(), + track_just_changed: false, + } + } +} + +impl PlayerState { + /// `PlaybackStatus` as the desktop's three states. Loading is the gap + /// while a track opens and reads as playing, so a status bar does not + /// flicker "paused" on every track change (D7). + fn playback_status(&self) -> PlaybackStatus { + match self.play_state { + PlayState::Playing | PlayState::Loading => PlaybackStatus::Playing, + PlayState::Paused => PlaybackStatus::Paused, + PlayState::Stopped | PlayState::Unspecified => PlaybackStatus::Stopped, + } + } + + /// What the desktop should show as the volume: 0 while muted, because + /// MPRIS has no mute of its own (D4). + fn published_volume(&self) -> Volume { + if self.muted { + 0.0 + } else { + self.volume.into() + } + } + + /// `mpris:length`, preferring the live millisecond duration over the + /// coarse one on the track and omitting the field when neither knows + /// (D9). + fn length(&self) -> Option