feat(slint): Linux system tray (ksni) + Wayland-safe close-to-tray

Port the Tauri tray to Slint on Linux via ksni: themed icon (auto/mono/
color), left-click window toggle, middle-click play/pause, scroll volume,
the 5-item menu, and a live tooltip reflecting the now-playing track. Tray
actions call the playback controller directly (QConnect-aware) instead of
emitting webview events. Settings reuse the shared qbz-app tray store
(per-user) wired through AppearanceState.

Window hide/show uses Slint window.hide()/show() (winit set_visible is a
no-op on Wayland) and the loop runs via run_event_loop_until_quit so a
tray-hide keeps the app alive. close-to-tray is honored on both the custom
titlebar close and the WM close (on_close_requested). minimize-to-tray is
hidden until custom window chrome exists.

cc
This commit is contained in:
vicrodh committed 2026-06-06 01:44:38 -06:00
1 parent 5b3ba963b0
commit 8a78c2250f
22 files changed
+1082 -24

No files matched your search

+23 -1
View File
@@ -638,7 +638,7 @@ dependencies = [
"log", "log",
"num-rational", "num-rational",
"num-traits", "num-traits",
"pastey", "pastey 0.1.1",
"rayon", "rayon",
"thiserror 2.0.18", "thiserror 2.0.18",
"v_frame", "v_frame",
@@ -3432,6 +3432,20 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "ksni"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc"
dependencies = [
"futures-util",
"once_cell",
"pastey 0.2.3",
"serde",
"tokio",
"zbus 5.15.0",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.13.1" version = "0.13.1"
@@ -4615,6 +4629,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -5283,7 +5303,9 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"env_logger", "env_logger",
"futures-util", "futures-util",
"i-slint-backend-winit",
"image", "image",
"ksni",
"log", "log",
"open", "open",
"qbz-app", "qbz-app",
+14
View File
@@ -22,6 +22,12 @@ slint = { version = "1.16", default-features = false, features = [
"accessibility", "accessibility",
"compat-1-2", "compat-1-2",
] } ] }
# Direct access to the winit backend for the system tray: WinitWindowAccessor
# (hide/show the OS window without quitting the event loop) and, later, the
# CustomApplicationHandler (tray event pump + close-to-tray intercept). MUST be
# pinned to the exact version Slint resolves (1.16.1) — i-slint-backend-winit
# does not follow semver, and a mismatch makes with_winit_window() return None.
i-slint-backend-winit = "=1.16.1"
log = { workspace = true } log = { workspace = true }
env_logger = "0.11" env_logger = "0.11"
tokio = { workspace = true } tokio = { workspace = true }
@@ -53,5 +59,13 @@ rusqlite = { version = "0.31", features = ["bundled"] }
qbz-cache = { path = "../qbz-cache" } qbz-cache = { path = "../qbz-cache" }
qbz-credentials = { path = "../qbz-credentials" } qbz-credentials = { path = "../qbz-credentials" }
# Linux system tray via StatusNotifierItem. Same crate + rationale as the
# Tauri build (src-tauri/Cargo.toml): libayatana-appindicator never dispatches
# primary-activate (left-click), so we use ksni's pure-Rust SNI which exposes
# Activate / SecondaryActivate / Scroll. macOS/Windows use `tray-icon` instead
# (added with the CustomApplicationHandler slice).
[target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3", features = ["blocking"] }
[build-dependencies] [build-dependencies]
slint-build = "1.16" slint-build = "1.16"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+96 -2
View File
@@ -64,6 +64,8 @@ mod settings;
mod share; mod share;
mod sidebar; mod sidebar;
mod toast; mod toast;
mod tray;
mod tray_settings;
mod ui_prefs; mod ui_prefs;
use std::sync::Arc; use std::sync::Arc;
@@ -94,10 +96,34 @@ async fn enter_shell(
// settings live in the per-user library.db). // settings live in the per-user library.db).
library_db::set_user(session.user_id); library_db::set_user(session.user_id);
// Bind tray settings to this user (per-user tray_settings.db, shared with
// the Tauri build) and snapshot them to seed the settings UI.
tray_settings::init_for_user(session.user_id);
let tray = tray_settings::get();
// Create the system tray from this user's persisted settings (gated by
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
// service runs on its own thread; macOS/Windows are no-ops until the
// tray-icon slice lands.
tray::init(
runtime.clone(),
weak.clone(),
tokio::runtime::Handle::current(),
tray.tray_icon_theme.clone(),
tray.enable_tray,
);
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let state = w.global::<SessionState>(); let state = w.global::<SessionState>();
state.set_user_name(session.display_name.into()); state.set_user_name(session.display_name.into());
state.set_subscription(session.subscription.into()); state.set_subscription(session.subscription.into());
// Seed the tray settings UI from the persisted per-user store.
let appearance = w.global::<AppearanceState>();
appearance.set_tray_enable(tray.enable_tray);
appearance.set_tray_minimize_to_tray(tray.minimize_to_tray);
appearance.set_tray_close_to_tray(tray.close_to_tray);
appearance.set_tray_mac_hide_dock(tray.mac_hide_dock);
appearance.set_tray_icon_theme_index(tray_settings::icon_theme_index(&tray.tray_icon_theme));
w.global::<HomeState>().set_loading(true); w.global::<HomeState>().set_loading(true);
w.set_screen(AppScreen::Shell); w.set_screen(AppScreen::Shell);
}); });
@@ -1751,6 +1777,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.global::<ShellState>() .global::<ShellState>()
.set_npb_mode(crate::ui_prefs::npb_mode_index(&crate::ui_prefs::load().npb_mode)); .set_npb_mode(crate::ui_prefs::npb_mode_index(&crate::ui_prefs::load().npb_mode));
// Tell the tray settings UI which platform it's on so it can show the
// macOS-only controls ("Menu Bar" header, hide-Dock toggle) and hide the
// Linux/Windows-only minimize-to-tray row.
window
.global::<AppearanceState>()
.set_is_macos(cfg!(target_os = "macos"));
let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak()))); let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak())));
// MusicBrainz cache — opens a SQLite store at // MusicBrainz cache — opens a SQLite store at
@@ -2234,6 +2267,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
// Appearance settings persistence. The toggles/selects set their
// AppearanceState property locally, then fire these generic callbacks so
// the choice survives restart. Tray keys persist to the shared per-user
// tray_settings store; unknown keys are logged (other appearance settings
// are wired as they land).
{
let appearance = window.global::<AppearanceState>();
appearance.on_appearance_bool(|key, value| match key.as_str() {
"tray-enable" => tray_settings::set_enable_tray(value),
"tray-minimize-to-tray" => tray_settings::set_minimize_to_tray(value),
"tray-close-to-tray" => tray_settings::set_close_to_tray(value),
"tray-mac-hide-dock" => tray_settings::set_mac_hide_dock(value),
other => log::debug!("[qbz-slint] unhandled appearance-bool '{other}'"),
});
appearance.on_appearance_select(|key, index| match key.as_str() {
"tray-icon-theme" => {
tray_settings::set_icon_theme_index(index);
// Re-theme the running tray icon live (no restart).
if let Some(t) = tray::handle() {
t.set_icon_theme(tray_settings::theme_for_index(index));
}
}
other => log::debug!("[qbz-slint] unhandled appearance-select '{other}'"),
});
}
// Context-menu / overlay media actions — route play / queue actions // Context-menu / overlay media actions — route play / queue actions
// into the playback controller; favorite / download stay logged. // into the playback controller; favorite / download stay logged.
{ {
@@ -6557,9 +6616,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
window.on_close_app(|| { window.on_close_app({
let weak = window.as_weak();
move || {
// Custom titlebar close button. Hide to tray when close-to-tray is
// enabled and the tray is live; otherwise quit.
if tray_settings::get().close_to_tray && tray::handle().is_some() {
log::info!("[qbz-slint] close-to-tray (titlebar): hiding to tray");
tray::hide_window(&weak);
} else {
log::info!("[qbz-slint] closing"); log::info!("[qbz-slint] closing");
let _ = slint::quit_event_loop(); let _ = slint::quit_event_loop();
}
}
});
// Intercept the window-manager close (native titlebar X / compositor
// close). Mirrors the custom titlebar: hide to tray when close-to-tray is
// on + the tray is live, otherwise quit. Required because the loop runs
// with quit_on_last_window_closed = false (so a tray-hide keeps the app
// alive) — without this, the native close would leave a headless process.
window.window().on_close_requested(move || {
if tray_settings::get().close_to_tray && tray::handle().is_some() {
// Slint performs the hide (destroys the surface) for HideWindow;
// we only sync the shown flag so the next tray toggle shows it.
log::info!("[qbz-slint] close-to-tray (WM close): hiding to tray");
tray::set_window_shown(false);
slint::CloseRequestResponse::HideWindow
} else {
log::info!("[qbz-slint] WM close requested: quitting");
let _ = slint::quit_event_loop();
slint::CloseRequestResponse::HideWindow
}
}); });
window.on_open_tos(|| { window.on_open_tos(|| {
@@ -6570,6 +6658,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
log::info!("[qbz-slint] window ready"); log::info!("[qbz-slint] window ready");
window.run()?; // NOT `window.run()`: that quits the event loop when the last window
// closes, which would kill the app the moment the window hides to tray.
// `run_event_loop_until_quit()` keeps the loop alive until an explicit
// `quit_event_loop()` (custom titlebar / WM close when not close-to-tray /
// tray Quit), so hide-to-tray works.
window.show()?;
slint::run_event_loop_until_quit()?;
Ok(()) Ok(())
} }
+17
View File
@@ -518,6 +518,10 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) { pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let state = runtime.core().get_queue_state().await; let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else { let Some(track) = state.current_track else {
// No current track → clear the tray tooltip (Linux).
if let Some(t) = crate::tray::handle() {
t.clear_track();
}
let _ = weak.upgrade_in_event_loop(|w| { let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NowPlayingState>().set_has_track(false); w.global::<NowPlayingState>().set_has_track(false);
}); });
@@ -549,6 +553,11 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
crate::quality::detail(track.bit_depth, track.sample_rate) crate::quality::detail(track.bit_depth, track.sample_rate)
}; };
// Mirror the now-playing metadata into the system tray tooltip (Linux).
if let Some(t) = crate::tray::handle() {
t.set_track(title.clone(), artist.clone(), album.clone());
}
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>(); let np = w.global::<NowPlayingState>();
np.set_has_track(true); np.set_has_track(true);
@@ -2393,6 +2402,14 @@ pub fn start_poll_loop(
last_track_id = track_id; last_track_id = track_id;
seen_position = position; seen_position = position;
} }
// Reflect play/pause into the tray tooltip on transition only
// (Linux), so the "Middle-click to pause/play" hint stays correct
// without spamming the updater channel every tick.
if is_playing != was_playing {
if let Some(t) = crate::tray::handle() {
t.set_playing(is_playing);
}
}
was_playing = is_playing; was_playing = is_playing;
// Auto-advance on track end. // Auto-advance on track end.
+482
View File
@@ -0,0 +1,482 @@
//! Linux system tray via `ksni` (StatusNotifierItem).
//!
//! Faithful port of `src-tauri/src/tray_linux_ksni.rs`. The icon decoding,
//! theme resolution, tooltip composition and the updater-thread pattern are
//! byte-for-byte equivalent. The ONLY behavioural difference: tray actions
//! drive the playback controller + winit window directly (no webview to emit
//! events to) — see the `super::dispatch_*` / `super::*_window` helpers.
use std::sync::{
mpsc::{self, Sender},
Arc, Mutex,
};
use image::GenericImageView;
use ksni::{blocking::TrayMethods, menu::StandardItem, Icon, MenuItem, Orientation, ToolTip, Tray};
use super::Runtime;
use crate::AppWindow;
// Multiple pixmap sizes per StatusNotifierItem spec — panels pick the best
// match for their bar height (22 = base, 32/44/64 = HiDPI).
//
// Legacy filename note (shared with the Tauri assets): `tray-light-*` holds
// the BLACK glyph (for LIGHT panels) and `tray-dark-*` holds the WHITE glyph.
// The constants use glyph-colour names so the mapping is explicit.
const TRAY_ICON_MONO_BLACK_22: &[u8] = include_bytes!("../../icons/tray-light-22.png");
const TRAY_ICON_MONO_BLACK_32: &[u8] = include_bytes!("../../icons/tray-light-32.png");
const TRAY_ICON_MONO_BLACK_44: &[u8] = include_bytes!("../../icons/tray-light-44.png");
const TRAY_ICON_MONO_BLACK_64: &[u8] = include_bytes!("../../icons/tray-light-64.png");
const TRAY_ICON_MONO_WHITE_22: &[u8] = include_bytes!("../../icons/tray-dark-22.png");
const TRAY_ICON_MONO_WHITE_32: &[u8] = include_bytes!("../../icons/tray-dark-32.png");
const TRAY_ICON_MONO_WHITE_44: &[u8] = include_bytes!("../../icons/tray-dark-44.png");
const TRAY_ICON_MONO_WHITE_64: &[u8] = include_bytes!("../../icons/tray-dark-64.png");
const TRAY_ICON_COLOR_22: &[u8] = include_bytes!("../../icons/tray-color-22.png");
const TRAY_ICON_COLOR_32: &[u8] = include_bytes!("../../icons/tray-color-32.png");
const TRAY_ICON_COLOR_44: &[u8] = include_bytes!("../../icons/tray-color-44.png");
const TRAY_ICON_COLOR_64: &[u8] = include_bytes!("../../icons/tray-color-64.png");
fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Detect whether the system prefers a dark color scheme. Tries (in order):
/// GNOME `color-scheme`, GTK `prefer-dark-theme`, KDE `ColorScheme`. Defaults
/// to `false` (light) when nothing matches.
fn prefer_dark_tray() -> bool {
if let Ok(out) = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
{
if String::from_utf8_lossy(&out.stdout).contains("prefer-dark") {
return true;
}
}
if let Some(config) = dirs::config_dir() {
for variant in ["gtk-4.0", "gtk-3.0"] {
let path = config.join(variant).join("settings.ini");
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
if let Some(rest) =
line.trim().strip_prefix("gtk-application-prefer-dark-theme")
{
let v = rest.trim_start_matches(['=', ' ']);
return v.starts_with('1') || v.starts_with("true");
}
}
}
}
if let Ok(content) = std::fs::read_to_string(config.join("kdeglobals")) {
for line in content.lines() {
if let Some(rest) = line.trim().strip_prefix("ColorScheme") {
return rest
.trim_start_matches(['=', ' '])
.to_lowercase()
.contains("dark");
}
}
}
}
false
}
/// Convert an embedded RGBA PNG to the ARGB32 big-endian layout ksni expects.
fn decode_pixmap(bytes: &[u8]) -> Result<Icon, String> {
let img = image::load_from_memory(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
let (width, height) = img.dimensions();
let mut data = img.into_rgba8().into_vec();
// ksni spec: ARGB32 with A, R, G, B order per pixel. `image` gives us
// RGBA; rotate_right(1) moves the alpha byte from the last slot to the
// first.
for pixel in data.chunks_exact_mut(4) {
pixel.rotate_right(1);
}
Ok(Icon {
width: width as i32,
height: height as i32,
data,
})
}
#[derive(Clone, Copy, Debug)]
enum IconVariant {
/// Black glyph — for light panels.
MonoBlack,
/// White glyph — for dark panels (Plasma dark, GNOME top bar).
MonoWhite,
/// Full color vinyl.
Color,
}
/// Resolve which icon variant to load. `theme_override`:
/// - "auto" (or unrecognised) — system color-scheme detection
/// - "mono-light" — white (light-coloured) glyph
/// - "mono-dark" — black (dark-coloured) glyph
/// - "color" — full vinyl logo
fn resolve_variant(theme_override: Option<&str>) -> IconVariant {
match theme_override {
Some("mono-light") => IconVariant::MonoWhite,
Some("mono-dark") => IconVariant::MonoBlack,
Some("color") => IconVariant::Color,
_ => {
if prefer_dark_tray() {
IconVariant::MonoWhite
} else {
IconVariant::MonoBlack
}
}
}
}
/// Decode pixmaps (22/32/44/64) for the resolved variant.
fn decode_tray_icons(theme_override: Option<&str>) -> Result<Vec<Icon>, String> {
let sources: [&[u8]; 4] = match resolve_variant(theme_override) {
IconVariant::MonoBlack => [
TRAY_ICON_MONO_BLACK_22,
TRAY_ICON_MONO_BLACK_32,
TRAY_ICON_MONO_BLACK_44,
TRAY_ICON_MONO_BLACK_64,
],
IconVariant::MonoWhite => [
TRAY_ICON_MONO_WHITE_22,
TRAY_ICON_MONO_WHITE_32,
TRAY_ICON_MONO_WHITE_44,
TRAY_ICON_MONO_WHITE_64,
],
IconVariant::Color => [
TRAY_ICON_COLOR_22,
TRAY_ICON_COLOR_32,
TRAY_ICON_COLOR_44,
TRAY_ICON_COLOR_64,
],
};
sources.iter().map(|b| decode_pixmap(b)).collect()
}
/// Now-playing info shown in the tooltip. Cleared when no track is loaded.
#[derive(Clone, Debug, Default)]
struct NowPlaying {
title: String,
artist: String,
album: String,
}
struct QbzTray {
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
icons: Vec<Icon>,
now_playing: Option<NowPlaying>,
is_playing: bool,
}
impl QbzTray {
fn play_pause(&self) {
super::dispatch_play_pause(self.runtime.clone(), self.weak.clone(), self.handle.clone());
}
}
impl Tray for QbzTray {
fn id(&self) -> String {
"com.blitzfc.qbz".into()
}
fn title(&self) -> String {
"QBZ".into()
}
fn icon_name(&self) -> String {
// Intentionally empty: SNI panels (KDE Plasma especially) prefer
// IconName over IconPixmap when both are present, and resolving the
// app id against the icon theme picks up the full colour app icon
// instead of our themed monochrome glyph (issue #362). An empty name
// forces panels to render IconPixmap directly.
String::new()
}
fn icon_pixmap(&self) -> Vec<Icon> {
self.icons.clone()
}
fn tool_tip(&self) -> ToolTip {
let (title, description) = match &self.now_playing {
Some(np) => {
let header = if np.title.is_empty() {
"QBZ".to_string()
} else {
np.title.clone()
};
let mut lines: Vec<String> = Vec::with_capacity(3);
if !np.artist.is_empty() {
lines.push(format!("by {}", np.artist));
}
if !np.album.is_empty() {
lines.push(np.album.clone());
}
lines.push(if self.is_playing {
"Middle-click to pause".to_string()
} else {
"Middle-click to play".to_string()
});
lines.push("Scroll to adjust volume".to_string());
(header, lines.join("\n"))
}
None => ("QBZ".to_string(), "Music Player\nNothing playing".to_string()),
};
ToolTip {
title,
description,
icon_name: String::new(),
icon_pixmap: vec![],
}
}
/// Primary click (left) — toggle main window visibility.
fn activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] primary activate (left click)");
super::toggle_window(&self.weak);
}
/// Secondary click (middle) — play/pause.
fn secondary_activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] secondary activate (middle click) -> play/pause");
self.play_pause();
}
/// Mouse wheel — adjust volume in 5%-per-notch steps. Panels report ±120
/// per notch; touch-pad scrolls produce smaller deltas, so we normalise by
/// 120 and fall back to `signum()`.
fn scroll(&mut self, delta: i32, orientation: Orientation) {
if !matches!(orientation, Orientation::Vertical) || delta == 0 {
return;
}
let mut ticks = delta / 120;
if ticks == 0 {
ticks = delta.signum();
}
log::debug!("[tray] scroll delta={} ticks={}", delta, ticks);
// Positive ticks = wheel-up = volume up.
super::dispatch_volume_delta(
self.runtime.clone(),
self.weak.clone(),
self.handle.clone(),
ticks,
);
}
fn menu(&self) -> Vec<MenuItem<Self>> {
vec![
StandardItem {
label: "Play/Pause".into(),
activate: Box::new(|this: &mut Self| this.play_pause()),
..Default::default()
}
.into(),
StandardItem {
label: "Next Track".into(),
activate: Box::new(|this: &mut Self| {
super::dispatch_next(
this.runtime.clone(),
this.weak.clone(),
this.handle.clone(),
)
}),
..Default::default()
}
.into(),
StandardItem {
label: "Previous Track".into(),
activate: Box::new(|this: &mut Self| {
super::dispatch_previous(
this.runtime.clone(),
this.weak.clone(),
this.handle.clone(),
)
}),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Show/Hide Window".into(),
activate: Box::new(|this: &mut Self| super::toggle_window(&this.weak)),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Quit QBZ".into(),
activate: Box::new(|_this: &mut Self| super::quit()),
..Default::default()
}
.into(),
]
}
}
/// Updates dispatched from the rest of the app into the ksni worker.
///
/// We cannot call `ksni::blocking::Handle::update` directly from a tokio task —
/// `ksni` 0.3 (`feature = "blocking"`) wraps every update in
/// `Runtime::block_on`, which panics inside an existing tokio runtime (the
/// playback poll loop runs on one). The panic is swallowed, so updates appear
/// to succeed but never apply. We serialise updates over a `std::sync::mpsc`
/// channel applied from a dedicated `std::thread` outside any tokio context.
enum TrayUpdate {
SetTrack {
title: String,
artist: String,
album: String,
},
ClearTrack,
SetPlaying(bool),
/// Re-decode pixmaps for the requested theme override and push them live
/// (`NewIcon` SNI signal — panels re-fetch without restart).
SetIconTheme(String),
}
/// Cross-thread handle to the live ksni tray. Cloneable; mutators forward to
/// the worker thread, safe from any async context. When the tray failed to
/// start the inner sender stays `None` and every mutator is a no-op.
#[derive(Clone)]
pub struct LinuxTrayHandle {
sender: Arc<Mutex<Option<Sender<TrayUpdate>>>>,
}
impl LinuxTrayHandle {
fn empty() -> Self {
Self {
sender: Arc::new(Mutex::new(None)),
}
}
fn install(&self, handle: ksni::blocking::Handle<QbzTray>) {
let (tx, rx) = mpsc::channel::<TrayUpdate>();
std::thread::Builder::new()
.name("qbz-tray-updater".into())
.spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
TrayUpdate::SetTrack {
title,
artist,
album,
} => {
log::debug!("[tray] tooltip update -> {} / {} / {}", title, artist, album);
handle.update(move |tray| {
tray.now_playing = Some(NowPlaying {
title,
artist,
album,
});
});
}
TrayUpdate::ClearTrack => {
log::debug!("[tray] tooltip cleared");
handle.update(|tray| {
tray.now_playing = None;
tray.is_playing = false;
});
}
TrayUpdate::SetPlaying(is_playing) => {
handle.update(move |tray| {
tray.is_playing = is_playing;
});
}
TrayUpdate::SetIconTheme(theme) => {
log::info!("[tray] icon theme override -> {}", theme);
match decode_tray_icons(Some(&theme)) {
Ok(new_icons) => {
handle.update(move |tray| {
tray.icons = new_icons;
});
}
Err(e) => {
log::error!(
"[tray] failed to decode icons for theme '{}': {}",
theme,
e
);
}
}
}
}
}
log::debug!("[tray] updater thread exiting");
})
.expect("spawn tray updater thread");
if let Ok(mut guard) = self.sender.lock() {
*guard = Some(tx);
}
}
fn send(&self, msg: TrayUpdate) {
let guard = match self.sender.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(tx) = guard.as_ref() {
let _ = tx.send(msg);
}
}
pub fn set_track(&self, title: String, artist: String, album: String) {
self.send(TrayUpdate::SetTrack {
title,
artist,
album,
});
}
pub fn clear_track(&self) {
self.send(TrayUpdate::ClearTrack);
}
pub fn set_playing(&self, is_playing: bool) {
self.send(TrayUpdate::SetPlaying(is_playing));
}
pub fn set_icon_theme(&self, theme: String) {
self.send(TrayUpdate::SetIconTheme(theme));
}
}
/// Initialize the Linux ksni tray service. Spawns a background thread that owns
/// the SNI service and returns a cloneable handle for live tooltip / theme
/// updates. `theme_override`: "auto"/"mono-light"/"mono-dark"/"color".
pub fn init(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
theme_override: &str,
) -> Result<LinuxTrayHandle, Box<dyn std::error::Error>> {
log::info!(
"Initializing ksni tray (Linux, SNI primary-activate enabled, theme={:?})",
theme_override
);
let icons = decode_tray_icons(Some(theme_override))?;
let tray = QbzTray {
runtime,
weak,
handle,
icons,
now_playing: None,
is_playing: false,
};
// Flatpak requires disabling the well-known DBus name because the sandbox
// cannot own arbitrary bus names.
let ksni_handle = if is_flatpak() {
log::info!("[tray] Flatpak detected — spawning ksni without DBus well-known name");
tray.disable_dbus_name(true).spawn()?
} else {
tray.spawn()?
};
let live = LinuxTrayHandle::empty();
live.install(ksni_handle);
log::info!("ksni tray initialized");
Ok(live)
}
+286
View File
@@ -0,0 +1,286 @@
//! System tray for the Slint app.
//!
//! Faithful port of the Tauri tray (`src-tauri/src/tray.rs` +
//! `src-tauri/src/tray_linux_ksni.rs`). Platform split, same as Tauri:
//! - **Linux** → `ksni` / StatusNotifierItem (`linux` submodule). Tauri's
//! libayatana path never dispatches primary-activate (left-click); ksni
//! exposes Activate / SecondaryActivate / Scroll (issue #310).
//! - **macOS / Windows** → `tray-icon` (added with the
//! CustomApplicationHandler slice). Until then, a no-op on those targets.
//!
//! Tray actions differ from Tauri in ONE way: there is no webview to emit
//! events to, so play/pause/next/previous/volume call the playback controller
//! directly (mirroring the now-playing bar's QConnect-aware dispatch) and
//! show/hide toggles the winit window in place.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use slint::ComponentHandle;
use crate::adapter::SlintAdapter;
use crate::AppWindow;
use qbz_app::shell::AppRuntime;
#[cfg(target_os = "linux")]
mod linux;
/// Shared runtime handle type used across the Slint app.
pub(crate) type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Whether the main window is currently shown. The tray toggle and (later)
/// close-to-tray both flip this so left-click / "Show/Hide" stay consistent
/// even on backends where querying winit visibility is unreliable (Wayland).
static WINDOW_SHOWN: AtomicBool = AtomicBool::new(true);
/// Cross-thread handle to the live tray. Cloneable; mutators forward to the
/// platform backend (ksni on Linux) and are no-ops when the tray is disabled
/// or on a platform without a live-update path.
#[derive(Clone, Default)]
pub struct TrayHandle {
#[cfg(target_os = "linux")]
linux: Option<linux::LinuxTrayHandle>,
}
impl TrayHandle {
pub fn set_track(&self, title: String, artist: String, album: String) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_track(title, artist, album);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = (title, artist, album);
}
pub fn clear_track(&self) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.clear_track();
}
}
pub fn set_playing(&self, is_playing: bool) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_playing(is_playing);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = is_playing;
}
pub fn set_icon_theme(&self, theme: String) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_icon_theme(theme);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = theme;
}
}
/// Process-global tray handle, set once by `init`. `None` until the tray is
/// created (or forever, if disabled / unsupported platform).
static TRAY: std::sync::OnceLock<TrayHandle> = std::sync::OnceLock::new();
/// The live tray handle, if the tray was initialized. Callers (playback poll
/// loop, settings) use this to push tooltip / theme updates.
pub fn handle() -> Option<&'static TrayHandle> {
TRAY.get()
}
/// Initialize the system tray, gated by the user's `enable_tray` setting.
/// `theme_override` is the persisted `tray_icon_theme` ("auto"/"mono-light"/
/// "mono-dark"/"color"). No-op when disabled or already initialized.
pub fn init(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
theme_override: String,
enabled: bool,
) {
if !enabled {
log::info!("[tray] disabled by user setting");
return;
}
if TRAY.get().is_some() {
return;
}
#[cfg(target_os = "linux")]
{
// ksni's blocking `spawn()` calls `Runtime::block_on` internally, which
// panics inside an existing tokio runtime (`init` is called from the
// tokio-based shell-entry task). Run the ksni setup on a dedicated
// std::thread, outside any tokio context (the Tauri build is safe
// because it inits from the non-tokio Tauri setup hook). The ksni
// service + updater thread persist independently; this thread exits.
std::thread::Builder::new()
.name("qbz-tray-init".into())
.spawn(move || match linux::init(runtime, weak, handle, &theme_override) {
Ok(linux_handle) => {
let _ = TRAY.set(TrayHandle {
linux: Some(linux_handle),
});
}
Err(e) => log::error!("[tray] Linux tray init failed: {e}"),
})
.expect("spawn tray init thread");
}
#[cfg(not(target_os = "linux"))]
{
// macOS / Windows tray lands with the CustomApplicationHandler slice.
let _ = (runtime, weak, handle, theme_override);
log::info!("[tray] no tray backend on this platform yet");
}
}
// ---------------------------------------------------------------------------
// Window show/hide — used by left-click, the "Show/Hide" menu item, and
// close-to-tray. Uses Slint's own `hide()`/`show()` (NOT winit `set_visible`,
// which is a no-op on Wayland): since Slint 1.7 `hide()` destroys the winit
// surface on Wayland and `show()` recreates it (PR slint-ui/slint#5529), the
// only path that actually works on KWin Wayland. The app survives a hidden
// window because main() runs the loop via `run_event_loop_until_quit()`
// (quit_on_last_window_closed = false).
// ---------------------------------------------------------------------------
/// Toggle the main window: hide if shown, else show + focus.
pub(crate) fn toggle_window(weak: &slint::Weak<AppWindow>) {
if WINDOW_SHOWN.load(Ordering::Relaxed) {
hide_window(weak);
} else {
show_window(weak);
}
}
/// Show the main window (recreates the Wayland surface) and focus it.
pub(crate) fn show_window(weak: &slint::Weak<AppWindow>) {
WINDOW_SHOWN.store(true, Ordering::Relaxed);
let _ = weak.upgrade_in_event_loop(|w| {
if let Err(e) = w.show() {
log::error!("[tray] window show failed: {e}");
}
// Best-effort raise/focus the re-created window (the compositor has
// the final say on Wayland).
use i_slint_backend_winit::WinitWindowAccessor;
w.window().with_winit_window(|win| {
win.focus_window();
});
});
}
/// Hide the main window to the tray (surface destroyed; the process keeps
/// running, the ksni service stays alive on its own thread).
pub(crate) fn hide_window(weak: &slint::Weak<AppWindow>) {
WINDOW_SHOWN.store(false, Ordering::Relaxed);
let _ = weak.upgrade_in_event_loop(|w| {
if let Err(e) = w.hide() {
log::error!("[tray] window hide failed: {e}");
}
});
}
/// Sync the shown-state flag without touching the window — used when Slint
/// itself performs the hide (e.g. an `on_close_requested` → `HideWindow`
/// response) so the next tray toggle knows to show.
pub(crate) fn set_window_shown(shown: bool) {
WINDOW_SHOWN.store(shown, Ordering::Relaxed);
}
/// Quit the whole app from a tray action (any thread).
pub(crate) fn quit() {
log::info!("[tray] quit requested");
let _ = slint::invoke_from_event_loop(|| {
let _ = slint::quit_event_loop();
});
}
// ---------------------------------------------------------------------------
// Player-action dispatch — mirrors the now-playing bar's QConnect-aware
// handlers (main.rs on_toggle_play / on_next / on_previous) so the tray drives
// the exact same path: try the remote renderer first, else local playback.
// ---------------------------------------------------------------------------
pub(crate) fn dispatch_play_pause(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let spawn_handle = handle.clone();
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
match svc.toggle_remote_renderer_playback_if_active().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[tray] play_pause handoff: {e}");
return;
}
}
}
crate::playback::toggle_play_pause(runtime, weak, spawn_handle);
});
}
pub(crate) fn dispatch_next(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let spawn_handle = handle.clone();
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
match svc.skip_next_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[tray] next handoff: {e}");
return;
}
}
}
crate::playback::next(runtime, weak, spawn_handle);
});
}
pub(crate) fn dispatch_previous(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let spawn_handle = handle.clone();
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
match svc.skip_previous_if_remote().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[tray] previous handoff: {e}");
return;
}
}
}
crate::playback::previous(runtime, weak, spawn_handle);
});
}
/// Step the local volume by `ticks` notches of 5% (positive = up). Mirrors the
/// Tauri `tray:volume_delta` handler. Local-only for now (remote-renderer
/// volume forwarding is a later refinement).
pub(crate) fn dispatch_volume_delta(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
ticks: i32,
) {
let spawn_handle = handle.clone();
handle.spawn(async move {
let current = runtime.core().player().get_playback_event().volume;
let next = (current + ticks as f32 * 0.05).clamp(0.0, 1.0);
crate::playback::set_volume(runtime, weak, spawn_handle, next);
});
}
+129
View File
@@ -0,0 +1,129 @@
//! Slint-side glue for tray preferences.
//!
//! The persisted model + SQLite store live in the shared, frontend-agnostic
//! `qbz_app::settings::tray` module (ADR-006). This module only owns a
//! process-global handle to that store, bound to the active user the same way
//! `library_db` binds the per-user `library.db`:
//!
//! <data_dir>/qbz/users/<user_id>/tray_settings.db
//!
//! so the Slint and Tauri builds share the exact same per-user tray settings
//! and the same `normalize_tray_icon_theme` semantics. Runtime tray creation,
//! icon updates and window hide/show live elsewhere (the `tray` module).
use std::path::PathBuf;
use std::sync::LazyLock;
pub use qbz_app::settings::tray::{normalize_tray_icon_theme, TraySettings};
use qbz_app::settings::tray::TraySettingsState;
/// Process-global tray settings handle. Starts session-less (`new_empty`);
/// `init_for_user` binds it to the per-user DB on shell entry.
static STATE: LazyLock<TraySettingsState> = LazyLock::new(TraySettingsState::new_empty);
/// The on-screen order of the tray icon variant dropdown
/// (`AppearanceState.tray-icon-themes`): Auto / Mono light / Mono dark / Color.
/// Indices map to the canonical `tray_icon_theme` string values.
const THEME_BY_INDEX: [&str; 4] = ["auto", "mono-light", "mono-dark", "color"];
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory the shared
/// store appends `tray_settings.db` to. Matches the Tauri per-user path.
fn user_dir(user_id: u64) -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
/// Bind the store to `user_id` on shell entry. Best-effort — failures are
/// logged and leave the store session-less (settings degrade to defaults).
pub fn init_for_user(user_id: u64) {
let Some(dir) = user_dir(user_id) else {
log::warn!("[qbz-slint] tray settings: data dir unavailable, not initialized");
return;
};
if let Err(e) = STATE.init_at(&dir) {
log::error!("[qbz-slint] tray settings init failed: {e}");
}
}
/// Current persisted settings, or `TraySettings::default()` when there is no
/// active session / the read fails.
pub fn get() -> TraySettings {
STATE.get_settings().unwrap_or_default()
}
pub fn set_enable_tray(value: bool) {
if let Err(e) = STATE.set_enable_tray(value) {
log::error!("[qbz-slint] tray settings set_enable_tray failed: {e}");
}
}
pub fn set_minimize_to_tray(value: bool) {
if let Err(e) = STATE.set_minimize_to_tray(value) {
log::error!("[qbz-slint] tray settings set_minimize_to_tray failed: {e}");
}
}
pub fn set_close_to_tray(value: bool) {
if let Err(e) = STATE.set_close_to_tray(value) {
log::error!("[qbz-slint] tray settings set_close_to_tray failed: {e}");
}
}
pub fn set_mac_hide_dock(value: bool) {
if let Err(e) = STATE.set_mac_hide_dock(value) {
log::error!("[qbz-slint] tray settings set_mac_hide_dock failed: {e}");
}
}
/// Persist the tray icon variant from the dropdown index (0..=3). Out-of-range
/// indices fall back to `"auto"`.
pub fn set_icon_theme_index(index: i32) {
let theme = THEME_BY_INDEX
.get(usize::try_from(index).unwrap_or(0))
.copied()
.unwrap_or("auto");
if let Err(e) = STATE.set_tray_icon_theme(theme) {
log::error!("[qbz-slint] tray settings set_tray_icon_theme failed: {e}");
}
}
/// Canonical theme string for a dropdown index (0..=3). Out-of-range → "auto".
/// Used to push the live `set_icon_theme` to the running tray.
pub fn theme_for_index(index: i32) -> String {
THEME_BY_INDEX
.get(usize::try_from(index).unwrap_or(0))
.copied()
.unwrap_or("auto")
.to_string()
}
/// Dropdown index (0..=3) for a canonical theme string. Unknown → 0 (Auto).
pub fn icon_theme_index(theme: &str) -> i32 {
let normalized = normalize_tray_icon_theme(theme);
THEME_BY_INDEX
.iter()
.position(|t| *t == normalized)
.map(|i| i as i32)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_round_trips_canonical_values() {
assert_eq!(icon_theme_index("auto"), 0);
assert_eq!(icon_theme_index("mono-light"), 1);
assert_eq!(icon_theme_index("mono-dark"), 2);
assert_eq!(icon_theme_index("color"), 3);
// Legacy + unknown normalize before lookup.
assert_eq!(icon_theme_index("light"), 1);
assert_eq!(icon_theme_index("dark"), 2);
assert_eq!(icon_theme_index("bogus"), 0);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint"; import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them. // Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice } from "state.slint"; export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it // Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`. // restores a saved session, then resolves to `shell` or `login`.
@@ -700,12 +700,11 @@ export component AppearanceSettings inherits VerticalLayout {
Rectangle { height: 12px; } Rectangle { height: 12px; }
// =================================================================== // ===================================================================
// SYSTEM TRAY // SYSTEM TRAY (macOS: "MENU BAR")
// =================================================================== // ===================================================================
GroupHeader { text: @tr("SYSTEM TRAY"); } GroupHeader { text: AppearanceState.is-macos ? @tr("MENU BAR") : @tr("SYSTEM TRAY"); }
SettingRow { SettingRow {
unsupported: true;
label: @tr("Enable tray icon"); label: @tr("Enable tray icon");
description: @tr("Show icon in system tray (requires restart)"); description: @tr("Show icon in system tray (requires restart)");
QbzToggle { QbzToggle {
@@ -716,24 +715,18 @@ export component AppearanceSettings inherits VerticalLayout {
} }
} }
} }
// NOTE: a separate "Minimize to tray" toggle is intentionally absent.
// Redirecting the window-manager minimize button to the tray requires
// owning that button (custom window chrome), which is not built yet, and
// Wayland forbids a client from intercepting the compositor's minimize.
// Until custom chrome lands, the option is HIDDEN (not shown disabled) and
// this row reads "Close to tray". When custom chrome exists, merge the two
// into a single "Minimize/Close to tray" control.
SettingRow { SettingRow {
unsupported: true;
label: @tr("Minimize to tray");
description: @tr("Hide window to tray when minimizing");
enabled: AppearanceState.tray-enable;
QbzToggle {
checked: AppearanceState.tray-minimize-to-tray;
enabled: AppearanceState.tray-enable;
toggled(v) => {
AppearanceState.tray-minimize-to-tray = v;
AppearanceState.appearance-bool("tray-minimize-to-tray", v);
}
}
}
SettingRow {
unsupported: true;
label: @tr("Close to tray"); label: @tr("Close to tray");
description: @tr("Hide window to tray instead of quitting"); description: AppearanceState.is-macos
? @tr("Keep playing in the menu bar instead of quitting when you close the window")
: @tr("Keep playing in the tray instead of quitting when you close the window");
enabled: AppearanceState.tray-enable; enabled: AppearanceState.tray-enable;
QbzToggle { QbzToggle {
checked: AppearanceState.tray-close-to-tray; checked: AppearanceState.tray-close-to-tray;
@@ -744,8 +737,23 @@ export component AppearanceSettings inherits VerticalLayout {
} }
} }
} }
// macOS-only: switch the activation policy to .accessory while closed to
// the menu bar (no Dock icon). Off = Spotify-style (keep the Dock icon).
SettingRow {
visible: AppearanceState.is-macos;
label: @tr("Hide Dock icon when closed to menu bar");
description: @tr("Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)");
enabled: AppearanceState.tray-enable && AppearanceState.tray-close-to-tray;
QbzToggle {
checked: AppearanceState.tray-mac-hide-dock;
enabled: AppearanceState.tray-enable && AppearanceState.tray-close-to-tray;
toggled(v) => {
AppearanceState.tray-mac-hide-dock = v;
AppearanceState.appearance-bool("tray-mac-hide-dock", v);
}
}
}
SettingRow { SettingRow {
unsupported: true;
label: @tr("Tray icon variant"); label: @tr("Tray icon variant");
description: @tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo"); description: @tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo");
QbzSelect { QbzSelect {
+6
View File
@@ -1432,9 +1432,15 @@ export global AppearanceState {
in-out property <bool> show-purchases: false; in-out property <bool> show-purchases: false;
// --- SYSTEM TRAY ------------------------------------------------------- // --- SYSTEM TRAY -------------------------------------------------------
// `is-macos` is set once from Rust (cfg!(target_os = "macos")) so the tray
// section can swap the header to "Menu Bar" and show macOS-only controls.
in-out property <bool> is-macos: false;
in-out property <bool> tray-enable: true; in-out property <bool> tray-enable: true;
in-out property <bool> tray-minimize-to-tray: false; in-out property <bool> tray-minimize-to-tray: false;
in-out property <bool> tray-close-to-tray: false; in-out property <bool> tray-close-to-tray: false;
// macOS-only: hide the Dock icon when closed to the menu bar (.accessory
// activation policy). Off = Spotify-style (keep the Dock icon).
in-out property <bool> tray-mac-hide-dock: false;
in-out property <[string]> tray-icon-themes: ["Auto", "Mono light", "Mono dark", "Color"]; in-out property <[string]> tray-icon-themes: ["Auto", "Mono light", "Mono dark", "Color"];
in-out property <int> tray-icon-theme-index: 0; in-out property <int> tray-icon-theme-index: 0;