feat(slint): system media controls — MPRIS with GNOME app icon + SMTC/MediaRemote
New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way GNOME Shell resolves the app icon for its media widget (souvlaki, the old cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient). macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread with its own tokio runtime; state arrives over an async channel. Wired into qbz-slint via src/media_controls.rs: started on shell entry, metadata/playback pushed from the playback paths (mirroring the tray), and inbound control events (media keys, the desktop media widget, Now Playing) dispatched to the player through the existing QConnect-aware helpers. Both Linux and macOS compile clean. cc
This commit is contained in:
12 files changed
+893
-4
No files matched your search
Generated
+157
-3
@@ -779,6 +779,12 @@ dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1125,6 +1131,36 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"block",
|
||||
"cocoa-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics 0.22.3",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa-foundation"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"block",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -1261,6 +1297,19 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.22.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.23.2"
|
||||
@@ -1270,7 +1319,7 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1499,6 +1548,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-crossroads"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
@@ -2073,6 +2131,15 @@ dependencies = [
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2080,7 +2147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2094,6 +2161,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -4024,6 +4097,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -4118,6 +4200,19 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpris-server"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "058bc2227727af394f34aa51da3e36aeecf2c808f39315d35f754872660750ae"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-channel",
|
||||
"serde",
|
||||
"trait-variant",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.18.0"
|
||||
@@ -4372,6 +4467,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -5575,6 +5679,17 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-media-controls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"log",
|
||||
"mpris-server",
|
||||
"souvlaki",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-models"
|
||||
version = "0.1.0"
|
||||
@@ -5714,6 +5829,7 @@ dependencies = [
|
||||
"qbz-credentials",
|
||||
"qbz-integrations",
|
||||
"qbz-library",
|
||||
"qbz-media-controls",
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-player",
|
||||
@@ -7060,6 +7176,24 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "souvlaki"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"block",
|
||||
"cocoa",
|
||||
"core-graphics 0.22.3",
|
||||
"dbus",
|
||||
"dbus-crossroads",
|
||||
"dispatch",
|
||||
"objc",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -7924,6 +8058,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trait-variant"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
@@ -8639,6 +8784,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -9151,7 +9305,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"core-graphics 0.23.2",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
"js-sys",
|
||||
|
||||
@@ -23,6 +23,7 @@ members = [
|
||||
"qconnect-app",
|
||||
"qbz-slint",
|
||||
"qbz-credentials",
|
||||
"qbz-media-controls",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "qbz-media-controls"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on Linux with a DesktopEntry so GNOME shows the app icon; SMTC/MediaRemote (souvlaki) on Windows/macOS"
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
# Linux: mpris-server publishes the full MPRIS interface INCLUDING the
|
||||
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
|
||||
# which is the only way GNOME Shell resolves the app icon. souvlaki never sets
|
||||
# it. tokio feature so the server shares the app's tokio runtime.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
mpris-server = "0.8"
|
||||
async-channel = "2"
|
||||
tokio = { workspace = true }
|
||||
|
||||
# macOS (MediaRemote / MPNowPlayingInfoCenter) + Windows (SMTC) via souvlaki —
|
||||
# there is no DesktopEntry concept there (macOS keys the icon off the app
|
||||
# bundle), so souvlaki stays the right tool off-Linux.
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Frontend-agnostic system media controls (ADR-006).
|
||||
//!
|
||||
//! - **Linux:** `mpris-server` — publishes the full MPRIS interface INCLUDING
|
||||
//! `org.mpris.MediaPlayer2.DesktopEntry = "com.blitzfc.qbz"`, the only way
|
||||
//! GNOME Shell resolves the application icon for its media widget. (souvlaki,
|
||||
//! the cross-platform crate, never sets it, so GNOME shows no app icon.)
|
||||
//! - **macOS / Windows:** `souvlaki` — MediaRemote / SMTC, where there is no
|
||||
//! DesktopEntry concept (macOS keys the icon off the app bundle).
|
||||
//!
|
||||
//! One trait ([`MediaIntegration`]); one factory ([`spawn`]); no winit / Slint
|
||||
//! / Tauri types — headless/TUI can reuse it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
mod types;
|
||||
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
mod platform;
|
||||
|
||||
/// Spawn the OS media-controls integration. `on_event` receives inbound
|
||||
/// control events (media keys, the desktop media widget, macOS Now Playing).
|
||||
/// Returns `None` if the platform backend could not start — the app keeps
|
||||
/// working without media controls.
|
||||
///
|
||||
/// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is
|
||||
/// safe to call from any thread, but the run loop (Slint's winit loop) must be
|
||||
/// running for inbound events to arrive.
|
||||
pub fn spawn(
|
||||
on_event: impl Fn(MediaEvent) + Send + Sync + 'static,
|
||||
) -> Option<Box<dyn MediaIntegration>> {
|
||||
let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return linux::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
|
||||
}
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
return platform::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = cb;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Linux MPRIS backend via `mpris-server`.
|
||||
//!
|
||||
//! The whole reason this exists instead of souvlaki: `mpris-server`'s
|
||||
//! `RootInterface::desktop_entry()` lets us publish the
|
||||
//! `org.mpris.MediaPlayer2.DesktopEntry` property as `"com.blitzfc.qbz"`,
|
||||
//! which is the ONLY mechanism GNOME Shell uses to resolve the application
|
||||
//! icon for its media widget (DesktopEntry → `<name>.desktop` → `Icon=`).
|
||||
//! souvlaki never sets it, so GNOME shows no icon. (KDE is lenient and works
|
||||
//! either way.) `mpris:artUrl` is album art — separate and unaffected.
|
||||
//!
|
||||
//! The server runs on a dedicated thread with its own current-thread tokio
|
||||
//! runtime (the workspace forces zbus 4's `tokio` feature via qbz-audio, so a
|
||||
//! tokio context must be present); state updates arrive over an async channel.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
|
||||
use mpris_server::zbus::{self, fdo};
|
||||
use mpris_server::{
|
||||
LoopStatus, Metadata, PlaybackRate, PlaybackStatus as MprisStatus, PlayerInterface, Property,
|
||||
RootInterface, Server, Time, TrackId, Volume,
|
||||
};
|
||||
|
||||
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||
|
||||
const BUS_SUFFIX: &str = "com.blitzfc.qbz";
|
||||
const DESKTOP_ENTRY: &str = "com.blitzfc.qbz";
|
||||
const IDENTITY: &str = "QBZ";
|
||||
|
||||
/// Monotonic counter so each track gets a distinct `mpris:trackid` object path
|
||||
/// (helps clients detect track changes).
|
||||
static TRACK_SEQ: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
|
||||
|
||||
/// Shared, mutable now-playing state. Read by the MPRIS getter methods (on the
|
||||
/// zbus task) and written by the update loop (on the same runtime). Never held
|
||||
/// across an `.await`.
|
||||
struct State {
|
||||
metadata: Metadata,
|
||||
status: MprisStatus,
|
||||
volume: Volume,
|
||||
position: Time,
|
||||
}
|
||||
|
||||
/// Update commands sent from the app to the server thread.
|
||||
enum Update {
|
||||
Metadata(Metadata),
|
||||
Playback {
|
||||
status: MprisStatus,
|
||||
position: Option<Time>,
|
||||
},
|
||||
Volume(Volume),
|
||||
}
|
||||
|
||||
/// The cloneable handle returned to the app. Pushing state is a non-blocking
|
||||
/// channel send from any thread/context.
|
||||
pub struct LinuxHandle {
|
||||
tx: async_channel::Sender<Update>,
|
||||
}
|
||||
|
||||
impl MediaIntegration for LinuxHandle {
|
||||
fn set_metadata(&self, meta: &TrackMeta) {
|
||||
let _ = self.tx.try_send(Update::Metadata(build_metadata(meta)));
|
||||
}
|
||||
|
||||
fn set_playback(&self, status: PlaybackStatus, position: Option<std::time::Duration>) {
|
||||
let _ = self.tx.try_send(Update::Playback {
|
||||
status: map_status(status),
|
||||
position: position.map(|d| Time::from_micros(d.as_micros() as i64)),
|
||||
});
|
||||
}
|
||||
|
||||
fn set_volume(&self, vol: f64) {
|
||||
let _ = self.tx.try_send(Update::Volume(vol.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(s: PlaybackStatus) -> MprisStatus {
|
||||
match s {
|
||||
PlaybackStatus::Playing => MprisStatus::Playing,
|
||||
PlaybackStatus::Paused => MprisStatus::Paused,
|
||||
PlaybackStatus::Stopped => MprisStatus::Stopped,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_metadata(meta: &TrackMeta) -> Metadata {
|
||||
let seq = TRACK_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let trackid = TrackId::try_from(format!("/com/blitzfc/qbz/track/{seq}"))
|
||||
.unwrap_or(TrackId::NO_TRACK);
|
||||
|
||||
let mut b = Metadata::builder().trackid(trackid).title(meta.title.clone());
|
||||
if !meta.artist.is_empty() {
|
||||
b = b.artist([meta.artist.clone()]);
|
||||
}
|
||||
if !meta.album.is_empty() {
|
||||
b = b.album(meta.album.clone());
|
||||
}
|
||||
if let Some(d) = meta.duration {
|
||||
b = b.length(Time::from_micros(d.as_micros() as i64));
|
||||
}
|
||||
if let Some(url) = &meta.art_url {
|
||||
b = b.art_url(url.clone());
|
||||
}
|
||||
b.build()
|
||||
}
|
||||
|
||||
/// The MPRIS interface implementation. Getters read shared `State`; the action
|
||||
/// methods forward to the app via `on_event`.
|
||||
struct QbzMpris {
|
||||
on_event: EventCb,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl QbzMpris {
|
||||
fn emit(&self, ev: MediaEvent) {
|
||||
(self.on_event)(ev);
|
||||
}
|
||||
}
|
||||
|
||||
impl RootInterface for QbzMpris {
|
||||
async fn raise(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Raise);
|
||||
Ok(())
|
||||
}
|
||||
async fn quit(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Quit);
|
||||
Ok(())
|
||||
}
|
||||
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn set_fullscreen(&self, _fullscreen: bool) -> zbus::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn can_raise(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn has_track_list(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn identity(&self) -> fdo::Result<String> {
|
||||
Ok(IDENTITY.to_string())
|
||||
}
|
||||
/// The GNOME app-icon fix: GNOME resolves the icon from this property.
|
||||
async fn desktop_entry(&self) -> fdo::Result<String> {
|
||||
Ok(DESKTOP_ENTRY.to_string())
|
||||
}
|
||||
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerInterface for QbzMpris {
|
||||
async fn next(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Next);
|
||||
Ok(())
|
||||
}
|
||||
async fn previous(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Previous);
|
||||
Ok(())
|
||||
}
|
||||
async fn pause(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Pause);
|
||||
Ok(())
|
||||
}
|
||||
async fn play_pause(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Toggle);
|
||||
Ok(())
|
||||
}
|
||||
async fn stop(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Stop);
|
||||
Ok(())
|
||||
}
|
||||
async fn play(&self) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::Play);
|
||||
Ok(())
|
||||
}
|
||||
async fn seek(&self, offset: Time) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::SeekBy(offset.as_micros()));
|
||||
Ok(())
|
||||
}
|
||||
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
|
||||
self.emit(MediaEvent::SetPosition(position.as_micros()));
|
||||
Ok(())
|
||||
}
|
||||
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn playback_status(&self) -> fdo::Result<MprisStatus> {
|
||||
Ok(self.state.lock().unwrap().status)
|
||||
}
|
||||
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
|
||||
Ok(LoopStatus::None)
|
||||
}
|
||||
async fn set_loop_status(&self, _loop_status: LoopStatus) -> zbus::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
async fn set_rate(&self, _rate: PlaybackRate) -> zbus::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn shuffle(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn set_shuffle(&self, _shuffle: bool) -> zbus::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||
Ok(self.state.lock().unwrap().metadata.clone())
|
||||
}
|
||||
async fn volume(&self) -> fdo::Result<Volume> {
|
||||
Ok(self.state.lock().unwrap().volume)
|
||||
}
|
||||
async fn set_volume(&self, volume: Volume) -> zbus::Result<()> {
|
||||
self.emit(MediaEvent::SetVolume(volume.clamp(0.0, 1.0)));
|
||||
Ok(())
|
||||
}
|
||||
async fn position(&self) -> fdo::Result<Time> {
|
||||
Ok(self.state.lock().unwrap().position)
|
||||
}
|
||||
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
async fn can_go_next(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn can_go_previous(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn can_play(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn can_pause(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn can_seek(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn can_control(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply(server: &Server<QbzMpris>, state: &Arc<Mutex<State>>, update: Update) {
|
||||
match update {
|
||||
Update::Metadata(m) => {
|
||||
state.lock().unwrap().metadata = m.clone();
|
||||
let _ = server.properties_changed([Property::Metadata(m)]).await;
|
||||
}
|
||||
Update::Playback { status, position } => {
|
||||
{
|
||||
let mut st = state.lock().unwrap();
|
||||
st.status = status;
|
||||
if let Some(p) = position {
|
||||
st.position = p;
|
||||
}
|
||||
}
|
||||
let _ = server
|
||||
.properties_changed([Property::PlaybackStatus(status)])
|
||||
.await;
|
||||
}
|
||||
Update::Volume(v) => {
|
||||
state.lock().unwrap().volume = v;
|
||||
let _ = server.properties_changed([Property::Volume(v)]).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the MPRIS server on a dedicated thread. Returns `None` if the thread
|
||||
/// or runtime can't start (the bus registration happens async on the thread;
|
||||
/// failures there are logged, not surfaced — the app keeps running).
|
||||
pub fn spawn(on_event: EventCb) -> Option<LinuxHandle> {
|
||||
let (tx, rx) = async_channel::unbounded::<Update>();
|
||||
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("qbz-mpris".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::error!("[mpris] runtime build failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let state = Arc::new(Mutex::new(State {
|
||||
metadata: Metadata::new(),
|
||||
status: MprisStatus::Stopped,
|
||||
volume: 1.0,
|
||||
position: Time::ZERO,
|
||||
}));
|
||||
let imp = QbzMpris {
|
||||
on_event,
|
||||
state: state.clone(),
|
||||
};
|
||||
let server = match Server::new(BUS_SUFFIX, imp).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[mpris] failed to register org.mpris.MediaPlayer2.{BUS_SUFFIX}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"[mpris] registered org.mpris.MediaPlayer2.{BUS_SUFFIX} (DesktopEntry={DESKTOP_ENTRY})"
|
||||
);
|
||||
while let Ok(update) = rx.recv().await {
|
||||
apply(&server, &state, update).await;
|
||||
}
|
||||
log::debug!("[mpris] update channel closed, server shutting down");
|
||||
});
|
||||
});
|
||||
|
||||
match spawned {
|
||||
Ok(_) => Some(LinuxHandle { tx }),
|
||||
Err(e) => {
|
||||
log::error!("[mpris] failed to spawn server thread: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! macOS (MediaRemote / MPNowPlayingInfoCenter) + Windows (SMTC) backend via
|
||||
//! souvlaki. No MPRIS / DesktopEntry here — macOS keys the Now Playing icon off
|
||||
//! the app bundle; Windows SMTC off the package. On macOS `MediaControls` is a
|
||||
//! zero-sized handle over global objc singletons (Send + Sync) and its command
|
||||
//! callbacks fire on the app's run loop (Slint's winit loop), so it is driven
|
||||
//! from any thread without main-thread marshaling — same as the Tauri build.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
|
||||
SeekDirection,
|
||||
};
|
||||
|
||||
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||
|
||||
/// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros).
|
||||
const SEEK_STEP_MICROS: i64 = 5_000_000;
|
||||
|
||||
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
|
||||
|
||||
pub struct PlatformHandle {
|
||||
controls: Arc<Mutex<Option<MediaControls>>>,
|
||||
}
|
||||
|
||||
impl MediaIntegration for PlatformHandle {
|
||||
fn set_metadata(&self, meta: &TrackMeta) {
|
||||
if let Ok(mut guard) = self.controls.lock() {
|
||||
if let Some(c) = guard.as_mut() {
|
||||
let md = MediaMetadata {
|
||||
title: Some(meta.title.as_str()),
|
||||
artist: Some(meta.artist.as_str()),
|
||||
album: Some(meta.album.as_str()),
|
||||
duration: meta.duration,
|
||||
cover_url: meta.art_url.as_deref(),
|
||||
};
|
||||
let _ = c.set_metadata(md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_playback(&self, status: PlaybackStatus, position: Option<Duration>) {
|
||||
if let Ok(mut guard) = self.controls.lock() {
|
||||
if let Some(c) = guard.as_mut() {
|
||||
let progress = position.map(MediaPosition);
|
||||
let pb = match status {
|
||||
PlaybackStatus::Playing => MediaPlayback::Playing { progress },
|
||||
PlaybackStatus::Paused => MediaPlayback::Paused { progress },
|
||||
PlaybackStatus::Stopped => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = c.set_playback(pb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, _vol: f64) {
|
||||
// souvlaki exposes no outbound volume (SMTC/MediaRemote manage it);
|
||||
// inbound SetVolume still arrives as a MediaEvent. No-op here.
|
||||
}
|
||||
}
|
||||
|
||||
fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
|
||||
Some(match e {
|
||||
MediaControlEvent::Play => MediaEvent::Play,
|
||||
MediaControlEvent::Pause => MediaEvent::Pause,
|
||||
MediaControlEvent::Toggle => MediaEvent::Toggle,
|
||||
MediaControlEvent::Next => MediaEvent::Next,
|
||||
MediaControlEvent::Previous => MediaEvent::Previous,
|
||||
MediaControlEvent::Stop => MediaEvent::Stop,
|
||||
MediaControlEvent::Raise => MediaEvent::Raise,
|
||||
MediaControlEvent::Quit => MediaEvent::Quit,
|
||||
MediaControlEvent::Seek(SeekDirection::Forward) => MediaEvent::SeekBy(SEEK_STEP_MICROS),
|
||||
MediaControlEvent::Seek(SeekDirection::Backward) => MediaEvent::SeekBy(-SEEK_STEP_MICROS),
|
||||
MediaControlEvent::SeekBy(dir, dur) => {
|
||||
let micros = dur.as_micros() as i64;
|
||||
MediaEvent::SeekBy(match dir {
|
||||
SeekDirection::Forward => micros,
|
||||
SeekDirection::Backward => -micros,
|
||||
})
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => MediaEvent::SetPosition(pos.0.as_micros() as i64),
|
||||
MediaControlEvent::SetVolume(v) => MediaEvent::SetVolume(v),
|
||||
MediaControlEvent::OpenUri(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "com.blitzfc.qbz",
|
||||
display_name: "QBZ",
|
||||
// macOS: unused. Windows SMTC needs the window HWND; not shipped/tested,
|
||||
// so left None (init may fail there → None handle, no media controls).
|
||||
hwnd: None,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[media-controls] souvlaki init failed: {e:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
if let Some(ev) = map_event(event) {
|
||||
on_event(ev);
|
||||
}
|
||||
}) {
|
||||
log::warn!("[media-controls] souvlaki attach failed: {e:?}");
|
||||
return None;
|
||||
}
|
||||
|
||||
log::info!("[media-controls] souvlaki (SMTC/MediaRemote) initialized");
|
||||
Some(PlatformHandle {
|
||||
controls: Arc::new(Mutex::new(Some(controls))),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Frontend-agnostic media-control types — no winit / Slint / Tauri here.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Now-playing metadata pushed to the OS media controls.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TrackMeta {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: Option<Duration>,
|
||||
/// `mpris:artUrl` — the ALBUM ART for the track (http/https/file URL). This
|
||||
/// is NOT the application icon (that is resolved by GNOME from the MPRIS
|
||||
/// `DesktopEntry` property → the installed .desktop → `Icon=`).
|
||||
pub art_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlaybackStatus {
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Inbound events from the OS media controls (media keys, GNOME/KDE widget,
|
||||
/// macOS Now Playing, Windows SMTC). Delivered to the callback passed to
|
||||
/// [`crate::spawn`]. All time values are MICROSECONDS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MediaEvent {
|
||||
Play,
|
||||
Pause,
|
||||
Toggle,
|
||||
Next,
|
||||
Previous,
|
||||
Stop,
|
||||
/// Relative seek by the given micros (signed; MPRIS `Seek`).
|
||||
SeekBy(i64),
|
||||
/// Absolute seek to the given micros (MPRIS `SetPosition`).
|
||||
SetPosition(i64),
|
||||
/// Set volume 0.0..=1.0.
|
||||
SetVolume(f64),
|
||||
Raise,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// A live handle to the OS media-controls integration. Cloneable callers hold
|
||||
/// it for the app lifetime and push state through it; dropping the last one
|
||||
/// tears the integration down.
|
||||
pub trait MediaIntegration: Send + Sync {
|
||||
fn set_metadata(&self, meta: &TrackMeta);
|
||||
fn set_playback(&self, status: PlaybackStatus, position: Option<Duration>);
|
||||
fn set_volume(&self, vol: f64);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ serde_json = { workspace = true }
|
||||
dirs = "5"
|
||||
chrono = { workspace = true, features = ["unstable-locales"] }
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-media-controls = { path = "../qbz-media-controls" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-player = { path = "../qbz-player" }
|
||||
|
||||
@@ -49,6 +49,7 @@ mod folders;
|
||||
mod library_db;
|
||||
mod local_library;
|
||||
mod local_library_settings;
|
||||
mod media_controls;
|
||||
mod locallibrary_prefs;
|
||||
mod tag_editor;
|
||||
mod offline;
|
||||
@@ -113,6 +114,15 @@ async fn enter_shell(
|
||||
tray.enable_tray,
|
||||
);
|
||||
|
||||
// System media controls — MPRIS on Linux (publishes DesktopEntry so GNOME
|
||||
// shows the app icon), SMTC/MediaRemote on macOS/Windows. Independent of
|
||||
// the tray; pushes metadata/state from the playback paths.
|
||||
media_controls::init(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let state = w.global::<SessionState>();
|
||||
state.set_user_name(session.display_name.into());
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! System media controls wiring (MPRIS on Linux — with the app icon via the
|
||||
//! `DesktopEntry` property — SMTC/MediaRemote on macOS/Windows).
|
||||
//!
|
||||
//! The backend lives in the frontend-agnostic `qbz-media-controls` crate; this
|
||||
//! module owns the process-global handle and bridges inbound control events
|
||||
//! (media keys, the GNOME/KDE media widget, macOS Now Playing) to the player.
|
||||
//! Playback metadata/state is pushed from `playback.rs` (mirroring the tray).
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use qbz_media_controls::{MediaEvent, MediaIntegration};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::AppWindow;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
|
||||
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||
|
||||
static CONTROLS: OnceLock<Box<dyn MediaIntegration>> = OnceLock::new();
|
||||
|
||||
/// The live integration, if it started. Playback pushes metadata/state through
|
||||
/// it (`set_metadata`/`set_playback`).
|
||||
pub 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.
|
||||
pub fn init(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
if CONTROLS.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let on_event = move |ev: MediaEvent| {
|
||||
dispatch(ev, runtime.clone(), weak.clone(), handle.clone());
|
||||
};
|
||||
match qbz_media_controls::spawn(on_event) {
|
||||
Some(c) => {
|
||||
let _ = CONTROLS.set(c);
|
||||
log::info!("[media-controls] integration started");
|
||||
}
|
||||
None => log::info!("[media-controls] no integration on this platform"),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(ev: MediaEvent, rt: Runtime, weak: slint::Weak<AppWindow>, h: tokio::runtime::Handle) {
|
||||
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 QConnect-aware path (shared with the tray).
|
||||
MediaEvent::Play | MediaEvent::Pause | MediaEvent::Toggle => {
|
||||
crate::tray::dispatch_play_pause(rt, weak, h)
|
||||
}
|
||||
MediaEvent::Next => crate::tray::dispatch_next(rt, weak, h),
|
||||
MediaEvent::Previous => crate::tray::dispatch_previous(rt, weak, h),
|
||||
MediaEvent::Stop => {
|
||||
h.spawn(async move {
|
||||
if let Err(e) = rt.core().stop() {
|
||||
log::warn!("[media-controls] stop failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
MediaEvent::Raise => crate::tray::show_window(&weak),
|
||||
MediaEvent::Quit => crate::tray::quit(),
|
||||
MediaEvent::SetVolume(v) => crate::playback::set_volume(rt, weak, h, v as f32),
|
||||
MediaEvent::SetPosition(micros) => seek_to_micros(rt, h, micros),
|
||||
MediaEvent::SeekBy(delta_micros) => seek_by_micros(rt, h, delta_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_to_micros(rt: Runtime, h: tokio::runtime::Handle, micros: i64) {
|
||||
let spawn_h = h.clone();
|
||||
h.spawn(async move {
|
||||
let dur = rt.core().get_playback_state().duration; // seconds
|
||||
if dur == 0 {
|
||||
return;
|
||||
}
|
||||
let fraction = (micros as f64 / 1_000_000.0 / dur as f64).clamp(0.0, 1.0) as f32;
|
||||
do_seek(rt, spawn_h, fraction).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn seek_by_micros(rt: Runtime, h: tokio::runtime::Handle, delta_micros: i64) {
|
||||
let spawn_h = h.clone();
|
||||
h.spawn(async move {
|
||||
let st = rt.core().get_playback_state();
|
||||
if st.duration == 0 {
|
||||
return;
|
||||
}
|
||||
let target = ((st.position as i64) * 1_000_000 + delta_micros).max(0);
|
||||
let fraction = (target as f64 / 1_000_000.0 / st.duration as f64).clamp(0.0, 1.0) as f32;
|
||||
do_seek(rt, spawn_h, fraction).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// QConnect-aware seek (mirrors the now-playing bar's `on_seek`).
|
||||
async fn do_seek(rt: Runtime, h: tokio::runtime::Handle, fraction: f32) {
|
||||
if let Some(svc) = crate::qconnect_service::service() {
|
||||
let position_ms =
|
||||
(fraction as f64 * rt.core().get_playback_state().duration as f64 * 1000.0).round() as i64;
|
||||
match svc.set_position_if_remote(position_ms).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[media-controls] seek handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::playback::seek(rt, h, fraction);
|
||||
}
|
||||
@@ -518,10 +518,13 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
|
||||
pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
let Some(track) = state.current_track else {
|
||||
// No current track → clear the tray tooltip (Linux).
|
||||
// No current track → clear the tray tooltip (Linux) + stop media controls.
|
||||
if let Some(t) = crate::tray::handle() {
|
||||
t.clear_track();
|
||||
}
|
||||
if let Some(mc) = crate::media_controls::handle() {
|
||||
mc.set_playback(qbz_media_controls::PlaybackStatus::Stopped, None);
|
||||
}
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<NowPlayingState>().set_has_track(false);
|
||||
});
|
||||
@@ -558,6 +561,23 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
|
||||
t.set_track(title.clone(), artist.clone(), album.clone());
|
||||
}
|
||||
|
||||
// Push to the OS media controls (MPRIS / SMTC / MediaRemote). The app icon
|
||||
// GNOME shows comes from the MPRIS DesktopEntry, not from album art; art_url
|
||||
// (album art) is a later refinement.
|
||||
if let Some(mc) = crate::media_controls::handle() {
|
||||
mc.set_metadata(&qbz_media_controls::TrackMeta {
|
||||
title: title.clone(),
|
||||
artist: artist.clone(),
|
||||
album: album.clone(),
|
||||
duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)),
|
||||
art_url: None,
|
||||
});
|
||||
mc.set_playback(
|
||||
qbz_media_controls::PlaybackStatus::Playing,
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let np = w.global::<NowPlayingState>();
|
||||
np.set_has_track(true);
|
||||
@@ -2409,6 +2429,14 @@ pub fn start_poll_loop(
|
||||
if let Some(t) = crate::tray::handle() {
|
||||
t.set_playing(is_playing);
|
||||
}
|
||||
if let Some(mc) = crate::media_controls::handle() {
|
||||
let status = if is_playing {
|
||||
qbz_media_controls::PlaybackStatus::Playing
|
||||
} else {
|
||||
qbz_media_controls::PlaybackStatus::Paused
|
||||
};
|
||||
mc.set_playback(status, Some(std::time::Duration::from_secs(position as u64)));
|
||||
}
|
||||
}
|
||||
was_playing = is_playing;
|
||||
|
||||
|
||||
Reference in new issue
Block a user