`qbz-models` is the base of the dependency graph and cannot reach `qbz-source`, so `artwork_ref_with_plex` knows exactly one remote word: "plex". A `jellyfin:<albumId>/<tag>` token fell through to its LocalFile arm, which handed MPRIS a file:// URI to a path that does not exist — the bar and the queue were fine (they resolve through the Qt art cache), only MPRIS was blank. Resolve source-tagged tokens in the Qt layer, where `qbz-source` IS reachable, and hand qbz-models an already-resolved local path. Verified over the session bus: a Jellyfin track now reports mpris:artUrl -> a real 256x256 JPEG in the image cache. cc
546 lines
26 KiB
Rust
546 lines
26 KiB
Rust
//! System media controls — MPRIS on Linux (what makes the KDE Plasma
|
|
//! now-playing widget, GNOME Shell's media section and the hardware media keys
|
|
//! see QBZ at all), SMTC / MediaRemote on Windows / macOS.
|
|
//!
|
|
//! The backend is the frontend-agnostic `qbz-media-controls` crate (ADR-006),
|
|
//! consumed VERBATIM — this module is only the Qt-side glue: it owns the
|
|
//! process-global handle, the metadata de-dupe, the artwork resolver MPRIS
|
|
//! needs, and it bridges inbound control events onto the transport verbs.
|
|
//! Playback metadata/state is PUSHED from `playback_qt.rs`, mirroring the tray
|
|
//! (`src/tray_qt.rs`).
|
|
//!
|
|
//! Ported from `crates/qbz/src/media_controls.rs`; where the Slint glue and
|
|
//! the daemon's (`crates/qbzd/src/mpris.rs`) disagree, the DAEMON is the
|
|
//! precedent, because qbz-qt is likewise not a Slint app:
|
|
//!
|
|
//! - **No captured runtime.** The Slint closure captures a strong
|
|
//! `Arc<AppRuntime>` plus a `slint::Weak<AppWindow>` and a tokio `Handle`
|
|
//! (`media_controls.rs:29-35`); the daemon deliberately holds only a
|
|
//! `Weak` so the integration can never pin the runtime (#521 audio-release
|
|
//! ordering, `qbzd/src/mpris.rs:15-18`). Qt needs NEITHER: `crate::app()`
|
|
//! (`src/main.rs:312`) and `crate::spawn()` (`:317`) are process-global
|
|
//! `OnceLock`s, so the callback captures nothing at all and the inbound
|
|
//! `fn` is a plain function item. Same property `tray_qt.rs:224-228` cites
|
|
//! for the ksni thread.
|
|
//! - **Sync core commands run on the D-Bus thread.** The callback runs on the
|
|
//! mpris-server thread — NOT a tokio worker, NOT the Qt thread
|
|
//! (`qbzd/src/mpris.rs:148-151`). `core().stop()` and
|
|
//! `core().get_playback_state()` are synchronous, so they are called
|
|
//! directly like the daemon does, and everything async is handed to the
|
|
//! `crate::transport_*` verbs, which do their own `app()` + `spawn()`.
|
|
//!
|
|
//! ## Threading — do NOT "simplify" any of this
|
|
//!
|
|
//! OUTBOUND (`set_metadata` / `set_playback`) is free from any thread: the
|
|
//! Linux handle's methods are non-blocking sends onto the server's channel.
|
|
//! INBOUND must never block: the D-Bus thread services every method call, so
|
|
//! nothing here waits on the network — the transport verbs return immediately
|
|
//! after spawning.
|
|
//!
|
|
//! This process ALREADY publishes on D-Bus (the ksni StatusNotifierItem on its
|
|
//! own thread, plus whatever libQt6DBus opens). MPRIS adds a THIRD connection
|
|
//! on its own thread. They coexist only because zbus 4 (mpris-server) and
|
|
//! zbus 5 (ksni / ashpd / rfd) unify features per-major: **never enable a
|
|
//! `tokio` feature on any D-Bus dependency** or the async-io threads panic
|
|
//! with "there is no reactor running" (see the ksni comment in Cargo.toml).
|
|
//!
|
|
//! ## No teardown
|
|
//!
|
|
//! Like the tray (`tray_qt.rs:230-234`) and the reference (`grep
|
|
//! 'media_controls' crates/qbz/src/auth.rs` → 0 hits) the integration is
|
|
//! process-scoped: `init` is idempotent, logout does not touch it, and there
|
|
//! is no `shutdown`. Do not add one.
|
|
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use qbz_media_controls::{MediaEvent, MediaIntegration};
|
|
|
|
/// The live integration, or `None` when it never started (no session bus, an
|
|
/// unsupported platform, or the name was already owned). Boxed behind a
|
|
/// `OnceLock` exactly like `tray_qt::TRAY` (`src/tray_qt.rs:159`).
|
|
static CONTROLS: OnceLock<Box<dyn MediaIntegration>> = OnceLock::new();
|
|
|
|
/// Last `(track id, resolved art URL)` pushed as metadata.
|
|
/// `refresh_now_playing` re-runs on resume / seek / republish — it has 23 call
|
|
/// sites across nine modules (`src/playback_qt.rs:1777-1779`) — so metadata is
|
|
/// only re-pushed when this key actually changes. 1:1 with the reference's
|
|
/// `MPRIS_LAST_META` (`crates/qbz/src/playback.rs:1723-1724`), including the
|
|
/// art field: the URL a widget can load changes when the cover resolves
|
|
/// differently (offline), which the track id alone cannot see.
|
|
/// `None` = nothing pushed yet / cleared.
|
|
static MPRIS_LAST_META: Mutex<Option<(u64, Option<String>)>> = Mutex::new(None);
|
|
|
|
/// Track id of the last DESKTOP NOTIFICATION raised, so only a real track
|
|
/// change can raise one. Separate from `MPRIS_LAST_META` on purpose: that key
|
|
/// includes the resolved art, so a cover that resolves differently re-pushes
|
|
/// metadata — correct for a widget, wrong for a toast. 1:1 with the
|
|
/// reference's `NOTIFY_LAST_TRACK` (`crates/qbz/src/playback.rs:1708`),
|
|
/// including the `u64::MAX` poison on stop so replaying the SAME track after a
|
|
/// stop notifies again.
|
|
static NOTIFY_LAST_TRACK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
|
|
/// The live integration, if it started. Playback pushes metadata/state through
|
|
/// it. Mirrors `tray_qt::handle()` (`src/tray_qt.rs:165`).
|
|
pub(crate) fn handle() -> Option<&'static dyn MediaIntegration> {
|
|
CONTROLS.get().map(|b| b.as_ref())
|
|
}
|
|
|
|
/// Start the OS media-controls integration once. No-op if already started or
|
|
/// unavailable on this platform.
|
|
///
|
|
/// NOT platform-gated, because the shared crate is not: `qbz_media_controls::
|
|
/// spawn` routes to the MPRIS backend on Linux, souvlaki on macOS/Windows and
|
|
/// returns `None` everywhere else (`crates/qbz-media-controls/src/lib.rs:36-53`),
|
|
/// so both consumers declare the dependency plain (`crates/qbz/Cargo.toml:80`,
|
|
/// `crates/qbzd/Cargo.toml:22`). Linux is the platform this port CLAIMS: under
|
|
/// Qt the souvlaki callbacks would have to be serviced by a Qt-owned run loop,
|
|
/// which nothing in this tree establishes.
|
|
///
|
|
/// A non-`None` handle does NOT mean the bus name was acquired — registration
|
|
/// happens asynchronously on the server thread and only logs on failure. The
|
|
/// smoke check is the `[mpris] registered org.mpris.MediaPlayer2.com.blitzfc.qbz`
|
|
/// line, not this one.
|
|
pub(crate) fn init() {
|
|
if CONTROLS.get().is_some() {
|
|
return;
|
|
}
|
|
match qbz_media_controls::spawn(dispatch) {
|
|
Some(c) => {
|
|
let _ = CONTROLS.set(c);
|
|
log::info!("[media-controls] integration started");
|
|
}
|
|
None => log::info!("[media-controls] no integration on this platform"),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outbound — pushed from playback_qt (siblings of the tray pushes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The has-track arm: metadata (de-duped) plus the OPTIMISTIC `Playing` at
|
|
/// position 0. 1:1 with `crates/qbz/src/playback.rs:2208-2223`, where
|
|
/// `set_playback` is deliberately unconditional — a stream is about to open,
|
|
/// and the poll loop's play/pause edge corrects it if it does not.
|
|
///
|
|
/// `title` is the VERSIONED title and `album` the release-variant
|
|
/// `album_display`, NOT the clean `track.album` the tray tooltip uses
|
|
/// (`playback.rs:2213` vs `:2164`) — the two are not interchangeable.
|
|
///
|
|
/// Pushing `set_playback` on every call is also what keeps the sleep inhibitor
|
|
/// alive: the Linux backend hangs #522's inhibit on the playback update, and
|
|
/// the inhibitor type is private, so there is no other way to reach it.
|
|
pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, album: &str) {
|
|
// The notification is NOT downstream of the MPRIS handle. A machine with no
|
|
// session-bus MPRIS (or a platform the shared crate has no backend for)
|
|
// still has a notification daemon, and the reference likewise raises the
|
|
// toast outside its `if let Some(mc) = handle()` block
|
|
// (`crates/qbz/src/playback.rs:2202` vs `:2228`). Hanging it off the early
|
|
// return here would have made the switch dead all over again, just for a
|
|
// smaller set of users.
|
|
maybe_notify(track, title, album);
|
|
let Some(mc) = handle() else {
|
|
return;
|
|
};
|
|
let art = art_url_for(track);
|
|
if meta_changed(&(track.id, art.clone())) {
|
|
// `qbz_media_controls::TrackMeta` — NOT `crate::now_playing::TrackMeta`
|
|
// (`src/now_playing.rs:261`), a different 13-field struct. Fully
|
|
// qualified at every use in this module for that reason.
|
|
mc.set_metadata(&qbz_media_controls::TrackMeta {
|
|
title: title.to_string(),
|
|
artist: track.artist.clone(),
|
|
album: album.to_string(),
|
|
duration: (track.duration_secs > 0)
|
|
.then(|| Duration::from_secs(track.duration_secs)),
|
|
art_url: art,
|
|
// `xesam:url` (#658): the public Qobuz link for catalog rows, None
|
|
// for local/plex (`qbz-media-controls/src/types.rs:30-36`).
|
|
url: qbz_media_controls::xesam_url_for(track.source.as_deref(), track.id),
|
|
});
|
|
// One line per real track edge. It exists because "Plasma sees
|
|
// nothing" has two very different causes — the player never
|
|
// registered, or it registered and no metadata was ever pushed — and
|
|
// without this the log cannot tell them apart. A desktop applet
|
|
// generally hides a player that is Stopped with empty metadata, so
|
|
// the FIRST push is the interesting event, not the hundredth.
|
|
log::info!(
|
|
"[mpris] metadata pushed: {} — {} ({}s)",
|
|
title,
|
|
track.artist,
|
|
track.duration_secs
|
|
);
|
|
}
|
|
mc.set_playback(
|
|
qbz_media_controls::PlaybackStatus::Playing,
|
|
Some(Duration::ZERO),
|
|
);
|
|
}
|
|
|
|
/// The no-track arm: reset the de-dupe (so replaying the SAME track after a
|
|
/// stop re-pushes metadata) and tell the widget we stopped.
|
|
/// `crates/qbz/src/playback.rs:1913-1922`, in that order.
|
|
pub(crate) fn clear_now_playing() {
|
|
if let Ok(mut last) = MPRIS_LAST_META.lock() {
|
|
*last = None;
|
|
}
|
|
// Poison the notify key, so playing the SAME track again after a stop
|
|
// raises a toast instead of being swallowed as "no change"
|
|
// (`crates/qbz/src/playback.rs:1911`, same `u64::MAX` sentinel — a real
|
|
// track id can never collide with it).
|
|
NOTIFY_LAST_TRACK.store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
|
|
if let Some(mc) = handle() {
|
|
mc.set_playback(qbz_media_controls::PlaybackStatus::Stopped, None);
|
|
}
|
|
}
|
|
|
|
/// The play/pause TRANSITION arm, carrying the live position in seconds.
|
|
/// `crates/qbz/src/playback.rs:5723-5730`.
|
|
///
|
|
/// Edge-triggered on purpose: `set_playback` emits a D-Bus `PropertiesChanged`,
|
|
/// so a per-tick push would spam every listening widget once a second. Between
|
|
/// transitions the MPRIS `Position` getter answers from the last value pushed
|
|
/// here — the same behaviour as the Slint build.
|
|
pub(crate) fn push_playback_state(is_playing: bool, position_secs: u64) {
|
|
let Some(mc) = handle() else {
|
|
return;
|
|
};
|
|
let status = if is_playing {
|
|
qbz_media_controls::PlaybackStatus::Playing
|
|
} else {
|
|
qbz_media_controls::PlaybackStatus::Paused
|
|
};
|
|
mc.set_playback(status, Some(Duration::from_secs(position_secs)));
|
|
}
|
|
|
|
/// Keep the reported position current, once per playback tick.
|
|
///
|
|
/// MPRIS `Position` is READ ON DEMAND — the spec keeps it out of
|
|
/// `PropertiesChanged` — so the backend serves whatever it last stored, and
|
|
/// before this it only stored one at a track start and at a play/pause edge.
|
|
/// A desktop widget therefore showed a value that could be minutes stale:
|
|
/// measured 2026-08-04 with QBZ at 4:03 and Plasma's widget stuck at 0:14.
|
|
///
|
|
/// `set_position` stores and emits nothing, so this is a channel send per
|
|
/// second and never a bus signal — it does not become the per-tick work
|
|
/// §7-M12 refuses.
|
|
pub(crate) fn push_position(position_secs: u64) {
|
|
if let Some(mc) = handle() {
|
|
mc.set_position(Duration::from_secs(position_secs));
|
|
}
|
|
}
|
|
|
|
/// Report a user SEEK — a discontinuous jump, not the ordinary advance.
|
|
///
|
|
/// `push_position` fixes what a client sees when it OPENS; this is what
|
|
/// reaches one that is ALREADY open. The owner's observation is what made the
|
|
/// distinction concrete: *"el contador del seekbar del widget solo empieza a
|
|
/// contar hasta que lo abro"* — the widget extrapolates from the value it read
|
|
/// at open and never looks again, so only the `Seeked` signal can tell it the
|
|
/// ground moved.
|
|
pub(crate) fn push_seeked(position_secs: u64) {
|
|
if let Some(mc) = handle() {
|
|
mc.seeked(Duration::from_secs(position_secs));
|
|
}
|
|
}
|
|
|
|
/// Compare-and-record the metadata de-dupe key. `true` when `key` differs from
|
|
/// the last pushed value (→ caller pushes now), recording it as the new
|
|
/// last-pushed value. A poisoned lock falls back to pushing.
|
|
/// `crates/qbz/src/playback.rs:1881-1893`.
|
|
fn meta_changed(key: &(u64, Option<String>)) -> bool {
|
|
match MPRIS_LAST_META.lock() {
|
|
Ok(mut last) => {
|
|
if last.as_ref() == Some(key) {
|
|
false
|
|
} else {
|
|
*last = Some(key.clone());
|
|
true
|
|
}
|
|
}
|
|
Err(_) => true,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The art resolver — `mpris:artUrl`
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The cover URL for `mpris:artUrl`, or `None` when there is nothing a widget
|
|
/// could load (the property is then simply omitted — no placeholder).
|
|
///
|
|
/// This is NOT `artwork_qt::cached_path`. That returns Qt's `file_url`
|
|
/// (`src/artwork_qt.rs:184-199`), which escapes only `%`, `#` and `?` and
|
|
/// leaves spaces raw — good enough for `QUrl`, not a valid URI for a media
|
|
/// widget. The URI comes from `qbz_models::ArtworkRef::to_mpris_url`
|
|
/// (`crates/qbz-models/src/source.rs:161-181`), whose LocalFile arm goes
|
|
/// through `url::Url::from_file_path` (full percent-encoding), so both
|
|
/// frontends hand the desktop identical URLs.
|
|
///
|
|
/// Source-aware exactly like the reference (`crates/qbz/src/playback.rs:2000`):
|
|
/// the ref is built with the CURRENT Plex credentials at `size: None` — MPRIS
|
|
/// art wants the large image — so a Plex track yields a fetchable tokenized
|
|
/// URL instead of a `file://`-poisoned miss. Local / offline-download covers
|
|
/// are already on disk and pass through as a `file://` URI.
|
|
///
|
|
/// The offline branch is the B11 rule (`playback.rs:2168-2198`): ONLINE a
|
|
/// remote Qobuz cover keeps its `https://` URL untouched — widgets fetch it
|
|
/// themselves, and the disk cache's extension-less `<md5>.img` copy is
|
|
/// REJECTED by the freedesktop mime database (`application/vnd.efi.img`),
|
|
/// which is what made every push carry a dead URL. OFFLINE nothing can fetch
|
|
/// https, so a cache hit becomes the `file://` copy (better than nothing for
|
|
/// widgets that sniff content) and a miss becomes no art at all. The cache is
|
|
/// therefore only consulted while offline — one fewer SQLite touch per track
|
|
/// on the normal path, with an identical result.
|
|
/// The cover reference both consumers start from — MPRIS art and the desktop
|
|
/// notification. Extracted so the two can never drift on the Plex credentials
|
|
/// or on the 600-tier rewrite; they diverge only on what they do with a
|
|
/// REMOTE cover, which is the whole point of the split (see `notify_art_for`).
|
|
fn artwork_ref_for(track: &qbz_models::QueueTrack) -> qbz_models::ArtworkRef {
|
|
// A SOURCE-TAGGED media token first, and it has to be first because
|
|
// `qbz-models` cannot resolve it. That crate is the base of the graph — it
|
|
// has no way to reach `qbz-source`, so `artwork_ref_with_plex` knows about
|
|
// exactly one server, and anything else falls to its `LocalFile` arm: a
|
|
// `jellyfin:<albumId>/<tag>` became a `file://` URI pointing at a path that
|
|
// does not exist, which is why the bar and the queue showed the cover and
|
|
// MPRIS did not.
|
|
//
|
|
// The frontend CAN resolve it, and by this point the cover is already in
|
|
// the shared disk cache (the now-playing feed fetched it), so this is a
|
|
// memo read. `LocalFile` rather than `Remote` on purpose: an MPRIS widget
|
|
// fetches a remote URL itself, and this one carries credentials.
|
|
if let Some(raw) = track.artwork_url.as_deref() {
|
|
if raw
|
|
.split_once(':')
|
|
.and_then(|(w, _)| qbz_source::SourceId::from_word(w))
|
|
.is_some_and(|id| {
|
|
matches!(
|
|
id,
|
|
qbz_source::SourceId::JELLYFIN | qbz_source::SourceId::SUBSONIC
|
|
)
|
|
})
|
|
{
|
|
if let Some(path) = crate::artwork_qt::cached_raw_path(raw) {
|
|
return qbz_models::ArtworkRef::LocalFile(path.to_string_lossy().into_owned());
|
|
}
|
|
// Not on disk yet: say NOTHING rather than hand the widget a
|
|
// `file://` to a token. An empty ref is a missing cover; a broken
|
|
// one is a broken cover.
|
|
return qbz_models::ArtworkRef::LocalFile(String::new());
|
|
}
|
|
}
|
|
let plex = crate::local_plex::settings();
|
|
let artwork = track.artwork_ref_with_plex(&plex.base_url, &plex.token, None);
|
|
// A restored queue can carry the 50px `small` Qobuz variant — the widget
|
|
// fetches this url itself, so a tiny suffix IS the pixelation the owner
|
|
// reported on the Plasma/GNOME art (2026-08-15). Rewrite to the 600 tier
|
|
// (and the offline cache lookup below then hits the very file the
|
|
// now-playing feed already downloaded at that tier).
|
|
match artwork {
|
|
qbz_models::ArtworkRef::Remote(u) => qbz_models::ArtworkRef::Remote(
|
|
qbz_models::qobuz_cover_at_px(&u, 600).unwrap_or(u),
|
|
),
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn art_url_for(track: &qbz_models::QueueTrack) -> Option<String> {
|
|
let artwork = artwork_ref_for(track);
|
|
let mut art = artwork.to_mpris_url();
|
|
if crate::offline_fwd::engine().is_offline() {
|
|
if let qbz_models::ArtworkRef::Remote(url) = &artwork {
|
|
art = crate::artwork_qt::cached_raw_path(url).and_then(|path| {
|
|
qbz_models::ArtworkRef::LocalFile(path.to_string_lossy().into_owned())
|
|
.to_mpris_url()
|
|
});
|
|
}
|
|
}
|
|
art
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The desktop "now playing" notification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Cover for the NOTIFICATION, which is not the same answer as `art_url_for`.
|
|
///
|
|
/// The notify pipeline strips `file://` and decodes the bytes BY CONTENT, so
|
|
/// the disk cache's extension-less `<md5>.img` copy is perfectly usable there
|
|
/// and saves a re-download — while MPRIS must NOT be handed that copy, because
|
|
/// widgets resolve `file://` through the freedesktop mime database, which maps
|
|
/// `*.img` by EXTENSION to `application/vnd.efi.img` and shows nothing (the B11
|
|
/// regression). So: a remote cover prefers the cached file here, and keeps the
|
|
/// remote URL when there is no cache hit — even offline, where the notifier's
|
|
/// own md5 cache may still serve it and the `offline` flag blocks the download.
|
|
/// 1:1 with `crates/qbz/src/playback.rs:2182-2198`.
|
|
fn notify_art_for(track: &qbz_models::QueueTrack) -> Option<String> {
|
|
let artwork = artwork_ref_for(track);
|
|
if let qbz_models::ArtworkRef::Remote(url) = &artwork {
|
|
if let Some(path) = crate::artwork_qt::cached_raw_path(url) {
|
|
return qbz_models::ArtworkRef::LocalFile(path.to_string_lossy().into_owned())
|
|
.to_mpris_url();
|
|
}
|
|
}
|
|
artwork.to_mpris_url()
|
|
}
|
|
|
|
/// Raise the desktop "now playing" toast for a real track change.
|
|
///
|
|
/// THE DEAD ROW THIS CLOSES: `system_notifications` was written by Settings and
|
|
/// read by nobody in the Qt port — the shared
|
|
/// `qbz_media_controls::show_track_notification` had exactly ONE caller in the
|
|
/// whole workspace and it was Slint's (`playback.rs:2276`). The switch shipped
|
|
/// on by default and did nothing.
|
|
///
|
|
/// Ordering here is load-bearing and matches the reference:
|
|
/// 1. **De-dupe first.** `push_now_playing` runs from `refresh_now_playing`,
|
|
/// which has 23 call sites — resume, seek, quality patch, every queue
|
|
/// republish. Only a changed track id may pass.
|
|
/// 2. **Then the gate.** Reading the pref costs a small JSON file read
|
|
/// (`settings_qt::pref_bool`), which is why it sits AFTER the atomic and
|
|
/// not before: once per track change, not once per republish. Deliberately
|
|
/// no cached atomic mirror of the pref — the reference needs one because
|
|
/// its notify path runs on a poll thread with no cheap access to prefs;
|
|
/// here a per-track read is both cheaper to reason about and immune to the
|
|
/// boot-order and cross-process staleness a mirrored gate invites (the
|
|
/// Slint build writes the same file).
|
|
/// 3. **Then the peer guard**, inside the spawn: never announce a track that
|
|
/// a remote QConnect renderer is playing (the Svelte `skipIfRemote`).
|
|
fn maybe_notify(track: &qbz_models::QueueTrack, title: &str, album: &str) {
|
|
use std::sync::atomic::Ordering;
|
|
if NOTIFY_LAST_TRACK.swap(track.id, Ordering::Relaxed) == track.id {
|
|
return;
|
|
}
|
|
if !crate::settings_qt::pref_bool("system_notifications", true) {
|
|
return;
|
|
}
|
|
let meta = qbz_media_controls::NotificationMeta {
|
|
title: title.to_string(),
|
|
artist: track.artist.clone(),
|
|
album: album.to_string(),
|
|
bit_depth: track.bit_depth,
|
|
sample_rate: track.sample_rate,
|
|
art_url: notify_art_for(track),
|
|
};
|
|
let offline = crate::offline_fwd::engine().is_offline();
|
|
crate::spawn(async move {
|
|
if let Some(svc) = crate::qconnect_qt::service() {
|
|
if svc.is_peer_active().await {
|
|
return;
|
|
}
|
|
}
|
|
qbz_media_controls::show_track_notification(meta, offline).await;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inbound — media keys, the Plasma/GNOME widget, playerctl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Map one inbound `MediaEvent` onto a transport verb. Runs on the mpris-server
|
|
/// (D-Bus) thread — not a tokio worker, not the Qt thread — so nothing here
|
|
/// blocks: the sync core reads are cheap and every async command goes through
|
|
/// `crate::transport_*`, which spawns onto the process-global runtime.
|
|
/// Time values are MICROSECONDS (`qbz-media-controls/src/types.rs:47`).
|
|
fn dispatch(ev: MediaEvent) {
|
|
match ev {
|
|
// The OS only sends Play when paused and Pause when playing (it reads
|
|
// PlaybackStatus), so routing all three through the toggle is correct
|
|
// and keeps the cast → QConnect → local ladder the tray verbs ride
|
|
// (`src/tray_qt.rs:487-500`).
|
|
MediaEvent::Play | MediaEvent::Pause | MediaEvent::Toggle => {
|
|
crate::tray_qt::dispatch_play_pause()
|
|
}
|
|
MediaEvent::Next => crate::tray_qt::dispatch_next(),
|
|
MediaEvent::Previous => crate::tray_qt::dispatch_previous(),
|
|
// The one command with no transport verb — direct on the core, like the
|
|
// daemon (`qbzd/src/mpris.rs:90-92`) and like the two existing Qt call
|
|
// sites (`src/cast_qt.rs:309`, `src/qconnect_engine_qt.rs:92`).
|
|
// `QbzCore::stop` is synchronous (`crates/qbz-core/src/core.rs:1060`).
|
|
MediaEvent::Stop => {
|
|
// Bound to a local first: `AppRuntime::core()` returns a BORROW
|
|
// (`crates/qbz-app/src/shell.rs:144`) and `app()` hands back an
|
|
// owned Arc, so the runtime must outlive the call — the same shape
|
|
// `tray_qt::dispatch_volume_delta` uses (`src/tray_qt.rs:525`).
|
|
let runtime = crate::app();
|
|
if let Err(e) = runtime.core().stop() {
|
|
log::warn!("[media-controls] stop failed: {e}");
|
|
}
|
|
}
|
|
// Present, not show_window: with the miniplayer open a forced main-
|
|
// window show reads as a duplicate instance (#559). `present()` was
|
|
// left in exactly this shape for this caller and needs no event-loop
|
|
// hop — it reads an AtomicBool mirror (`src/tray_qt.rs:442-462`).
|
|
MediaEvent::Raise => crate::tray_qt::present(),
|
|
MediaEvent::Quit => crate::tray_qt::quit(),
|
|
// Local model first, then the engine — `transport_set_volume` does both
|
|
// (`src/main.rs:1706-1711`) and its Qt hop is safe from any thread.
|
|
MediaEvent::SetVolume(v) => crate::transport_set_volume((v as f32).clamp(0.0, 1.0)),
|
|
MediaEvent::SetPosition(micros) => seek_to_micros(micros),
|
|
MediaEvent::SeekBy(delta_micros) => seek_by_micros(delta_micros),
|
|
}
|
|
}
|
|
|
|
/// Absolute seek (MPRIS `SetPosition`).
|
|
///
|
|
/// Micros → a 0..1 FRACTION, because that is this app's seek primitive
|
|
/// (`crate::transport_seek`, `src/main.rs:1701`), which alone carries the full
|
|
/// cast → QConnect → local ladder (`src/playback_qt.rs:1586-1621`). The Slint
|
|
/// glue converts the same way (`crates/qbz/src/media_controls.rs:72-95`); the
|
|
/// daemon converts to SECONDS instead only because `core.seek(secs)` is its
|
|
/// primitive.
|
|
fn seek_to_micros(micros: i64) {
|
|
let runtime = crate::app();
|
|
let state = runtime.core().get_playback_state();
|
|
if let Some(fraction) = fraction_for(micros, state.duration) {
|
|
crate::transport_seek(fraction);
|
|
}
|
|
}
|
|
|
|
/// Relative seek (MPRIS `Seek`), signed micros.
|
|
fn seek_by_micros(delta_micros: i64) {
|
|
// `position` / `duration` are SECONDS (`qbz-player`'s PlaybackState,
|
|
// `crates/qbz-player/src/player/mod.rs:5447-5453`).
|
|
let runtime = crate::app();
|
|
let state = runtime.core().get_playback_state();
|
|
let target = (state.position as i64) * 1_000_000 + delta_micros;
|
|
if let Some(fraction) = fraction_for(target, state.duration) {
|
|
crate::transport_seek(fraction);
|
|
}
|
|
}
|
|
|
|
/// A 0..1 seek fraction for an absolute target in micros, or `None` when the
|
|
/// duration is unknown (a zero-duration seek would divide by zero — the
|
|
/// reference's `dur == 0` early return).
|
|
fn fraction_for(target_micros: i64, duration_secs: u64) -> Option<f32> {
|
|
if duration_secs == 0 {
|
|
return None;
|
|
}
|
|
let target = target_micros.max(0);
|
|
Some((target as f64 / 1_000_000.0 / duration_secs as f64).clamp(0.0, 1.0) as f32)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fraction_for_needs_a_known_duration() {
|
|
assert_eq!(fraction_for(5_000_000, 0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn fraction_for_maps_micros_onto_the_track() {
|
|
assert_eq!(fraction_for(0, 200), Some(0.0));
|
|
assert_eq!(fraction_for(100_000_000, 200), Some(0.5));
|
|
assert_eq!(fraction_for(200_000_000, 200), Some(1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn fraction_for_clamps_both_ends() {
|
|
// A relative seek back past the start, and past the end.
|
|
assert_eq!(fraction_for(-30_000_000, 200), Some(0.0));
|
|
assert_eq!(fraction_for(999_000_000, 200), Some(1.0));
|
|
}
|
|
}
|