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
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 730 B |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 705 B |
|
After Width: | Height: | Size: 970 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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({
|
||||||
log::info!("[qbz-slint] closing");
|
let weak = window.as_weak();
|
||||||
let _ = slint::quit_event_loop();
|
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");
|
||||||
|
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(())
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||