feat(qt): MPRIS — the desktop can finally see the player

Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its
default shipped. The owner reversed it after smoking the tray: QBZ did not
appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without
this the desktop cannot see the player at all.

src/media_controls_qt.rs (new) — a plain module over the SHARED
qbz-media-controls crate, following the qbzd precedent rather than the Slint
one because qbz-qt is also not a Slint app. Outbound: metadata on the track
edge, playback state on the play/pause edge, both carrying position.
Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus
Raise — which calls tray_qt::present() unchanged, the mini-aware verb the
tray contract shaped for exactly this caller.

src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without
its file_url step, so the art URI is built for MPRIS rather than for QML.

src/playback_qt.rs — four sibling pushes beside the tray's: both arms of
refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two
is the silent half-port, so the grep is the instruction.

Verified on the session bus, not by reading: qbz-qt owns
org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry
com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl,
CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit
all true and PlaybackStatus tracking. Metadata is empty until something
plays, and that half is NOT verified: it needs audio.

cc
This commit is contained in:
vicrodh committed 2026-08-04 08:42:28 -06:00
1 parent a64c20b09f
commit 5dd450fb9f
6 files changed
+440

No files matched your search

+16
View File
@@ -70,6 +70,22 @@ qbz-theme = { path = "../qbz-theme" }
qbz-integrations = { path = "../qbz-integrations" }
# Last.fm "similar albums" row on the album page (external_reco_qt.rs).
qbz-external-reco = { path = "../qbz-external-reco" }
# System media controls — MPRIS on Linux (what makes the KDE Plasma / GNOME
# now-playing widget and the hardware media keys see QBZ), SMTC/MediaRemote
# elsewhere. Driven by src/media_controls_qt.rs.
#
# Plain, NOT target-gated, and with no features — the same line both other
# consumers use (crates/qbz/Cargo.toml:80, crates/qbzd/Cargo.toml:22). The
# crate carries its own cfg arms internally (qbz-media-controls/src/lib.rs:36-53)
# and is already a workspace member (crates/Cargo.toml:32), so this adds no
# lock entry.
#
# Its Linux backend rides mpris-server -> zbus 4 with its own current-thread
# tokio runtime, which is a DIFFERENT major from the zbus 5 that ksni / ashpd /
# rfd use with async-io. That is the only reason the two coexist in one
# process; the rule in the ksni comment below applies here too — never enable a
# `tokio` feature on any D-Bus dependency.
qbz-media-controls = { path = "../qbz-media-controls" }
# Qobuz Connect (2026-08-01 Qt-port contract): the shared session/renderer
# orchestration and the WS transport, consumed VERBATIM — backend untouchable,
# same edges as the Slint crate (crates/qbz/Cargo.toml:87-88). Driven by
+20
View File
@@ -279,6 +279,26 @@ pub fn cached_path(url: &str) -> String {
}
}
/// The cached file for a FETCHABLE cover (http/https, and Plex thumbs once
/// tokenized) as a RAW filesystem path — `None` when it is not on disk.
///
/// The remote arm of [`cached_path`] without its `file_url` step. It exists
/// because a consumer that needs a DIFFERENT URI form cannot un-escape what
/// `file_url` produced: `media_controls_qt` builds `mpris:artUrl` through
/// `qbz_models::ArtworkRef::to_mpris_url` (full percent-encoding via
/// `url::Url::from_file_path`), and `file_url` escapes only `%`, `#` and `?`.
/// Same synchronous, no-download contract as [`cached_path`]: memo first, then
/// one SQLite hit, and a zero-byte entry counts as a miss.
///
/// Local classes deliberately return `None` — they were never in the disk
/// cache to begin with, and their caller already has the path.
pub fn cached_raw_path(url: &str) -> Option<PathBuf> {
match classify(url) {
ArtUrl::Http(fetch) | ArtUrl::Plex(fetch) => disk_path(url, &fetch),
ArtUrl::Empty | ArtUrl::PlexUnconfigured | ArtUrl::LocalFile(_) => None,
}
}
/// Fill `art_path` from the disk cache for every card that already has one.
/// Returns the urls still missing (deduped, non-empty).
pub fn attach_cached(sections: &mut [HomeSection]) -> Vec<String> {
+13
View File
@@ -241,6 +241,13 @@ mod mini_qt;
mod tray_qt;
#[cfg(target_os = "linux")]
mod tray_linux;
// MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04
// after smoking the tray: "no aparece por ejemplo en el widget de now playing
// de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see
// QBZ at all. Portable module over the shared `qbz-media-controls` crate,
// exactly like the Slint and qbzd consumers; the crate does its own cfg
// gating, so this mod line is not platform-gated.
mod media_controls_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -452,6 +459,12 @@ fn on_session_entered() {
tray.enable_tray && !tray_qt::in_gamescope(),
);
}
// MPRIS, beside the tray exactly as the reference puts media_controls
// beside tray::init (`crates/qbz/src/main.rs:257` then `:268`). No enable
// flag and no gamescope predicate — the reference has neither, and a
// desktop that cannot see the player is the complaint this closes.
// Idempotent, so the multi-entry nature of this function is safe.
media_controls_qt::init();
// Phase 7: the sidebar playlist tree.
load_sidebar_once();
// Refresh the favourite-id cache from the network. The disk seed already
+354
View File
@@ -0,0 +1,354 @@
//! 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);
/// 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) {
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),
});
}
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;
}
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)));
}
/// 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.
pub(crate) fn art_url_for(track: &qbz_models::QueueTrack) -> Option<String> {
let plex = crate::local_plex::settings();
let artwork = track.artwork_ref_with_plex(&plex.base_url, &plex.token, None);
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
}
// ---------------------------------------------------------------------------
// 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));
}
}
+36
View File
@@ -1749,6 +1749,13 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
if let Some(t) = crate::tray_qt::handle() {
t.clear_track();
}
// OS media controls: the no-track arm, a SIBLING of the tray clear
// above and in the reference's order — dedupe reset, tray, MPRIS
// (crates/qbz/src/playback.rs:1913-1922). Tells the Plasma/GNOME widget
// we stopped and resets the metadata dedupe, so replaying the SAME
// track after a stop pushes its metadata again. Silent no-op when the
// integration never started.
crate::media_controls_qt::clear_now_playing();
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
@@ -1793,6 +1800,22 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
Some(v) => format!("{} ({v})", track.album),
None => track.album.clone(),
};
// OS media controls: the has-track arm, the second SIBLING of the tray push
// above (crates/qbz/src/playback.rs:2208-2223). This is what the KDE Plasma
// now-playing widget, GNOME's media section and playerctl read.
//
// It sits HERE, after `album_display` is built and BEFORE `title` is moved
// into the NowPlayingModel meta below, because MPRIS wants the pair the
// reference sends: the VERSIONED title and the release-variant album — not
// the clean `track.album` the tray tooltip takes (`:2213` vs `:2164`).
//
// Metadata is de-duped on (track id, resolved art) INSIDE the callee: this
// publisher re-runs on resume/seek/republish from 23 call sites, so an
// unconditional push would re-emit PropertiesChanged for identical values.
// The playback push is unconditional and optimistic (Playing at 0) exactly
// as in the reference — the poll loop's play/pause edge corrects it if the
// stream never opens.
crate::media_controls_qt::push_now_playing(&track, &title, &album_display);
// "Playing from" origin for the song-card layers glyph, re-derived from the
// CURRENT track's own stamp on EVERY change (never a stale global) —
// playback.rs:1953-1965. A track with no container origin falls back to its
@@ -2434,6 +2457,15 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
// OS media controls, inserted between the tray and
// Discord exactly as in the reference, whose single
// block is tray (playback.rs:5720-5722), MPRIS
// (`:5723-5730`), Discord (`:5734`). Carries the live
// position, so this transition is also the only thing
// that moves the widget's Position — nothing per tick.
// SIBLING OF THE BLOCK AT THE LOOP TAIL: a push in only
// one of the two is the silent half-port.
crate::media_controls_qt::push_playback_state(is_playing, position);
crate::integrations_qt::discord_push(&runtime);
}
was_playing = is_playing;
@@ -2466,6 +2498,10 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
// OS media controls — the SECOND of the two blocks, see the
// sibling in the stop-after arm above. Same order as the
// reference: tray, MPRIS, Discord.
crate::media_controls_qt::push_playback_state(is_playing, position);
crate::integrations_qt::discord_push(&runtime);
}
was_playing = is_playing;