From 8a78c2250f6bf5ba4a1c5b7877e7677c36cc1da2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:44:38 -0600 Subject: [PATCH] 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 --- crates/Cargo.lock | 24 +- crates/qbz-slint/Cargo.toml | 14 + crates/qbz-slint/icons/tray-color-22.png | Bin 0 -> 1314 bytes crates/qbz-slint/icons/tray-color-32.png | Bin 0 -> 2160 bytes crates/qbz-slint/icons/tray-color-44.png | Bin 0 -> 3195 bytes crates/qbz-slint/icons/tray-color-64.png | Bin 0 -> 4839 bytes crates/qbz-slint/icons/tray-dark-22.png | Bin 0 -> 730 bytes crates/qbz-slint/icons/tray-dark-32.png | Bin 0 -> 988 bytes crates/qbz-slint/icons/tray-dark-44.png | Bin 0 -> 1330 bytes crates/qbz-slint/icons/tray-dark-64.png | Bin 0 -> 1899 bytes crates/qbz-slint/icons/tray-light-22.png | Bin 0 -> 705 bytes crates/qbz-slint/icons/tray-light-32.png | Bin 0 -> 970 bytes crates/qbz-slint/icons/tray-light-44.png | Bin 0 -> 1292 bytes crates/qbz-slint/icons/tray-light-64.png | Bin 0 -> 1818 bytes crates/qbz-slint/src/main.rs | 102 +++- crates/qbz-slint/src/playback.rs | 17 + crates/qbz-slint/src/tray/linux.rs | 482 ++++++++++++++++++ crates/qbz-slint/src/tray/mod.rs | 286 +++++++++++ crates/qbz-slint/src/tray_settings.rs | 129 +++++ crates/qbz-slint/ui/app.slint | 2 +- .../ui/settings/AppearanceSettings.slint | 48 +- crates/qbz-slint/ui/state.slint | 6 + 22 files changed, 1084 insertions(+), 26 deletions(-) create mode 100644 crates/qbz-slint/icons/tray-color-22.png create mode 100644 crates/qbz-slint/icons/tray-color-32.png create mode 100644 crates/qbz-slint/icons/tray-color-44.png create mode 100644 crates/qbz-slint/icons/tray-color-64.png create mode 100644 crates/qbz-slint/icons/tray-dark-22.png create mode 100644 crates/qbz-slint/icons/tray-dark-32.png create mode 100644 crates/qbz-slint/icons/tray-dark-44.png create mode 100644 crates/qbz-slint/icons/tray-dark-64.png create mode 100644 crates/qbz-slint/icons/tray-light-22.png create mode 100644 crates/qbz-slint/icons/tray-light-32.png create mode 100644 crates/qbz-slint/icons/tray-light-44.png create mode 100644 crates/qbz-slint/icons/tray-light-64.png create mode 100644 crates/qbz-slint/src/tray/linux.rs create mode 100644 crates/qbz-slint/src/tray/mod.rs create mode 100644 crates/qbz-slint/src/tray_settings.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 3be43c651..8a8062e30 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -638,7 +638,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.18", "v_frame", @@ -3432,6 +3432,20 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "kurbo" version = "0.13.1" @@ -4615,6 +4629,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -5283,7 +5303,9 @@ dependencies = [ "dirs 5.0.1", "env_logger", "futures-util", + "i-slint-backend-winit", "image", + "ksni", "log", "open", "qbz-app", diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index a07d9d1a9..cc37bd37c 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -22,6 +22,12 @@ slint = { version = "1.16", default-features = false, features = [ "accessibility", "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 } env_logger = "0.11" tokio = { workspace = true } @@ -53,5 +59,13 @@ rusqlite = { version = "0.31", features = ["bundled"] } qbz-cache = { path = "../qbz-cache" } 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] slint-build = "1.16" diff --git a/crates/qbz-slint/icons/tray-color-22.png b/crates/qbz-slint/icons/tray-color-22.png new file mode 100644 index 0000000000000000000000000000000000000000..8a776fd58e905eef4b987ae3c9edb7327602f45e GIT binary patch literal 1314 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 literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-color-64.png b/crates/qbz-slint/icons/tray-color-64.png new file mode 100644 index 0000000000000000000000000000000000000000..d8eede599a9bb8480da991879a13c0019299cac9 GIT binary patch literal 4839 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 Iol`;+02eSPLjV8( literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-dark-32.png b/crates/qbz-slint/icons/tray-dark-32.png new file mode 100644 index 0000000000000000000000000000000000000000..7b28a6bf489e6b3d5f7d3392f2d792f74b04a856 GIT binary patch literal 988 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4zl0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4T;_2(k{)$DG(Ms;q9HZAj zA=MJsh?11Vl2ohYqEsNoU}RuqrfXoLYh)Q>U}$A(VP$NfZD3$!V6gu7+(;A+x%nxX zX_dG&+-p;P3e=zhx1l66H?_DVF}DD>p3ccERzO!D@N{tu@i@M8s;@^#qDb5Qi*-7u zPR_4evtomfVh>N$43W$rPuFR;__v8Qbxhl;krQeyE-LAk(S5H!1!0o$1v|(}K+Zz6w0*}+|CSIA7%shWRn6=SaPj=ds=dXHp zPdGlQOM}OH-2^)?Egp4`$zRSgOxFw#@^9kVrRpi&`#&_OYKD%d_oA&{lm2jW)=XNm z^jN$|-4ahO&!{{mM^=@$20u5we88AD)nk&K^qkviUNNj{vc6v!>aN|0Gi7k*VT^mj zbs~Xlhfvw7N9#VaFw`a*RMwa~Wf{$=Wsu>rw)8p9H8Y)or+Ai+Sn)-b8>s@i2TX1X zZkPSwwwxjF-h?yvZiTK@X4v9heuvp%_To04b;g+;io1S_{%y!vIn}cC8gImvBN_VL zQc@WU=J5*MmRQg)(^a)*xzSMOw+Ei4yUs&^X z$)AlkJWstW2~aX~R-fAWVrSht?U02+xq-V>yyu&&h`WAcPxb%qzl<#wC-?p|$_A!o O1_n=8KbLh*2~7YD+l>JL literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-dark-44.png b/crates/qbz-slint/icons/tray-dark-44.png new file mode 100644 index 0000000000000000000000000000000000000000..709277e114b05f0ac4c4d7aa2b57e78bc3b24100 GIT binary patch literal 1330 zcmZ{kdpOez7{`A^b@nt#q;f0mlH0M(I+#mj%YMW|kCaa3wvA!4SjQ9{Cz9K;hRl#$ zI*V8?qhg(O7&^(V5d10D)s@4YTCjCy$D+r8b)e3V`UL$Mis%Cgi}mXFq4c6jUlT5U{IJJtWa1h zjJ+QUjXPwAvq4#)P&gFo{z!4a{{&hziAst8w(uquKA{p0{&#^%rIBNq!8FFVo%9?H z8~|wS0`abX#LmWiTDU)Xcjv}r{H;55#0)`0#`%*>_bj)q)CLm*2p&5sbbtA=qc-Fy zLAz_)QQ}eEgM?_&?OC@^V-Y-`DMj)#%gMj?N{ck1d3RSZ4+KuVmlN%lVh>L`_l|(m zt1&%^vW<<6*OSe2UvNV=z9JVq)Y2-wGethJu9t!=>Q^@Sh%Lrqr_ywqxmWZFx z^bhdg`mUOh*P~#Ifuww8C~?fuiTUz{Z_hP~R*WaMe>bhd%1YwQzY8~Z>7GO{qJq)X z(`I=V=F0;*9jvQT%^R`9J(`*8oJt8v&@g)Bu%LN-@1OYq}xTZlHIn&@SSnPl6!`b>V>m9Iwb}DZLSent~+Iq?CT>lZnc8vKIR{R zZU77{@f{GY!L`X@+68ANnL)ft?Ax4Su*ODPp|^ zqt)wIo48-9_fwkEyO1qQjd|X=HsQ6k5QUQJ3uO?ZRF2~uxk@yDUCQxDY;eBdd zmx<7}Ene&HQ~&#h0qJncqEqLknt3`Wge%N}v00r)bBXe1iKiTL@1z^I;!^QN$BJAS zwbXi6@D`t@_}OH$I60m);iX6f2lA>QEaa!caf=R^R}QQgdOi%~D*y1{Y8B25USATy zs8!apBcwD|0gNiT{c%rh4O2W(P{^x5g99h_kJ*C*2}94Y^fG^L)YcyRBnUY0(Av=y z?!1AMa@j`jr|6*UR{NS~ZJ5wV{q9wRaB$^0VsOp-{D`5;lc)rX2I0e% z?yS0fFsJM`!c`=Xs{d|soY&A2HN0d_f`wCBcYG-vVsUIJqV~5Kws*Owb$RcxC|~39 zo9?VDg5h?|6Lc^dndGg*#b*Yalv*A;OIQ4yaUp}$UWtl>B50Ljs3}ceSzgUX(8xAU zubbAAQl!^7^69SnLfYsG__QfY>GE8Z&*zp6GEc1y6}0Md--MRFpJilcC0CE#aWNS2 z6|oPQNfp?^!2a$K!Mw2a#&rNeh-^36Om8f-4h@^0Fwp9YT#D|vUTYcQ4BPR(w6Cr! zY#pmP3a?@n>51v)fy1fsnI|Otg@MDITf%;5)sUWfKGXGp;$+F@@84}v4$KCYIS`7K S5?giER{=nRH(q!o=*mCV@?XFJ literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-dark-64.png b/crates/qbz-slint/icons/tray-dark-64.png new file mode 100644 index 0000000000000000000000000000000000000000..7ff20e1eeb66272fdb14a3719485bf4112072b1c GIT binary patch literal 1899 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 literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-light-32.png b/crates/qbz-slint/icons/tray-light-32.png new file mode 100644 index 0000000000000000000000000000000000000000..9be22de462912d2ad7d70067d0a7f2774d70a10b GIT binary patch literal 970 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4zl0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4T;_2(k{)$DG(Ms;q9HZAj zA=MJsh?11Vl2ohYqEsNoU}RuqrfXoLYh)Q>U}$A(VP$NfZD3$!V6gu7+(;A+x%nxX zX_dG&+-p;P3e=zhx1l66H?_DVF}DD>p3ccERzO#;^K@|x@i@Nps&__gssQ_k`0Cn_ zi$~PH28y<3g-+-ajk&VmM#Pn-q^6I=F5h%Id+BLOHMbG5!jtw)C@s`*%Va+9HU4k*LM&O#_43vS#r<(}_QY8RX4M6lz3X_nM7r4B{MYAIPAe*|t1Vrk zfAh&+8=tF}=f2Qi&0YFC=K8g|wStN|l`H2~dc@B77&4vb{b~7>l~-TuZ9cSxeb}txRfFzc4)HiQyaxUa{@d=Z%l3sOf;GqW}#ej z`%?e*mV)#{$6L=wX#V~?ktaN|`QtQ(_5*7<|MvyCwP{aMbXcqEAR06CL+Z_3)oBH# zMmjT&@8$e%`9OHDSyTMvedg(pcoVk;9N835?A*7FGx5@r08hy^M(TU!HLmr|t#RWJ z-uXFd`6Y$7zIOcF4?psJvA%9{BA)Z%lILG`+|kxN@b_f${sO=IMlzyWt-2RpA2(~3 zsXMiK*U3EwRS%D_w;BYUNSwO;tlFbV&i}ObINV&pYf@%;B{x6gPS)+W+ip*YF4(?U z?Mt!;N4xayn$7!LZ?5B3o$M&O@YbGy8<~MCMB`?ZZ8}^tJEpku;GzKYONlGjea(!U zC~kf9ex};}ir>5ByZ6V<|Bn9{TLWxP`m=c{0#iGKr>mdKI;Vst0C~op Am;e9( literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/icons/tray-light-44.png b/crates/qbz-slint/icons/tray-light-44.png new file mode 100644 index 0000000000000000000000000000000000000000..957fe66b20b783f2be7103cb819cff2e5309ccab GIT binary patch literal 1292 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 literal 0 HcmV?d00001 diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 6b700f7e5..17728240b 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -64,6 +64,8 @@ mod settings; mod share; mod sidebar; mod toast; +mod tray; +mod tray_settings; mod ui_prefs; use std::sync::Arc; @@ -94,10 +96,34 @@ async fn enter_shell( // settings live in the per-user library.db). 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 state = w.global::(); state.set_user_name(session.display_name.into()); state.set_subscription(session.subscription.into()); + // Seed the tray settings UI from the persisted per-user store. + let appearance = w.global::(); + 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::().set_loading(true); w.set_screen(AppScreen::Shell); }); @@ -1751,6 +1777,13 @@ fn main() -> Result<(), Box> { .global::() .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::() + .set_is_macos(cfg!(target_os = "macos")); + let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak()))); // MusicBrainz cache — opens a SQLite store at @@ -2234,6 +2267,32 @@ fn main() -> Result<(), Box> { }); } + // 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::(); + 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 // into the playback controller; favorite / download stay logged. { @@ -6557,9 +6616,38 @@ fn main() -> Result<(), Box> { }); } - window.on_close_app(|| { - log::info!("[qbz-slint] closing"); - let _ = slint::quit_event_loop(); + 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"); + 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(|| { @@ -6570,6 +6658,12 @@ fn main() -> Result<(), Box> { }); 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(()) } diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 0c5eb3845..3c20d68ed 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -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) { let state = runtime.core().get_queue_state().await; 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| { w.global::().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) }; + // 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 np = w.global::(); np.set_has_track(true); @@ -2393,6 +2402,14 @@ pub fn start_poll_loop( last_track_id = track_id; 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; // Auto-advance on track end. diff --git a/crates/qbz-slint/src/tray/linux.rs b/crates/qbz-slint/src/tray/linux.rs new file mode 100644 index 000000000..d7a276342 --- /dev/null +++ b/crates/qbz-slint/src/tray/linux.rs @@ -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 { + 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, 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, + handle: tokio::runtime::Handle, + icons: Vec, + now_playing: Option, + 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 { + 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 = 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> { + 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>>>, +} + +impl LinuxTrayHandle { + fn empty() -> Self { + Self { + sender: Arc::new(Mutex::new(None)), + } + } + + fn install(&self, handle: ksni::blocking::Handle) { + let (tx, rx) = mpsc::channel::(); + 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, + handle: tokio::runtime::Handle, + theme_override: &str, +) -> Result> { + 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) +} diff --git a/crates/qbz-slint/src/tray/mod.rs b/crates/qbz-slint/src/tray/mod.rs new file mode 100644 index 000000000..721817c76 --- /dev/null +++ b/crates/qbz-slint/src/tray/mod.rs @@ -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>; + +/// 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, +} + +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 = 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, + 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) { + 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) { + 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) { + 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, + 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, + 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, + 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, + 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); + }); +} diff --git a/crates/qbz-slint/src/tray_settings.rs b/crates/qbz-slint/src/tray_settings.rs new file mode 100644 index 000000000..ff19b82f7 --- /dev/null +++ b/crates/qbz-slint/src/tray_settings.rs @@ -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`: +//! +//! /qbz/users//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 = 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"]; + +/// `/qbz/users//` — 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 { + 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); + } +} diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 1287a7eb3..99277dd86 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint"; import { Toast } from "primitives/Toast.slint"; // 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 // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/settings/AppearanceSettings.slint b/crates/qbz-slint/ui/settings/AppearanceSettings.slint index 9a9796da8..a8ce99590 100644 --- a/crates/qbz-slint/ui/settings/AppearanceSettings.slint +++ b/crates/qbz-slint/ui/settings/AppearanceSettings.slint @@ -700,12 +700,11 @@ export component AppearanceSettings inherits VerticalLayout { 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 { - unsupported: true; label: @tr("Enable tray icon"); description: @tr("Show icon in system tray (requires restart)"); 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 { - 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"); - 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; QbzToggle { 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 { - unsupported: true; 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"); QbzSelect { diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 1f45e4071..53bfc50b2 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1432,9 +1432,15 @@ export global AppearanceState { in-out property show-purchases: false; // --- 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 is-macos: false; in-out property tray-enable: true; in-out property tray-minimize-to-tray: false; in-out property 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 tray-mac-hide-dock: false; in-out property <[string]> tray-icon-themes: ["Auto", "Mono light", "Mono dark", "Color"]; in-out property tray-icon-theme-index: 0;