Merge pre-release into main for v1.2.12 hotfix release

This commit is contained in:
vicrodh committed 2026-05-09 18:14:44 -06:00
commit e920c350c4
28 files changed
+565 -825

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
Shipping v1.2.12 hotfix that rolls back the broken titlebar-mode-selector
# 1.2.11 — Exclusive Hardening
This release tightens bit-perfect playback on both fronts. macOS finally gets a real **CoreAudio Exclusive Mode** thanks to **@Vudgekek**'s first-class contribution (PR #391), and on Linux the player now actively **reserves the DAC** via the standard `org.freedesktop.ReserveDevice1` D-Bus protocol for the lifetime of an ALSA Direct stream. Both halves landed in the same cycle by happy coincidence — hence the codename.
Beyond audio, this is the largest **Local Library** release since the local engine landed: a new folder-tree mode, ephemeral playback that never writes to your library DB, and a metadata-grouped Albums tab that finally treats Plex as a first-class peer.
A new **Offline Cache Manager** view replaces the old Settings shortcut, with a configurable cache cap (5 GB default), live progress rings, and per-album expand. **Discord Rich Presence** lands as opt-in and works inside the Flathub sandbox via a small symlink bridge. macOS gets a proper title bar integration thanks to **@afonsojramos**, who continues to keep the macOS build healthy alongside this round of fixes.
Welcome to **@Vudgekek** and **@DoubleGate** as new contributors — thanks for the first PRs, and for taking the time to engage with the review process.
---
## Exclusive Audio
- **macOS CoreAudio Exclusive Mode** (PR #391, @Vudgekek) — bit-perfect playback on Apple hardware via `kAudioDevicePropertyHogMode`; exclusive stream recreation on track changes; CoreAudio device-rate checked against the requested track rate before claiming the device
- **macOS hardware volume alignment** — the exclusive device's hardware volume tracks qbz's volume slider when in exclusive mode (@Vudgekek)
- **macOS exclusive stream hardening** — defensive cleanup on stream errors and shutdown so a failed claim never strands the device (@afonsojramos)
- **Linux DAC reservation** — per-process zbus client acquires `org.freedesktop.ReserveDevice1` (the standard cross-app coordination protocol used by JACK, PipeWire, and PulseAudio) for the lifetime of an `AlsaDirectStream`; other apps see qbz as the sole DAC owner instead of getting silent, misleading "device busy" errors
- **Reserve-DAC toggle** — Settings → Audio adds an opt-out for the reservation, persisted in `audio_settings.db` alongside the existing backend choice
- **ALSA card resolution by id** — ALSA card matching now uses the stable card id (`hw:0`) instead of the long descriptive name; any plugin prefix is accepted and parse failures degrade gracefully
- **`audio_settings` defaults** — `backend_type` now defaults to a per-OS choice and migrates rows where the column was NULL on older installs
---
## Local Library
- **Folder Tree mode** — new two-column layout with the folder tree on the left and a folder detail pane on the right; drag-resizable sidebar (default ×1.8), recursive multi-select, search with path-parents preserved, and a network-folder filter for shares that take a while to walk
- **Ephemeral folder playback** — pick a folder outside your watched library and play it without writing anything to the library DB, including CUE support, a format whitelist, ephemeral-aware gapless prefetch, and a session-restore filter so transient picks don't pin themselves
- **Albums tab — metadata-grouped** — the existing folder-grouped Albums view is now joined by a metadata-grouped variant that merges entries across folders (and Plex), with the full action bar (group/filter/sort/view/select)
- **Per-user Library tab order** — new modal to reorder, show, or hide tabs; persists per user; the active tab loader now triggers after preferences settle on mount, so the loader never hits the wrong tab
- **Plex per-track artwork** preserved during library scan; the same fix is applied to both scan loops, with a folder-lookup cache to avoid the second-loop regression
- **Compact album view** for the tree right pane, with selection + bulk actions, circular play, and a per-view track search
- **Multi-select coverage in Album Detail** (#381) — bulk action bar now reaches the album-detail tracklist as well
- **Local Library performance** — viewport-driven Plex hydration with a quality override map; batched track-quality with per-call timeout; sort pushed into SQL instead of `localeCompare` on the client
---
## Discord Rich Presence (#336)
- **Opt-in track display** with album, artist, and elapsed time
- **Native Discord** (deb / rpm / AUR builds) writes the IPC socket directly under `$XDG_RUNTIME_DIR` and works without setup
- **Discord-via-Flathub** — qbz reads the Discord-Flatpak app dir (with `:create` permission) and lays down its own `discord-ipc-{0..9}` symlinks pointing at it; the symlinks bridge the two sandboxes for the `discord-rich-presence` crate, which only knows the static path
- **Lazy bridge** — symlinks are only laid down after you enable the toggle in Settings, so users who never opt in never touch that filesystem path
- **Translations** — keys for all five locales (en/es/de/fr/pt)
---
## Offline Cache Manager
- **Dedicated view** replaces the old "Open folder" shortcut in Settings; navigate via the new sidebar entry or the Manage offline cache button
- **Album rows** with expand-by-default, per-track actions, album cover artwork, and live progress rings on both album and track rows
- **Alpha-index artist rail** — jump to artists alphabetically; empty-state CTA when nothing is cached yet
- **Cache size limit** — modal to set a configurable cap (default bumped from 2 GB to 5 GB), persisted across restarts; new track downloads enforce the limit
- **Re-download flow** — copy renamed from "Re-download album" to "Re-download all tracks" to match what actually happens
- **Excluded from `last_view` persistence** — opening the manager doesn't pin it on next launch
---
## Sleep Timer (#402)
- **Hard-stop pause** — at expiry the player pauses cleanly (no fadeouts that overrun the deadline)
- **Smart-positioned popover** with refined typography; custom durations from 1 minute upward
---
## Title bar
- **macOS integration** (@afonsojramos) — qbz custom titlebar now mounts on macOS with traffic-light reservation, drag band aligned to sidebar top padding; new nav and search titlebar toggles plus a hide-title-bar toggle in Settings; legacy macOS overlay drag region retired
- **Mode selector** — Settings → Appearance → Title bar replaces the hide/system toggles with a `system / plasma / stripped` dropdown; legacy preferences are migrated, sandbox/Wayland caveats are surfaced as inline hints
---
## QConnect
- **Startup persistence** — new `startup_mode` (Auto / Always-on / Disabled) and `last_known_state` write-through on connect/disconnect, both persisted; settings dropdown surfaces the choice with a local-library hint
- **CLI override** — `--enable-qconnect` and `--disable-qconnect` flags applied during app setup
- **Honor peer-controller seeks** when qbz is the active renderer — peer-driven seeks no longer get clobbered by the local cursor-align
---
## Album & Artist detail polish
- **Header gradient backdrop** — palette-derived gradient and blurred artwork on Album/Artist detail
- **Album metadata enrichment** — featured artist line, parental warning badge, click-to-expand description, tracklist toolbar, label/awards sidebar shrunk by ~20%
- **Tracklist columns aligned** with smooth select-mode toggle
---
## Immersive background
- **Kawarp WebGL renderer** replaces the legacy WebGL2 path in `Full` mode; attribution added to the About modal
---
## Bug fixes
- **Mixtape detail** — virtualized list scroll-up behavior stabilized
- **Library** — Albums tab visible in Edit Library Tabs modal; Plex albums included in metadata-grouped Albums view
- **Audio (Linux)** — host stream drop deferred on Stop so the device can be reused across track changes (@afonsojramos)
- **Audio (macOS)** — output device picker populated; the 50 ms post-stop sleep is now gated to `cfg(target_os = "linux")` (@afonsojramos)
- **Playback** — uniform 250 ms state polling cadence eliminates seekbar cold-start latency
- **Playlist** — track-selection key includes the row index for duplicate tracks (#386); multi-select is now aligned with the TrackRow checkbox pattern; the checkbox input is pointer-transparent so the row click handler always fires
- **Sidebar** — `isOffline` `$effect` skips its initial subscription so a stale value doesn't flip the offline indicator on mount (@DoubleGate, PR #384)
- **Home** — sticky tab header pop-in animation unified across the macOS and Linux paths (@afonsojramos, PR #407)
- **Theme** — WCAG-aware `btn-primary-text` token across all themes (the previous fixed value failed contrast on a couple of light themes)
---
## Internal & packaging
- **Node 20 → 24** across CI workflows and the Flatpak manifest
- **Dependency bumps** — Tauri 2.10.3 → 2.11.1, openssl 0.10.79, tokio 1.52.1, axum 0.8.9, sha2 0.11.0, mdns-sd 0.19.1, plus the usual minor/patch swarm
- **Flatpak permissions** — the manifest now declares `--filesystem=xdg-run/discord-ipc-0` and `--filesystem=xdg-run/app/com.discordapp.Discord:create` for the lazy Discord IPC bridge
---
Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.10...v1.2.11
+3 -3
View File
@@ -15,10 +15,10 @@
# VERSION BUMP: update version, rev, and hashes
# when tagging a new release.
# ──────────────────────────────────────────────
qbzVersion = "1.2.10";
qbzVersion = "1.2.11";
qbzRev = "v${qbzVersion}";
srcHash = "sha256-cPe7J0KtcTQFfjhNL9giRPLx2VwHsUYRDV/ZM/2dFas=";
npmHash = "sha256-MYBxnLGSz4MyrpdNKUIBtnA6HFimPwrcEOYOGSbS5AY=";
srcHash = "sha256-6KAm9dZBNwjEcmaKTWSq4r91Cu1f6Fqr3G9Z//FaOCU=";
npmHash = "sha256-Iz5QzSsMhP/82kANYOhZftX7n1wrLeTUI5Hda/JM0pE=";
in
{
packages.default = pkgs.rustPlatform.buildRustPackage rec {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "qbz-nix",
"version": "1.2.11",
"version": "1.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qbz-nix",
"version": "1.2.11",
"version": "1.2.12",
"license": "MIT",
"dependencies": {
"@icons-pack/svelte-simple-icons": "^7.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "qbz-nix",
"version": "1.2.11",
"version": "1.2.12",
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
"type": "module",
"scripts": {
@@ -69,6 +69,17 @@
</screenshot>
</screenshots>
<releases>
<release version="1.2.12" date="2026-05-09">
<description>
<p>Hotfix: rolls back the broken titlebar-mode-selector that left v1.2.11 with no visible window chrome on Linux. Restores v1.2.10 title bar behavior (System / Custom / Hidden). All other v1.2.11 features remain.</p>
<ul>
<li>Revert titlebar-mode-selector and PR #406 (Linux titlebar draggable + macOS QBZ strip)</li>
<li>macOS QoL (PR #408 by @Vudgekek): native .icns app icon and tray icon variant support with auto dark-mode detection</li>
<li>Sticky home tab header animation fix (PR #407 by @afonsojramos) preserved</li>
<li>All v1.2.11 features remain: macOS CoreAudio Exclusive Mode, Linux DAC reservation, Discord Rich Presence, Sleep Timer, Offline Cache Manager, Local Library folder-tree and ephemeral playback, metadata-grouped Albums, QConnect startup persistence, album/artist detail polish</li>
</ul>
</description>
</release>
<release version="1.2.11" date="2026-05-09">
<description>
<p>Exclusive Hardening: bit-perfect playback gets reinforced on both ends — macOS gains a real CoreAudio Exclusive Mode (community PR by @Vudgekek) and Linux now reserves the DAC via the standard ReserveDevice1 D-Bus protocol while ALSA Direct is engaged.</p>
+2 -2
View File
@@ -3,7 +3,7 @@
name: qbz-player
title: "QBZ — Native Qobuz Client for Linux"
version: '1.2.11'
version: '1.2.12'
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
description: |
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
@@ -97,7 +97,7 @@ parts:
qbz:
plugin: rust
source: https://github.com/vicrodh/qbz.git
source-tag: v1.2.10
source-tag: v1.2.12
build-packages:
- libwebkit2gtk-4.1-dev
- libgtk-3-dev
+1 -1
View File
@@ -5148,7 +5148,7 @@ dependencies = [
[[package]]
name = "qbz-nix"
version = "1.2.11"
version = "1.2.12"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "qbz-nix"
version = "1.2.11"
version = "1.2.12"
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
authors = ["blitzkriegfc"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+13 -28
View File
@@ -10,7 +10,6 @@ use crate::config::playback_preferences::{
use crate::config::tray_settings::TraySettings;
use crate::config::tray_settings::TraySettingsState;
use crate::config::window_settings::WindowSettingsState;
use crate::config::window_settings::{available_titlebar_modes, is_sandboxed, TitlebarMode};
use crate::core_bridge::CoreBridgeState;
use crate::AppState;
@@ -24,32 +23,11 @@ pub async fn v2_set_api_locale(locale: String, state: State<'_, AppState>) -> Re
}
#[tauri::command]
pub fn v2_get_titlebar_mode(
state: State<'_, WindowSettingsState>,
) -> Result<TitlebarMode, String> {
state.get_titlebar_mode()
}
#[tauri::command]
pub fn v2_set_titlebar_mode(
mode: TitlebarMode,
pub fn v2_set_use_system_titlebar(
value: bool,
state: State<'_, WindowSettingsState>,
) -> Result<(), String> {
state.set_titlebar_mode(mode)
}
#[derive(serde::Serialize)]
pub struct TitlebarModeAvailability {
pub modes: Vec<TitlebarMode>,
pub sandboxed: bool,
}
#[tauri::command]
pub fn v2_available_titlebar_modes() -> TitlebarModeAvailability {
TitlebarModeAvailability {
modes: available_titlebar_modes(),
sandboxed: is_sandboxed(),
}
state.set_use_system_titlebar(value)
}
#[tauri::command]
@@ -96,13 +74,12 @@ pub fn v2_set_close_to_tray(
}
/// Update the tray icon variant ("auto" / "light" / "dark"). Persists
/// the setting and pushes the change to the live SNI tray on Linux so
/// it takes effect immediately — no restart required.
/// the setting and pushes the change to the live tray handle when one is
/// active, so it takes effect immediately — no restart required.
#[tauri::command]
pub fn v2_set_tray_icon_theme(
value: String,
state: State<'_, TraySettingsState>,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let normalized = crate::config::tray_settings::normalize_tray_icon_theme(&value);
@@ -124,6 +101,14 @@ pub fn v2_set_tray_icon_theme(
tray.set_icon_theme(normalized);
}
}
#[cfg(not(target_os = "linux"))]
{
use tauri::Manager;
if let Some(tray) = app_handle.try_state::<crate::tray::NativeTrayHandle>() {
tray.set_icon_theme(normalized);
}
}
Ok(())
}
+2 -166
View File
@@ -9,44 +9,9 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
/// Titlebar rendering mode. Controls which decoration source is active and
/// how the custom titlebar component renders (or whether it renders at all).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TitlebarMode {
/// Default — full custom titlebar with drag, resize, dblclick, controls.
Qbz,
/// OS-native chrome (CSD on Wayland, KWin SSD on X11, NSWindow on macOS).
System,
/// KDE-only — KWin SSD via Xwayland + stripped custom strip below for search+nav.
Plasma,
/// No titlebar — for tiling WMs (i3/Hyprland/sway/niri).
Hidden,
}
impl TitlebarMode {
/// Whether this mode requests OS decorations (decorations=true at window creation).
pub fn wants_decorations(self) -> bool {
matches!(self, TitlebarMode::System | TitlebarMode::Plasma)
}
/// Whether this mode requires GDK_BACKEND=x11 to coerce SSD on Wayland.
pub fn wants_xwayland(self) -> bool {
matches!(self, TitlebarMode::Plasma)
}
}
impl Default for TitlebarMode {
fn default() -> Self {
TitlebarMode::Qbz
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowSettings {
/// Use OS native window decorations (legacy — superseded by `titlebar_mode`).
/// Kept as a write-through shadow of `titlebar_mode in {System, Plasma}` for
/// any consumers that still read it; will be removed in a follow-up.
/// Use OS native window decorations instead of custom title bar
pub use_system_titlebar: bool,
/// Last saved window width (logical pixels, non-maximized)
pub window_width: f64,
@@ -58,8 +23,6 @@ pub struct WindowSettings {
/// Implies building the window transparent on Linux so the corners
/// can be seen through to the desktop. Requires restart to take effect.
pub match_system_window_chrome: bool,
/// Authoritative titlebar mode (replaces `use_system_titlebar`).
pub titlebar_mode: TitlebarMode,
}
impl Default for WindowSettings {
@@ -70,7 +33,6 @@ impl Default for WindowSettings {
window_height: 800.0,
is_maximized: false,
match_system_window_chrome: false,
titlebar_mode: TitlebarMode::Qbz,
}
}
}
@@ -124,26 +86,6 @@ impl WindowSettingsStore {
[],
);
// Add titlebar_mode column. Defaults to NULL so we can detect
// pre-existing rows and migrate from use_system_titlebar.
let _ = conn.execute(
"ALTER TABLE window_settings ADD COLUMN titlebar_mode TEXT",
[],
);
// One-time migration: if titlebar_mode is NULL but use_system_titlebar
// exists, derive mode. This runs at every open but only does work on
// rows where titlebar_mode is still NULL (idempotent).
let _ = conn.execute(
"UPDATE window_settings
SET titlebar_mode = CASE
WHEN use_system_titlebar = 1 THEN 'system'
ELSE 'qbz'
END
WHERE titlebar_mode IS NULL AND id = 1",
[],
);
info!("[WindowSettings] Database initialized");
Ok(Self { conn })
@@ -160,32 +102,11 @@ impl WindowSettingsStore {
Self::open_at(base_dir, "window_settings.db")
}
/// Lightweight read-only open for startup (before Tauri state).
/// Opens existing DB without creating tables/running migrations.
pub fn new_readonly() -> Result<Self, String> {
let db_path = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("window_settings.db");
if !db_path.exists() {
return Err("Window settings DB does not exist yet".to_string());
}
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open window settings database (readonly): {}", e))?;
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<WindowSettings, String> {
self.conn
.query_row(
"SELECT use_system_titlebar, window_width, window_height, is_maximized,
match_system_window_chrome, titlebar_mode
match_system_window_chrome
FROM window_settings WHERE id = 1",
[],
|row| {
@@ -194,13 +115,6 @@ impl WindowSettingsStore {
let window_height: f64 = row.get(2)?;
let is_maximized: i32 = row.get(3)?;
let match_chrome: i32 = row.get(4)?;
let mode_str: Option<String> = row.get(5)?;
let titlebar_mode = match mode_str.as_deref() {
Some("system") => TitlebarMode::System,
Some("plasma") => TitlebarMode::Plasma,
Some("hidden") => TitlebarMode::Hidden,
_ => TitlebarMode::Qbz,
};
let defaults = WindowSettings::default();
let (w, h) = if is_valid_window_size(window_width, window_height) {
(window_width, window_height)
@@ -218,7 +132,6 @@ impl WindowSettingsStore {
window_height: h,
is_maximized: is_maximized != 0,
match_system_window_chrome: match_chrome != 0,
titlebar_mode,
})
},
)
@@ -245,26 +158,6 @@ impl WindowSettingsStore {
Ok(())
}
pub fn set_titlebar_mode(&self, mode: TitlebarMode) -> Result<(), String> {
let mode_str = match mode {
TitlebarMode::Qbz => "qbz",
TitlebarMode::System => "system",
TitlebarMode::Plasma => "plasma",
TitlebarMode::Hidden => "hidden",
};
let use_system = mode.wants_decorations() as i64;
self.conn
.execute(
"UPDATE window_settings
SET titlebar_mode = ?1, use_system_titlebar = ?2
WHERE id = 1",
params![mode_str, use_system],
)
.map_err(|e| format!("Failed to set titlebar_mode: {}", e))?;
info!("Window titlebar_mode set to {}", mode_str);
Ok(())
}
/// Save the non-maximized window dimensions (called on resize while not maximized).
pub fn set_window_size(&self, width: f64, height: f64) -> Result<(), String> {
if !is_valid_window_size(width, height) {
@@ -369,23 +262,6 @@ impl WindowSettingsState {
.ok_or("Window settings store not initialized")?;
store.set_match_system_window_chrome(value)
}
pub fn set_titlebar_mode(&self, mode: TitlebarMode) -> Result<(), String> {
let store = self.store.lock().map_err(|e| format!("lock poisoned: {}", e))?;
store
.as_ref()
.ok_or_else(|| "Window settings store not initialized".to_string())?
.set_titlebar_mode(mode)
}
pub fn get_titlebar_mode(&self) -> Result<TitlebarMode, String> {
let store = self.store.lock().map_err(|e| format!("lock poisoned: {}", e))?;
let settings = store
.as_ref()
.ok_or_else(|| "Window settings store not initialized".to_string())?
.get_settings()?;
Ok(settings.titlebar_mode)
}
}
/// Check that window dimensions are within a sane range.
@@ -430,43 +306,3 @@ pub fn set_match_system_window_chrome(
);
state.set_match_system_window_chrome(value)
}
/// Returns the titlebar modes valid for the current platform/session.
/// `Plasma` is only included on KDE Plasma + Wayland — on other environments
/// the Xwayland trick is unnecessary or non-applicable.
pub fn available_titlebar_modes() -> Vec<TitlebarMode> {
let mut modes = vec![
TitlebarMode::Qbz,
TitlebarMode::System,
TitlebarMode::Hidden,
];
#[cfg(target_os = "linux")]
{
let kde = std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.split(':').any(|s| s.eq_ignore_ascii_case("KDE")))
.unwrap_or(false);
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
if kde && wayland {
modes.push(TitlebarMode::Plasma);
}
}
modes
}
/// Whether the current process is running inside a Linux sandbox
/// (Flatpak or Snap). Used by the frontend to display a sandbox-caveat
/// hint when the user selects Plasma mode in those builds.
pub fn is_sandboxed() -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new("/.flatpak-info").exists()
|| std::env::var_os("SNAP").is_some()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
+9 -18
View File
@@ -542,15 +542,10 @@ fn should_use_main_window_transparency() -> bool {
// Opt-in via persisted settings: "match system window chrome" wants
// rounded corners, which need a transparent window so the webview
// background stops painting white outside the radius. Only honor this
// when the titlebar mode is Qbz — in System/Plasma/Hidden modes the
// chrome is owned by the platform compositor or a separate widget,
// and a transparent window leaks through KDE/system chrome.
// background stops painting white outside the radius.
if let Ok(store) = config::window_settings::WindowSettingsStore::new() {
if let Ok(settings) = store.get_settings() {
if settings.titlebar_mode == config::window_settings::TitlebarMode::Qbz
&& settings.match_system_window_chrome
{
if settings.match_system_window_chrome {
return true;
}
}
@@ -711,15 +706,14 @@ pub fn run(qconnect_cli_override: Option<bool>) {
let window_settings = config::window_settings::WindowSettingsStore::new()
.and_then(|store| store.get_settings())
.unwrap_or_default();
let titlebar_mode = window_settings.titlebar_mode;
log::info!(
"Window settings: titlebar_mode={:?}, decorations={}, size={}x{}, maximized={}",
titlebar_mode,
titlebar_mode.wants_decorations(),
"Window settings: use_system_titlebar={}, size={}x{}, maximized={}",
window_settings.use_system_titlebar,
window_settings.window_width as u32,
window_settings.window_height as u32,
window_settings.is_maximized,
);
let use_system_titlebar = window_settings.use_system_titlebar;
let mut saved_win_width = window_settings.window_width;
let mut saved_win_height = window_settings.window_height;
let saved_win_maximized = window_settings.is_maximized;
@@ -950,9 +944,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
.manage(user_data_paths)
.setup(move |app| {
log::info!(
"Creating main window (mode={:?}, decorations={})",
titlebar_mode,
titlebar_mode.wants_decorations()
"Creating main window (decorations={})",
use_system_titlebar
);
let main_window_transparent = should_use_main_window_transparency();
MAIN_WINDOW_TRANSPARENT.store(main_window_transparent, std::sync::atomic::Ordering::SeqCst);
@@ -1008,7 +1001,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
.title("QBZ")
.inner_size(saved_win_width, saved_win_height)
.min_inner_size(800.0, 600.0)
.decorations(if cfg!(target_os = "macos") { true } else { titlebar_mode.wants_decorations() })
.decorations(if cfg!(target_os = "macos") { true } else { use_system_titlebar })
.transparent(main_window_transparent)
.resizable(true)
.zoom_hotkeys_enabled(true);
@@ -1569,9 +1562,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
updates::has_snap_welcome_been_shown,
config::favorites_cache::is_track_favorite,
commands_v2::v2_set_api_locale,
commands_v2::v2_get_titlebar_mode,
commands_v2::v2_set_titlebar_mode,
commands_v2::v2_available_titlebar_modes,
commands_v2::v2_set_use_system_titlebar,
commands_v2::v2_set_match_system_window_chrome,
commands_v2::v2_main_window_is_transparent,
commands_v2::v2_set_enable_tray,
+1 -13
View File
@@ -321,13 +321,6 @@ fn main() {
let force_dmabuf = std::env::var("QBZ_FORCE_DMABUF").as_deref() == Ok("1");
let disable_dmabuf = std::env::var("QBZ_DISABLE_DMABUF").as_deref() == Ok("1");
// Read titlebar mode from window settings store (lightweight read-only).
let titlebar_wants_xwayland = qbz_nix_lib::config::window_settings::WindowSettingsStore::new_readonly()
.ok()
.and_then(|store| store.get_settings().ok())
.map(|s| s.titlebar_mode.wants_xwayland())
.unwrap_or(false);
// Force X11: persistent setting from DB, env var overrides (crash recovery)
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
let force_x11 = match std::env::var("QBZ_FORCE_X11").as_deref() {
@@ -394,13 +387,8 @@ fn main() {
}
// --- GDK backend selection ---
if (force_x11 || titlebar_wants_xwayland) && is_wayland {
if force_x11 && is_wayland {
qbz_nix_lib::logging::log_startup("[QBZ] Forcing X11 backend on Wayland session");
if titlebar_wants_xwayland && !force_x11 {
qbz_nix_lib::logging::log_startup(
"[QBZ] X11 backend forced by Plasma titlebar mode",
);
}
std::env::set_var("GDK_BACKEND", "x11");
// GDK_SCALE is integer-only and only meaningful on X11/XWayland
+114 -12
View File
@@ -13,10 +13,12 @@ use image::GenericImageView;
#[cfg(not(target_os = "linux"))]
use std::path::PathBuf;
#[cfg(not(target_os = "linux"))]
use std::sync::Mutex;
#[cfg(not(target_os = "linux"))]
use tauri::{
image::Image,
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
#[cfg(target_os = "linux")]
@@ -24,7 +26,56 @@ use tauri::Manager;
use tauri::AppHandle;
#[cfg(not(target_os = "linux"))]
const TRAY_ICON_PNG: &[u8] = include_bytes!("../icons/tray.png");
const TRAY_ICON_COLOR_PNG: &[u8] = include_bytes!("../icons/tray.png");
#[cfg(not(target_os = "linux"))]
const TRAY_ICON_MONO_WHITE_PNG: &[u8] = include_bytes!("../icons/tray-dark-64.png");
#[cfg(not(target_os = "linux"))]
const TRAY_ICON_MONO_BLACK_PNG: &[u8] = include_bytes!("../icons/tray-light-64.png");
#[cfg(not(target_os = "linux"))]
#[derive(Clone, Copy, Debug)]
enum IconVariant {
/// Black glyph — for light menu bars.
MonoBlack,
/// White glyph — for dark menu bars.
MonoWhite,
/// Full colour vinyl logo.
Color,
}
#[cfg(not(target_os = "linux"))]
pub struct NativeTrayHandle {
tray: Mutex<Option<TrayIcon>>,
}
#[cfg(not(target_os = "linux"))]
impl NativeTrayHandle {
fn empty() -> Self {
Self {
tray: Mutex::new(None),
}
}
fn install(&self, tray: TrayIcon) {
if let Ok(mut guard) = self.tray.lock() {
*guard = Some(tray);
}
}
pub fn set_icon_theme(&self, theme: String) {
let tray = match self.tray.lock() {
Ok(guard) => guard.as_ref().cloned(),
Err(_) => None,
};
let Some(tray) = tray else {
return;
};
if let Err(e) = tray.set_icon(Some(load_tray_icon(Some(&theme)))) {
log::error!("[tray] failed to set macOS tray icon theme '{}': {}", theme, e);
}
}
}
/// Ensure tray icon is available in the user's icon theme directory.
/// This makes the icon discoverable by libayatana-appindicator via
@@ -49,7 +100,7 @@ fn ensure_tray_icon_in_theme() {
if std::fs::create_dir_all(&icon_dir).is_ok() {
let icon_path = icon_dir.join("com.blitzfc.qbz.png");
if !icon_path.exists() {
if let Err(e) = std::fs::write(&icon_path, TRAY_ICON_PNG) {
if let Err(e) = std::fs::write(&icon_path, TRAY_ICON_COLOR_PNG) {
log::warn!("Failed to write tray icon to theme dir: {}", e);
} else {
log::info!("Installed tray icon to {:?}", icon_path);
@@ -66,9 +117,46 @@ fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Get the tray icon - loads from file in Flatpak, embedded data otherwise
/// Detect whether the macOS menu bar is currently dark. We key off the global
/// Apple interface style so `auto` can mirror the Linux tray theme behavior:
/// white glyph on dark chrome, black glyph on light chrome.
#[cfg(target_os = "macos")]
fn prefer_dark_tray() -> bool {
if let Ok(out) = std::process::Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
{
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim() == "Dark";
}
}
false
}
#[cfg(all(not(target_os = "linux"), not(target_os = "macos")))]
fn prefer_dark_tray() -> bool {
false
}
#[cfg(not(target_os = "linux"))]
fn load_tray_icon() -> Image<'static> {
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
}
}
}
}
/// Get the tray icon - loads from file in Flatpak, embedded data otherwise.
#[cfg(not(target_os = "linux"))]
fn load_tray_icon(theme_override: Option<&str>) -> Image<'static> {
// In Flatpak, try to use the installed icon file first
// This works better with StatusNotifierItem/libayatana-appindicator
if is_flatpak() {
@@ -87,7 +175,12 @@ fn load_tray_icon() -> Image<'static> {
}
// Default: decode embedded PNG
let img = image::load_from_memory(TRAY_ICON_PNG).expect("Failed to decode tray icon PNG");
let icon_bytes = match resolve_variant(theme_override) {
IconVariant::MonoBlack => TRAY_ICON_MONO_BLACK_PNG,
IconVariant::MonoWhite => TRAY_ICON_MONO_WHITE_PNG,
IconVariant::Color => TRAY_ICON_COLOR_PNG,
};
let img = image::load_from_memory(icon_bytes).expect("Failed to decode tray icon PNG");
let (width, height) = img.dimensions();
let rgba = img.into_rgba8().into_raw();
Image::new_owned(rgba, width, height)
@@ -100,7 +193,7 @@ fn load_tray_icon() -> Image<'static> {
/// On Linux this also installs the live `LinuxTrayHandle` into Tauri state
/// so the rest of the backend can push live tooltip updates as the player
/// state changes. `theme_override` is the persisted user preference for
/// the icon variant ("auto"/"light"/"dark"); ignored on macOS.
/// the icon variant.
pub fn init_tray(
app: &AppHandle,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
@@ -114,13 +207,20 @@ pub fn init_tray(
}
#[cfg(not(target_os = "linux"))]
init_tray_tauri(app)
{
let handle = init_tray_tauri(app, theme_override)?;
app.manage(handle);
Ok(())
}
}
/// Tauri-backed tray implementation used on macOS. Kept as a separate fn so
/// the Linux path doesn't pay to compile the Tauri tray at all.
#[cfg(not(target_os = "linux"))]
fn init_tray_tauri(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
fn init_tray_tauri(
app: &AppHandle,
theme_override: Option<&str>,
) -> Result<NativeTrayHandle, Box<dyn std::error::Error>> {
log::info!("Initializing system tray icon (Tauri backend)");
// Create menu items
@@ -150,7 +250,7 @@ fn init_tray_tauri(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
ensure_tray_icon_in_theme();
// Load custom tray icon (with transparent background)
let tray_icon = load_tray_icon();
let tray_icon = load_tray_icon(theme_override);
// Build and display tray icon
let mut builder = TrayIconBuilder::new()
@@ -177,7 +277,7 @@ fn init_tray_tauri(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
}
}
let _tray = builder
let tray = builder
.on_menu_event(|app, event| {
let id = event.id.as_ref();
log::info!("Tray menu event: {}", id);
@@ -266,5 +366,7 @@ fn init_tray_tauri(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
.build(app)?;
log::info!("System tray icon initialized");
Ok(())
let live = NativeTrayHandle::empty();
live.install(tray);
Ok(live)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "QBZ",
"version": "1.2.11",
"version": "1.2.12",
"identifier": "com.blitzfc.qbz",
"build": {
"beforeDevCommand": "npm run dev",
+2 -3
View File
@@ -2515,10 +2515,9 @@
height: calc(100vh - 104px); /* Only 104px NowPlayingBar, no title bar */
}
/* macOS hidden mode: pad sidebar top so search clears the traffic lights
that the OS still draws via TitleBarStyle::Overlay. */
/* macOS: pad top of sidebar to clear native traffic light buttons */
:global(html.macos) .sidebar.no-titlebar {
padding-top: 36px;
padding-top: 32px;
}
.content {
+11 -50
View File
@@ -4,14 +4,8 @@
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { platform } from '$lib/utils/platform';
import type { ButtonColorSet } from '$lib/stores/windowControlsStore';
import type { Snippet } from 'svelte';
import {
getTitleBarVariant,
getEffectiveShowWindowControls,
subscribe as subscribeTitleBar,
} from '$lib/stores/titleBarStore';
interface TraySettings {
enable_tray: boolean;
@@ -56,17 +50,6 @@
let appWindow: ReturnType<typeof getCurrentWindow>;
let searchInputEl = $state<HTMLInputElement | null>(null);
let variant = $state<'full' | 'stripped'>(getTitleBarVariant());
let effectiveShowControls = $state(getEffectiveShowWindowControls());
$effect(() => {
const unsub = subscribeTitleBar(() => {
variant = getTitleBarVariant();
effectiveShowControls = getEffectiveShowWindowControls();
});
return unsub;
});
onMount(() => {
let unlisten: (() => void) | undefined;
@@ -238,20 +221,16 @@
class:has-search={searchInTitlebar}
class:has-nav={!!navSnippet}
class:controls-left={controlsPosition === 'left'}
class:variant-full={variant === 'full'}
class:variant-stripped={variant === 'stripped'}
class:macos={platform === 'macos'}
{...variant === 'full' ? { 'data-tauri-drag-region': '' } : {}}
>
<!-- Left zone -->
<div class="zone zone-left">
{#if effectiveShowControls && controlsPosition === 'left'}
{#if showWindowControls && controlsPosition === 'left'}
{@render windowControls('left')}
{/if}
{#if navSnippet && navPosition === 'left'}
{@render navSnippet()}
{/if}
<div class="drag-region" {...variant === 'full' ? { 'data-tauri-drag-region': '' } : {}}></div>
<div class="drag-region" data-tauri-drag-region></div>
</div>
<!-- Center zone (search, always centered) -->
@@ -285,17 +264,17 @@
{/if}
</div>
{:else}
<div class="drag-region" {...variant === 'full' ? { 'data-tauri-drag-region': '' } : {}}></div>
<div class="drag-region" data-tauri-drag-region></div>
{/if}
</div>
<!-- Right zone -->
<div class="zone zone-right">
<div class="drag-region" {...variant === 'full' ? { 'data-tauri-drag-region': '' } : {}}></div>
<div class="drag-region" data-tauri-drag-region></div>
{#if navSnippet && navPosition === 'right'}
{@render navSnippet()}
{/if}
{#if effectiveShowControls && controlsPosition === 'right'}
{#if showWindowControls && controlsPosition === 'right'}
{@render windowControls('right')}
{/if}
</div>
@@ -312,30 +291,7 @@
user-select: none;
-webkit-user-select: none;
-webkit-app-region: drag;
}
/* Full variant: keeps the default chrome (drag, dblclick, controls). */
.titlebar.variant-full {
height: 44px;
min-height: 44px;
}
/* Stripped variant: KWin SSD owns chrome. No drag, no dblclick, no controls,
no rounded chrome of our own — just the strip below the SSD with search/nav. */
.titlebar.variant-stripped {
height: 42px;
min-height: 42px;
border-radius: 0;
box-shadow: none;
-webkit-app-region: no-drag;
}
/* macOS: TitleBarStyle::Overlay paints the traffic lights at the top-left of
the window, on top of our content. Reserve ~84px on the left zone so the
lights don't collide with nav/search. The reserved area stays a drag
region (it's part of zone-left). */
.titlebar.macos .zone-left {
padding-left: 84px;
app-region: drag;
}
/* 3-zone layout: left and right zones are equal width, center is fixed */
@@ -383,6 +339,7 @@
transition: background-color 150ms ease, border-color 150ms ease;
flex-shrink: 0;
-webkit-app-region: no-drag;
app-region: no-drag;
color: var(--text-muted);
}
@@ -409,6 +366,7 @@
padding: 0;
min-width: 0;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.titlebar-search-input::placeholder {
@@ -430,6 +388,7 @@
flex-shrink: 0;
transition: background-color 150ms ease, color 150ms ease;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.titlebar-search-clear:hover {
@@ -444,6 +403,7 @@
align-items: stretch;
height: 100%;
-webkit-app-region: no-drag;
app-region: no-drag;
flex-shrink: 0;
}
@@ -492,6 +452,7 @@
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* --- Rectangular --- */
+81 -154
View File
@@ -99,11 +99,12 @@
} from '$lib/stores/consoleLogStore';
import {
subscribe as subscribeTitleBar,
getMode,
setMode,
getHideTitleBar,
setHideTitleBar,
getUseSystemTitleBar,
setUseSystemTitleBar,
getShowWindowControls,
setShowWindowControls,
type TitlebarMode
setShowWindowControls
} from '$lib/stores/titleBarStore';
import {
subscribe as subscribeWindowChrome,
@@ -1084,43 +1085,11 @@
let windowTitleTemplate = $state(DEFAULT_WINDOW_TITLE_TEMPLATE);
// Title bar settings
const initialTitlebarMode: TitlebarMode = getMode();
let titlebarMode = $state<TitlebarMode>(initialTitlebarMode);
// Fallback list before v2_available_titlebar_modes resolves. Always include
// the currently active mode so the dropdown shows the user's selected value
// even when the backend invoke fails.
let availableModes = $state<TitlebarMode[]>(
initialTitlebarMode === 'plasma' ? ['qbz', 'system', 'plasma', 'hidden'] : ['qbz', 'system', 'hidden']
);
let isSandboxed = $state(false);
let hideTitleBar = $state(getHideTitleBar());
let useSystemTitleBar = $state(getUseSystemTitleBar());
let matchSystemWindowChromeState = $state(getMatchSystemWindowChrome());
let windowControlsVisible = $state(getShowWindowControls());
$effect(() => {
invoke<{ modes: TitlebarMode[]; sandboxed: boolean }>('v2_available_titlebar_modes')
.then((result) => {
availableModes = result.modes;
isSandboxed = result.sandboxed;
})
.catch((e) => console.error('[SettingsView] available_titlebar_modes failed:', e));
});
// Per ADR-001: do NOT call $t() inside $derived(). Translate in markup.
function getModeLabel(m: TitlebarMode): string {
return $t(`settings.titlebar.mode.${m}`);
}
function getModeFromLabel(label: string): TitlebarMode | null {
const found = availableModes.find((m) => getModeLabel(m) === label);
return found ?? null;
}
function getDisabledHint(setting: 'windowControls' | 'matchSystemChrome' | 'searchLocation' | 'titlebarNav'): string {
return $t(`settings.titlebar.disabledHint.${setting}`);
}
// Applicability helpers (orthogonal toggles)
const isQbzMode = $derived(titlebarMode === 'qbz');
const supportsTitlebarFeatures = $derived(titlebarMode !== 'hidden');
// Desktop theme detection (Plasma / Klassy → adaptive preset visibility).
// `null` until the first detect call returns. `isKlassy=true` means a
// genuine Klassy install was detected; `desktop` starting with "plasma"
@@ -1817,7 +1786,8 @@
// Subscribe to title bar state changes
const unsubscribeTitleBar = subscribeTitleBar(() => {
titlebarMode = getMode();
hideTitleBar = getHideTitleBar();
useSystemTitleBar = getUseSystemTitleBar();
windowControlsVisible = getShowWindowControls();
});
const unsubscribeWindowChrome = subscribeWindowChrome(() => {
@@ -3552,7 +3522,9 @@
try {
await invoke('v2_set_enable_tray', { value });
enableTray = value;
showToast($t('settings.appearance.tray.enableTrayDesc'), 'info');
showToast($t(platform === 'macos'
? 'settings.appearance.tray.enableTrayDescMacos'
: 'settings.appearance.tray.enableTrayDesc'), 'info');
} catch (err) {
console.error('Failed to set enable tray:', err);
showToast($t('toast.failedSaveTray'), 'error');
@@ -4836,70 +4808,49 @@
/>
</div>
{/if}
<!-- macOS: only the 'qbz' ↔ 'hidden' axis is meaningful. 'system' /
'plasma' describe Linux WM behaviors that don't apply on Darwin,
so we expose a single Toggle instead of the full mode dropdown. -->
{#if platform === 'macos'}
<!-- Title bar toggles: hidden on macOS (always uses native overlay title bar) -->
{#if platform !== 'macos'}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.useSystemTitleBar')}</span>
<span class="setting-desc">{$t('settings.appearance.useSystemTitleBarDesc')}</span>
</div>
<Toggle enabled={useSystemTitleBar} onchange={(v) => setUseSystemTitleBar(v)} />
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.hideTitleBar')}</span>
<span class="setting-desc">{$t('settings.appearance.hideTitleBarDesc')}</span>
</div>
<Toggle
enabled={titlebarMode === 'hidden'}
onchange={(v) => setMode(v ? 'hidden' : 'qbz')}
/>
<Toggle enabled={hideTitleBar} onchange={(v) => setHideTitleBar(v)} disabled={useSystemTitleBar} />
</div>
{/if}
{#if platform !== 'macos'}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.titlebar.mode.label')}</span>
</div>
<Dropdown
value={getModeLabel(titlebarMode)}
options={availableModes.map((m) => getModeLabel(m))}
onchange={(label) => {
const next = getModeFromLabel(label);
if (next) setMode(next);
}}
/>
</div>
{#if titlebarMode === 'plasma' && isSandboxed}
<p class="hint">{$t('settings.titlebar.sandboxCaveat')}</p>
{/if}
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.matchSystemChrome')}</span>
<span class="setting-desc">{$t('settings.appearance.matchSystemChromeDesc')}</span>
</div>
<span title={!isQbzMode ? getDisabledHint('matchSystemChrome') : ''}>
<Toggle
enabled={matchSystemWindowChromeState}
onchange={(v) => {
setMatchSystemWindowChrome(v);
showToast($t('settings.appearance.matchSystemChromeRestart'), 'info');
}}
disabled={!isQbzMode}
/>
</span>
<Toggle
enabled={matchSystemWindowChromeState}
onchange={(v) => {
setMatchSystemWindowChrome(v);
showToast($t('settings.appearance.matchSystemChromeRestart'), 'info');
}}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
{/if}
<!-- Search-in-titlebar and nav-in-titlebar: now available on macOS too,
since the qbz strip mounts there and can host them next to the
native traffic lights. -->
<div class="setting-row" class:disabled-section={!supportsTitlebarFeatures}>
<!-- Title bar customization: hidden on macOS (uses native overlay title bar) -->
{#if platform !== 'macos'}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.searchInTitleBar')}</span>
<span class="setting-desc">{$t('settings.appearance.searchInTitleBarDesc')}</span>
</div>
<span title={!supportsTitlebarFeatures ? getDisabledHint('searchLocation') : ''}>
<Toggle
enabled={searchInTitlebar}
onchange={(v) => setSearchBarLocation(v ? 'titlebar' : 'sidebar')}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={searchInTitlebar}
onchange={(v) => setSearchBarLocation(v ? 'titlebar' : 'sidebar')}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
<div class="setting-row">
<div class="setting-info">
@@ -4907,57 +4858,47 @@
<span class="setting-desc">{$t('settings.appearance.navInTitleBarDesc')}</span>
</div>
</div>
<div class="setting-row indented-setting" class:disabled-section={!supportsTitlebarFeatures}>
<div class="setting-row indented-setting">
<span class="setting-label">{$t('nav.home')}</span>
<span title={!supportsTitlebarFeatures ? getDisabledHint('titlebarNav') : ''}>
<Toggle
enabled={tbNavConfig.discover}
onchange={(v) => setDiscoverInTitlebar(v)}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={tbNavConfig.discover}
onchange={(v) => setDiscoverInTitlebar(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
<div class="setting-row indented-setting" class:disabled-section={!supportsTitlebarFeatures}>
<div class="setting-row indented-setting">
<span class="setting-label">{$t('nav.favorites')}</span>
<span title={!supportsTitlebarFeatures ? getDisabledHint('titlebarNav') : ''}>
<Toggle
enabled={tbNavConfig.favorites}
onchange={(v) => setFavoritesInTitlebar(v)}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={tbNavConfig.favorites}
onchange={(v) => setFavoritesInTitlebar(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
<div class="setting-row indented-setting" class:disabled-section={!supportsTitlebarFeatures}>
<div class="setting-row indented-setting">
<span class="setting-label">{$t('library.title')}</span>
<span title={!supportsTitlebarFeatures ? getDisabledHint('titlebarNav') : ''}>
<Toggle
enabled={tbNavConfig.library}
onchange={(v) => setLibraryInTitlebar(v)}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={tbNavConfig.library}
onchange={(v) => setLibraryInTitlebar(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
<div class="setting-row indented-setting" class:disabled-section={!supportsTitlebarFeatures}>
<div class="setting-row indented-setting">
<span class="setting-label">{$t('nav.myQbz')}</span>
<span title={!supportsTitlebarFeatures ? getDisabledHint('titlebarNav') : ''}>
<Toggle
enabled={tbNavConfig.myQbz}
onchange={(v) => setMyQbzInTitlebar(v)}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={tbNavConfig.myQbz}
onchange={(v) => setMyQbzInTitlebar(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
<div class="setting-row indented-setting" class:disabled-section={!supportsTitlebarFeatures}>
<div class="setting-row indented-setting">
<span class="setting-label">{$t('nav.purchases')}</span>
<span title={!supportsTitlebarFeatures ? getDisabledHint('titlebarNav') : ''}>
<Toggle
enabled={tbNavConfig.purchases}
onchange={(v) => setPurchasesInTitlebar(v)}
disabled={!supportsTitlebarFeatures}
/>
</span>
<Toggle
enabled={tbNavConfig.purchases}
onchange={(v) => setPurchasesInTitlebar(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
{#if titlebarNavAnyEnabled && supportsTitlebarFeatures}
{#if titlebarNavAnyEnabled && !hideTitleBar && !useSystemTitleBar}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.navInTitleBarPosition')}</span>
@@ -4980,11 +4921,7 @@
/>
</div>
{/if}
<!-- Window-controls customization: still Linux-only. macOS draws native
traffic lights via TitleBarStyle::Overlay, so position/style/size/
color settings would have no effect. -->
{#if platform !== 'macos'}
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-row" class:disabled-section={hideTitleBar || useSystemTitleBar}>
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.windowControlsPosition')}</span>
<span class="setting-desc">{$t('settings.appearance.windowControlsPositionDesc')}</span>
@@ -4995,7 +4932,7 @@
onchange={handleWcPositionChange}
/>
</div>
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-row" class:disabled-section={hideTitleBar || useSystemTitleBar}>
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.windowControlsStyle')}</span>
<span class="setting-desc">{$t('settings.appearance.windowControlsStyleDesc')}</span>
@@ -5006,7 +4943,7 @@
onchange={handleWcShapeChange}
/>
</div>
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-row" class:disabled-section={hideTitleBar || useSystemTitleBar}>
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.windowControlsSize')}</span>
<span class="setting-desc">{$t('settings.appearance.windowControlsSizeDesc')}</span>
@@ -5017,7 +4954,7 @@
onchange={handleWcSizeChange}
/>
</div>
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-row" class:disabled-section={hideTitleBar || useSystemTitleBar}>
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.windowControlsColorPreset')}</span>
<span class="setting-desc">{$t('settings.appearance.windowControlsColorPresetDesc')}</span>
@@ -5029,7 +4966,7 @@
/>
</div>
{#if wcConfig.preset === 'custom'}
<div class="wc-custom-panel" class:disabled-section={!isQbzMode}>
<div class="wc-custom-panel" class:disabled-section={hideTitleBar || useSystemTitleBar}>
<span class="wc-custom-panel-title">{$t('settings.appearance.windowControlsCustomColors')}</span>
{#each WC_BUTTONS as btn}
<div class="wc-color-group">
@@ -5057,18 +4994,16 @@
{/each}
</div>
{/if}
<div class="setting-row" class:disabled-section={!isQbzMode}>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.showWindowControls')}</span>
<span class="setting-desc">{$t('settings.appearance.showWindowControlsDesc')}</span>
</div>
<span title={!isQbzMode ? getDisabledHint('windowControls') : ''}>
<Toggle
enabled={windowControlsVisible}
onchange={(v) => setShowWindowControls(v)}
disabled={!isQbzMode}
/>
</span>
<Toggle
enabled={windowControlsVisible}
onchange={(v) => setShowWindowControls(v)}
disabled={hideTitleBar || useSystemTitleBar}
/>
</div>
{/if}
<div class="setting-row">
@@ -5123,6 +5058,7 @@
</div>
<Toggle enabled={closeToTray} onchange={(v) => handleCloseToTrayChange(v)} disabled={!enableTray} />
</div>
{/if}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.tray.iconTheme.label')}</span>
@@ -5134,7 +5070,6 @@
onchange={handleTrayIconThemeChange}
/>
</div>
{/if}
<!-- Immersive subsection -->
<h4 class="subsection-title">{$t('settings.appearance.immersive.title')}</h4>
@@ -7951,14 +7886,6 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
pointer-events: none;
}
/* Inline hint paragraph (e.g. sandbox caveat under titlebar mode dropdown) */
.hint {
margin: 0 0 12px 0;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
}
/* Window Controls Custom Colors Panel */
.wc-custom-panel {
margin: 0 0 8px 0;
-20
View File
@@ -2456,26 +2456,6 @@
"queue": "Warteschlange"
}
},
"titlebar": {
"label": "Fenstertitelleiste",
"mode": {
"label": "Titelleisten-Modus",
"qbz": "QBZ-Titelleiste (Standard)",
"system": "System-Titelleiste",
"plasma": "Plasma-Titelleiste (KDE Wayland)",
"hidden": "Keine Titelleiste (Tiling WM)"
},
"showWindowControls": "Fensterschaltflächen anzeigen (Min/Max/Schließen)",
"matchSystemChrome": "Systemfensterrahmen anpassen",
"searchLocation": "Suchposition",
"disabledHint": {
"windowControls": "Gilt nur, wenn die QBZ-Titelleiste aktiv ist. Die System- und Plasma-Modi deaktivieren dies, da das Betriebssystem dies bereitstellt.",
"matchSystemChrome": "Gilt nur, wenn die QBZ-Titelleiste aktiv ist. Die System- und Plasma-Modi deaktivieren dies, da das Betriebssystem den Rahmen bereitstellt.",
"searchLocation": "Die Suchposition gilt nicht, wenn keine Titelleiste angezeigt wird.",
"titlebarNav": "Die Titelleisten-Navigation gilt nicht, wenn keine Titelleiste angezeigt wird."
},
"sandboxCaveat": "Der Plasma-Modus ist in Sandbox-Builds (Flatpak/Snap) nicht verifiziert. Wenn der System-Rahmen nicht erscheint, wechseln Sie zum System-Modus."
},
"library": {
"title": "Bibliothek",
"fetchArtistImages": "Qobuz™-Künstlerbilder abrufen",
-20
View File
@@ -2462,26 +2462,6 @@
}
}
},
"titlebar": {
"label": "Window titlebar",
"mode": {
"label": "Titlebar mode",
"qbz": "QBZ titlebar (default)",
"system": "System titlebar",
"plasma": "Plasma titlebar (KDE Wayland)",
"hidden": "No titlebar (tiling WM)"
},
"showWindowControls": "Show window controls (min/max/close)",
"matchSystemChrome": "Match system window chrome",
"searchLocation": "Search location",
"disabledHint": {
"windowControls": "Only applies when QBZ titlebar is active. System and Plasma modes disable this because the OS provides it.",
"matchSystemChrome": "Only applies when QBZ titlebar is active. System and Plasma modes disable this because the OS provides chrome.",
"searchLocation": "Search location does not apply when no titlebar is shown.",
"titlebarNav": "Titlebar navigation does not apply when no titlebar is shown."
},
"sandboxCaveat": "Plasma mode is unverified in sandboxed builds (Flatpak/Snap). If the system chrome doesn't appear, switch to System mode."
},
"library": {
"title": "Library",
"fetchArtistImages": "Fetch Qobuz™ Artist Images",
-20
View File
@@ -2462,26 +2462,6 @@
}
}
},
"titlebar": {
"label": "Barra de título",
"mode": {
"label": "Modo de barra de título",
"qbz": "Barra QBZ (predeterminada)",
"system": "Barra del sistema",
"plasma": "Barra Plasma (KDE Wayland)",
"hidden": "Sin barra (gestores tiling)"
},
"showWindowControls": "Mostrar controles de ventana (min/max/cerrar)",
"matchSystemChrome": "Coincidir con el cromado del sistema",
"searchLocation": "Ubicación de búsqueda",
"disabledHint": {
"windowControls": "Solo aplica cuando la barra QBZ está activa. Los modos Sistema y Plasma lo desactivan porque el OS lo provee.",
"matchSystemChrome": "Solo aplica cuando la barra QBZ está activa. Los modos Sistema y Plasma lo desactivan porque el OS provee la decoración.",
"searchLocation": "La ubicación de búsqueda no aplica cuando no se muestra ninguna barra.",
"titlebarNav": "La navegación de la barra no aplica cuando no se muestra ninguna barra."
},
"sandboxCaveat": "El modo Plasma no está verificado en builds sandboxed (Flatpak/Snap). Si no aparece el cromado del sistema, cambia a modo Sistema."
},
"library": {
"title": "Biblioteca",
"fetchArtistImages": "Obtener imágenes de artistas de Qobuz™",
-20
View File
@@ -2456,26 +2456,6 @@
}
}
},
"titlebar": {
"label": "Barre de titre",
"mode": {
"label": "Mode de barre de titre",
"qbz": "Barre QBZ (par défaut)",
"system": "Barre système",
"plasma": "Barre Plasma (KDE Wayland)",
"hidden": "Aucune barre (WM en mosaïque)"
},
"showWindowControls": "Afficher les contrôles de fenêtre (min/max/fermer)",
"matchSystemChrome": "Aligner sur le chrome du système",
"searchLocation": "Emplacement de la recherche",
"disabledHint": {
"windowControls": "Ne s'applique qu'avec la barre QBZ active. Les modes Système et Plasma désactivent ceci car le système d'exploitation le fournit.",
"matchSystemChrome": "Ne s'applique qu'avec la barre QBZ active. Les modes Système et Plasma désactivent ceci car le système d'exploitation fournit le chrome.",
"searchLocation": "L'emplacement de la recherche ne s'applique pas lorsqu'aucune barre n'est affichée.",
"titlebarNav": "La navigation de la barre ne s'applique pas lorsqu'aucune barre n'est affichée."
},
"sandboxCaveat": "Le mode Plasma n'est pas vérifié sur les builds sandboxés (Flatpak/Snap). Si le chrome système n'apparaît pas, basculez vers le mode Système."
},
"library": {
"title": "Bibliothèque",
"fetchArtistImages": "Récupérer les images d'artistes Qobuz™",
-20
View File
@@ -2456,26 +2456,6 @@
}
}
},
"titlebar": {
"label": "Barra de título",
"mode": {
"label": "Modo da barra de título",
"qbz": "Barra QBZ (padrão)",
"system": "Barra do sistema",
"plasma": "Barra Plasma (KDE Wayland)",
"hidden": "Sem barra (WM em mosaico)"
},
"showWindowControls": "Mostrar controles de janela (min/máx/fechar)",
"matchSystemChrome": "Combinar com o cromado do sistema",
"searchLocation": "Localização da pesquisa",
"disabledHint": {
"windowControls": "Apenas se aplica quando a barra QBZ está ativa. Os modos Sistema e Plasma desativam isto porque o SO o fornece.",
"matchSystemChrome": "Apenas se aplica quando a barra QBZ está ativa. Os modos Sistema e Plasma desativam isto porque o SO fornece o cromado.",
"searchLocation": "A localização da pesquisa não se aplica quando nenhuma barra é mostrada.",
"titlebarNav": "A navegação da barra não se aplica quando nenhuma barra é mostrada."
},
"sandboxCaveat": "O modo Plasma não está verificado em builds sandboxed (Flatpak/Snap). Se o cromado do sistema não aparecer, mude para o modo Sistema."
},
"library": {
"title": "Biblioteca",
"fetchArtistImages": "Buscar Imagens de Artistas do Qobuz™",
+2 -5
View File
@@ -17,7 +17,7 @@ import {
setMiniPlayerSurface,
type MiniPlayerSurface
} from '$lib/stores/uiStore';
import { getMode } from '$lib/stores/titleBarStore';
import { getUseSystemTitleBar } from '$lib/stores/titleBarStore';
import { getUserItem } from '$lib/utils/userStorage';
const MINI_PLAYER_LABEL = 'miniplayer';
@@ -93,10 +93,7 @@ async function getOrCreateMiniPlayerWindow(): Promise<Window> {
minWidth: 340,
minHeight: 57,
resizable: true,
decorations: (() => {
const mode = getMode();
return mode === 'system' || mode === 'plasma';
})(),
decorations: getUseSystemTitleBar(),
alwaysOnTop: miniState.alwaysOnTop,
transparent: true,
focus: true
-14
View File
@@ -74,17 +74,3 @@ export function setSearchBarLocation(value: SearchBarLocation): void {
export function toggleSearchBarLocation(): void {
setSearchBarLocation(location === 'sidebar' ? 'titlebar' : 'sidebar');
}
import { getMode } from './titleBarStore';
/**
* Effective search-bar location after applying mode rules.
* Only 'hidden' forces sidebar; qbz/plasma/system all honor user pref.
* In system mode the stripped strip mounts when the user wants
* search-in-titlebar (same content rule as plasma).
*/
export function getEffectiveSearchBarLocation(): SearchBarLocation {
const mode = getMode();
if (mode === 'hidden') return 'sidebar';
return getSearchBarLocation();
}
+178 -211
View File
@@ -1,211 +1,178 @@
/**
* Title Bar Settings Store
*
* Manages titlebar mode (single 4-value enum) plus the orthogonal
* `showWindowControls` preference.
*
* Modes:
* - 'qbz': default — full custom titlebar with drag/resize/dblclick/controls
* - 'system': OS-native chrome
* - 'plasma': KDE-only, KWin SSD via Xwayland + stripped strip
* - 'hidden': no titlebar (tiling WMs)
*/
import { invoke } from '@tauri-apps/api/core';
import { skipIfRemote } from '$lib/services/commandRouter';
import { platform } from '$lib/utils/platform';
import { getSearchBarLocation } from './searchBarLocationStore';
import { isTitlebarNavEnabled } from './titlebarNavStore';
export type TitlebarMode = 'qbz' | 'system' | 'plasma' | 'hidden';
const STORAGE_KEY_MODE = 'qbz-titlebar-mode';
const STORAGE_KEY_WINDOW_CONTROLS = 'qbz-show-window-controls';
const LEGACY_KEY_HIDE = 'qbz-hide-titlebar';
const LEGACY_KEY_SYSTEM = 'qbz-use-system-titlebar';
let mode: TitlebarMode = 'qbz';
let showWindowControls = true;
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
function migrateLegacyKeys(): TitlebarMode | null {
// Returns the migrated mode, or null if no migration was needed.
const hide = localStorage.getItem(LEGACY_KEY_HIDE);
const system = localStorage.getItem(LEGACY_KEY_SYSTEM);
if (hide === null && system === null) return null;
let migrated: TitlebarMode = 'qbz';
if (hide === 'true') {
migrated = 'hidden';
} else if (system === 'true') {
migrated = 'system';
}
return migrated;
}
export function initTitleBarStore(): void {
if (skipIfRemote()) return;
try {
const savedMode = localStorage.getItem(STORAGE_KEY_MODE);
if (savedMode === 'qbz' || savedMode === 'system' || savedMode === 'plasma' || savedMode === 'hidden') {
mode = savedMode;
} else {
// No persisted mode — try migrating from legacy keys.
const migrated = migrateLegacyKeys();
if (migrated !== null) {
mode = migrated;
localStorage.setItem(STORAGE_KEY_MODE, mode);
// Push to backend so next launch reads the correct mode before window creation.
invoke('v2_set_titlebar_mode', { mode }).catch((e) => {
console.error('[TitleBarStore] Failed to persist migrated mode to backend:', e);
});
}
}
const savedControls = localStorage.getItem(STORAGE_KEY_WINDOW_CONTROLS);
if (savedControls !== null) {
showWindowControls = savedControls !== 'false';
}
} catch (e) {
console.error('[TitleBarStore] Failed to initialize:', e);
}
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
listener();
return () => listeners.delete(listener);
}
export function getMode(): TitlebarMode {
return mode;
}
/**
* Whether the custom TitleBar.svelte component should mount.
* - 'qbz' → true (full variant). macOS always falls here: mode selection
* is hidden in settings on macOS, so it stays at the 'qbz' default and
* the strip mounts with native traffic lights overlaid on its left zone
* (see TitleBar.svelte's `.titlebar.macos` rule).
* - 'plasma' or 'system' → true ONLY if the stripped strip would carry
* content (search-in-titlebar OR at least one nav item). Both render
* the strip below their respective OS chrome (KWin SSD via Xwayland
* for plasma, GTK CSD / WM SSD for system).
* - 'hidden' → false.
*/
export function shouldShowTitleBar(): boolean {
if (mode === 'hidden') return false;
if (mode === 'qbz') return true;
// mode === 'plasma' or 'system' — both render the stripped strip below
// their respective chrome (KWin SSD or GTK CSD), but only if the strip
// would have effective content.
return getSearchBarLocation() === 'titlebar' || isTitlebarNavEnabled();
}
/**
* Variant for the custom TitleBar component when mounted.
* 'qbz' is the full variant; everything else (plasma/system) is the
* stripped strip rendered below OS chrome.
*/
export function getTitleBarVariant(): 'full' | 'stripped' {
return mode === 'qbz' ? 'full' : 'stripped';
}
export function getTitleBarHeight(): number {
if (!shouldShowTitleBar()) return 0;
return mode === 'qbz' ? 44 : 42;
}
export function getShowWindowControls(): boolean {
return showWindowControls;
}
export function setShowWindowControls(value: boolean): void {
showWindowControls = value;
try {
localStorage.setItem(STORAGE_KEY_WINDOW_CONTROLS, String(value));
} catch (e) {
console.error('[TitleBarStore] Failed to save window controls setting:', e);
}
notifyListeners();
}
/**
* Effective `showWindowControls` after applying mode rules.
* In 'plasma' mode, KWin draws controls in its SSD, so the custom
* controls are forced off regardless of the user pref.
*/
export function getEffectiveShowWindowControls(): boolean {
// macOS draws native traffic lights via TitleBarStyle::Overlay, so the
// custom controls would visually collide with them.
if (platform === 'macos') return false;
if (mode !== 'qbz') return false;
return showWindowControls;
}
/**
* Set the titlebar mode. Persists to localStorage and backend, then
* restarts the app when the mode change crosses the decoration boundary
* (mounting/unmounting decorations requires window re-creation).
*/
export async function setMode(value: TitlebarMode): Promise<void> {
if (skipIfRemote()) return;
const previousWantsDecorations =
mode === 'system' || mode === 'plasma';
const newWantsDecorations =
value === 'system' || value === 'plasma';
// Push to backend FIRST. If it fails, leave local state untouched.
try {
await invoke('v2_set_titlebar_mode', { mode: value });
} catch (e) {
console.error('[TitleBarStore] Failed to push mode to backend:', e);
return;
}
mode = value;
try {
localStorage.setItem(STORAGE_KEY_MODE, value);
} catch (e) {
console.error('[TitleBarStore] Failed to save mode setting:', e);
}
if (previousWantsDecorations !== newWantsDecorations) {
// Crossing the decoration boundary requires window re-creation.
try {
await invoke('v2_restart_app');
} catch (e) {
console.error('[TitleBarStore] Failed to restart app:', e);
}
} else {
notifyListeners();
}
}
export interface TitleBarState {
mode: TitlebarMode;
showTitleBar: boolean;
variant: 'full' | 'stripped';
showWindowControls: boolean;
effectiveShowWindowControls: boolean;
titleBarHeight: number;
}
export function getTitleBarState(): TitleBarState {
return {
mode,
showTitleBar: shouldShowTitleBar(),
variant: getTitleBarVariant(),
showWindowControls,
effectiveShowWindowControls: getEffectiveShowWindowControls(),
titleBarHeight: getTitleBarHeight()
};
}
/**
* Title Bar Settings Store
*
* Manages title bar visibility settings with localStorage persistence.
*
* Settings:
* - hideTitleBar: Remove title bar completely for tiling WM users (default: false)
* - useSystemTitleBar: Use OS native window decorations instead of custom title bar (default: false)
*/
import { invoke } from '@tauri-apps/api/core';
import { skipIfRemote } from '$lib/services/commandRouter';
import { platform } from '$lib/utils/platform';
const STORAGE_KEY_HIDE = 'qbz-hide-titlebar';
const STORAGE_KEY_SYSTEM = 'qbz-use-system-titlebar';
const STORAGE_KEY_WINDOW_CONTROLS = 'qbz-show-window-controls';
// State
let hideTitleBar = false;
let useSystemTitleBar = false;
let showWindowControls = true;
// Listeners
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
/**
* Initialize the store from localStorage
*/
export function initTitleBarStore(): void {
if (skipIfRemote()) return;
try {
const savedHide = localStorage.getItem(STORAGE_KEY_HIDE);
if (savedHide !== null) {
hideTitleBar = savedHide === 'true';
}
const savedSystem = localStorage.getItem(STORAGE_KEY_SYSTEM);
if (savedSystem !== null) {
useSystemTitleBar = savedSystem === 'true';
}
const savedControls = localStorage.getItem(STORAGE_KEY_WINDOW_CONTROLS);
if (savedControls !== null) {
showWindowControls = savedControls !== 'false';
}
// Sync localStorage value to Rust backend so it's available at next
// startup (before window creation). Handles migration from the
// localStorage-only era and keeps both stores in sync.
// Always sync, not just when true - otherwise false values never propagate.
invoke('v2_set_use_system_titlebar', { value: useSystemTitleBar }).catch((e) => {
console.error('[TitleBarStore] Failed to sync system titlebar to backend:', e);
});
} catch (e) {
console.error('[TitleBarStore] Failed to initialize:', e);
}
}
/**
* Subscribe to title bar state changes
*/
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
listener();
return () => listeners.delete(listener);
}
/**
* Get current hide setting
*/
export function getHideTitleBar(): boolean {
return hideTitleBar;
}
/**
* Get current system title bar setting
*/
export function getUseSystemTitleBar(): boolean {
return useSystemTitleBar;
}
/**
* Determine if the custom title bar should be visible
* Hidden when either system title bar is active or hide mode is on
*/
export function shouldShowTitleBar(): boolean {
// macOS always uses native decorations — no custom title bar
if (platform === 'macos') return false;
return !hideTitleBar && !useSystemTitleBar;
}
/**
* Get the title bar height for layout calculations
* Returns 0 if title bar is hidden or system title bar is active, 40 otherwise
*/
export function getTitleBarHeight(): number {
if (platform === 'macos') return 0;
return (hideTitleBar || useSystemTitleBar) ? 0 : 40;
}
/**
* Get whether to show window control buttons (minimize/maximize/close)
*/
export function getShowWindowControls(): boolean {
return showWindowControls;
}
/**
* Set whether to show window control buttons
*/
export function setShowWindowControls(value: boolean): void {
showWindowControls = value;
try {
localStorage.setItem(STORAGE_KEY_WINDOW_CONTROLS, String(value));
} catch (e) {
console.error('[TitleBarStore] Failed to save window controls setting:', e);
}
notifyListeners();
}
/**
* Set whether to hide title bar completely
*/
export function setHideTitleBar(value: boolean): void {
hideTitleBar = value;
try {
localStorage.setItem(STORAGE_KEY_HIDE, String(value));
} catch (e) {
console.error('[TitleBarStore] Failed to save hide titlebar setting:', e);
}
notifyListeners();
}
/**
* Set whether to use system (OS native) title bar.
* Persists to both localStorage and Rust backend, then restarts the app.
* Restart is required because the window must be created with the correct
* decoration state — runtime toggling doesn't work reliably on Linux/Wayland.
*/
export async function setUseSystemTitleBar(value: boolean): Promise<void> {
if (skipIfRemote()) return;
useSystemTitleBar = value;
try {
localStorage.setItem(STORAGE_KEY_SYSTEM, String(value));
} catch (e) {
console.error('[TitleBarStore] Failed to save system titlebar setting:', e);
}
try {
await invoke('v2_set_use_system_titlebar', { value });
await invoke('v2_restart_app');
} catch (e) {
console.error('[TitleBarStore] Failed to apply system titlebar change:', e);
}
}
export interface TitleBarState {
hideTitleBar: boolean;
useSystemTitleBar: boolean;
showTitleBar: boolean;
showWindowControls: boolean;
titleBarHeight: number;
}
export function getTitleBarState(): TitleBarState {
return {
hideTitleBar,
useSystemTitleBar,
showTitleBar: shouldShowTitleBar(),
showWindowControls,
titleBarHeight: getTitleBarHeight()
};
}
-13
View File
@@ -172,16 +172,3 @@ export function setTitlebarNavConfig(newConfig: Partial<TitlebarNavConfig>): voi
persist();
notifyListeners();
}
import { getMode } from './titleBarStore';
/**
* Whether the titlebar nav should render.
* Only 'hidden' is N/A; qbz/plasma/system all honor user pref.
* In system mode the stripped strip carries the nav (same as plasma).
*/
export function isTitlebarNavEffectivelyEnabled(): boolean {
const mode = getMode();
if (mode === 'hidden') return false;
return isTitlebarNavEnabled();
}
+11 -27
View File
@@ -123,7 +123,7 @@
import {
subscribe as subscribeSearchBarLocation,
initSearchBarLocation,
getEffectiveSearchBarLocation
getSearchBarLocation
} from '$lib/stores/searchBarLocationStore';
// Window controls customization store
@@ -551,8 +551,8 @@
let windowTransparent = $state(getWindowIsTransparent());
let showWindowControls = $state(getShowWindowControls());
// Search Bar Location State (effective: respects mode, falls back to sidebar in system/hidden)
let searchBarLocationPref = $state(getEffectiveSearchBarLocation());
// Search Bar Location State
let searchBarLocationPref = $state(getSearchBarLocation());
let titlebarSearchQuery = $state(getSearchQuery());
// Window Controls State
@@ -4840,9 +4840,6 @@
const unsubscribeTitleBar = subscribeTitleBar(() => {
showTitleBar = shouldShowTitleBar();
showWindowControls = getShowWindowControls();
// Recompute the effective search location: mode changes can flip
// search from titlebar to sidebar even when the user pref is unchanged.
searchBarLocationPref = getEffectiveSearchBarLocation();
applyChromeClass();
});
@@ -4877,11 +4874,7 @@
// Initialize and subscribe to search bar location
initSearchBarLocation();
const unsubscribeSearchBarLocation = subscribeSearchBarLocation(() => {
searchBarLocationPref = getEffectiveSearchBarLocation();
// In plasma mode the strip's visibility depends on whether search
// or any nav item is enabled, so a search-location toggle can flip
// the strip on/off.
showTitleBar = shouldShowTitleBar();
searchBarLocationPref = getSearchBarLocation();
});
// Initialize and subscribe to window controls customization
@@ -4902,10 +4895,6 @@
tbNavLibrary = isLibraryInTitlebar();
tbNavMyQbz = isMyQbzInTitlebar();
tbNavPurchases = isPurchasesInTitlebar();
// In plasma mode the strip's visibility depends on whether any nav
// item or search is enabled, so toggling the last nav item can
// flip the strip on/off.
showTitleBar = shouldShowTitleBar();
});
// Detect floating window state (not maximized) for rounded corners + shadow
@@ -5766,11 +5755,7 @@
class:match-chrome={matchSystemChrome && showTitleBar && windowTransparent}
style="--chrome-radius: {chromeRadiusPx}px;"
>
<!-- macOS: when the user hides the qbz strip (mode='hidden'), the OS
still draws traffic lights via TitleBarStyle::Overlay on top of
the sidebar. This invisible band gives the window something to
drag and reserves the same vertical real estate the strip would
have. -->
<!-- macOS: drag region for window movement (overlay title bar has no native drag area) -->
{#if !showTitleBar && platform === 'macos'}
<div class="macos-drag-region" data-tauri-drag-region></div>
{/if}
@@ -7215,21 +7200,20 @@
height: calc(100vh - var(--player-bar-height, 104px));
}
/* macOS hidden mode: pad main content to clear native overlay traffic
lights. Only fires when the qbz strip isn't mounted. */
:global(html.macos) .app.no-titlebar .main-content {
/* macOS: pad main content to clear native overlay title bar */
:global(html.macos) .main-content {
padding-top: 16px;
height: calc(100vh - 104px - 16px);
}
:global(html.macos) .app.no-titlebar .main-content :global(.home-view) {
/* macOS: home view handles its own spacing */
:global(html.macos) .main-content :global(.home-view) {
margin-top: -16px;
}
/* macOS hidden mode: invisible drag region for window movement, mirrors
the band the qbz strip would otherwise occupy. */
/* macOS: invisible drag region for window movement (overlay title bar) */
:global(html.macos) .macos-drag-region {
height: 32px;
height: 28px;
width: 100%;
position: absolute;
top: 0;