- Appearance > Play indicator animation (default OFF): static play glyph in an accent ring on the playing row, pause on hover, accent edge pill; the 8fps eq-bars clock no longer runs while playing with the pref off (CPU complaint on some machines). - Appearance > Invert swipe navigation direction for the two-finger touchpad back/forward gesture (#653 follow-up). km
1048 lines
44 KiB
Plaintext
1048 lines
44 KiB
Plaintext
// Appearance settings panel — theme, typography, language, window chrome,
|
||
// notifications, player views, system tray, and immersive options. A 1:1
|
||
// structural replica of the Tauri app's Settings > Appearance section.
|
||
//
|
||
// This is primarily an UNWIRED replica: rows are bound to AppearanceState
|
||
// and emit generic appearance-bool / appearance-select / appearance-text /
|
||
// appearance-action callbacks, but the Rust side does not yet persist or
|
||
// apply most of them. The single exception is the sidebar-playlist-collage
|
||
// toggle, which is wired straight through to SidebarState.playlist-collage
|
||
// (an already-functional flag). Conditional rows are `if`-gated on the same
|
||
// flags Tauri gates on (System theme, title-bar mode, etc.).
|
||
|
||
import { Theme } from "../foundation/semantic-colors.slint";
|
||
import { ThemeState } from "../foundation/theme-state.slint";
|
||
import { Typography } from "../foundation/typography.slint";
|
||
import { Radius } from "../foundation/radius.slint";
|
||
import { AppearanceState, SidebarState, ShellState, ToastState, MyQbzBrandingState , UiFocusState } from "../state.slint";
|
||
import { QbzToggle } from "../primitives/QbzToggle.slint";
|
||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||
import { ColorPicker } from "../primitives/ColorPicker.slint";
|
||
import { SettingRow } from "SettingRow.slint";
|
||
import { LineEdit } from "std-widgets.slint";
|
||
|
||
component GroupHeader inherits Text {
|
||
color: Theme.text-muted;
|
||
font-size: 11px;
|
||
letter-spacing: 1.5px;
|
||
font-weight: Typography.semibold;
|
||
}
|
||
|
||
// A small muted sub-group label inside the custom-theme editor grid.
|
||
component TokenGroupLabel inherits Text {
|
||
color: Theme.text-muted;
|
||
font-size: 10px;
|
||
letter-spacing: 1px;
|
||
font-weight: Typography.semibold;
|
||
}
|
||
|
||
// One editable base-token CELL in the custom-theme editor: a compact color
|
||
// swatch with its label underneath, laid INLINE with its group siblings.
|
||
// Clicking toggles the shared ColorPicker at the bottom of the editor panel
|
||
// (one picker for the whole grid keeps the layout stable while editing).
|
||
component CustomTokenCell inherits VerticalLayout {
|
||
in property <string> token-key;
|
||
in property <string> label;
|
||
in property <color> swatch-color;
|
||
spacing: 6px;
|
||
alignment: start;
|
||
|
||
property <bool> open: AppearanceState.custom-open-token == root.token-key;
|
||
|
||
Rectangle {
|
||
width: 56px;
|
||
height: 32px;
|
||
border-radius: Radius.sm;
|
||
border-width: root.open ? 2px : 1px;
|
||
border-color: root.open ? Theme.accent : Theme.border-subtle;
|
||
background: root.swatch-color;
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => {
|
||
AppearanceState.custom-open-token =
|
||
AppearanceState.custom-open-token == root.token-key ? "" : root.token-key;
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
text: root.label;
|
||
color: root.open ? Theme.text-primary : Theme.text-muted;
|
||
font-size: Typography.legal;
|
||
width: 56px;
|
||
horizontal-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
component Divider inherits Rectangle {
|
||
height: 1px;
|
||
background: Theme.border-subtle;
|
||
}
|
||
|
||
// A small secondary-style button used for "Select Image…" / "Regenerate".
|
||
component SecondaryButton inherits Rectangle {
|
||
in property <string> label;
|
||
in property <bool> enabled: true;
|
||
callback clicked();
|
||
|
||
// 160px floor keeps the settings right-edge grid; longer translations grow.
|
||
width: Math.max(label-txt.preferred-width + 32px, 160px);
|
||
height: 34px;
|
||
border-radius: Radius.sm;
|
||
border-width: 1px;
|
||
border-color: Theme.border-subtle;
|
||
background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated;
|
||
opacity: root.enabled ? 1.0 : 0.4;
|
||
|
||
label-txt := Text {
|
||
text: root.label;
|
||
color: Theme.text-secondary;
|
||
font-size: Typography.body;
|
||
font-weight: Typography.medium;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
ta := TouchArea {
|
||
mouse-cursor: root.enabled ? pointer : default;
|
||
clicked => {
|
||
if (root.enabled) {
|
||
root.clicked();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A collapsible sub-section header (Immersive Panels / Animation Frame Rate).
|
||
// Hidden: its only users were the IMMERSIVE block (now commented out). Kept
|
||
// commented for re-enable alongside that block; do NOT delete.
|
||
/*
|
||
component CollapsibleHeader inherits Rectangle {
|
||
in property <string> title;
|
||
in property <string> summary;
|
||
in property <bool> collapsed: true;
|
||
callback toggled();
|
||
|
||
height: 56px;
|
||
HorizontalLayout {
|
||
spacing: 8px;
|
||
VerticalLayout {
|
||
alignment: center;
|
||
horizontal-stretch: 1;
|
||
spacing: 3px;
|
||
Text {
|
||
text: root.title;
|
||
color: Theme.text-primary;
|
||
font-size: Typography.body;
|
||
font-weight: Typography.semibold;
|
||
}
|
||
Text {
|
||
text: root.summary;
|
||
color: Theme.text-muted;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
alignment: center;
|
||
QbzIcon {
|
||
source: root.collapsed
|
||
? @image-url("../assets/icons/chevron-down.svg")
|
||
: @image-url("../assets/icons/chevron-up.svg");
|
||
width: 16px;
|
||
height: 16px;
|
||
tint: Theme.text-secondary;
|
||
}
|
||
}
|
||
}
|
||
ta := TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => {
|
||
root.toggled();
|
||
}
|
||
}
|
||
}
|
||
*/
|
||
|
||
export component AppearanceSettings inherits VerticalLayout {
|
||
spacing: 4px;
|
||
|
||
// Derived: title-bar customization rows are disabled when the title bar
|
||
// is hidden or the system title bar is used (mirrors Tauri).
|
||
property <bool> tb-locked: AppearanceState.hide-title-bar || AppearanceState.use-system-title-bar;
|
||
|
||
// ===================================================================
|
||
// THEME
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("THEME"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Theme");
|
||
HorizontalLayout {
|
||
spacing: 8px;
|
||
// Theme list filter cycle (All / Dark / Light).
|
||
filter-btn := Rectangle {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: Radius.sm;
|
||
border-width: 1px;
|
||
border-color: Theme.border-subtle;
|
||
background: filter-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||
QbzIcon {
|
||
// 0 = All, 1 = Dark, 2 = Light (SunMoon / Moon / Sun).
|
||
source: AppearanceState.theme-filter == 1
|
||
? @image-url("../assets/icons/moon.svg")
|
||
: AppearanceState.theme-filter == 2
|
||
? @image-url("../assets/icons/sun.svg")
|
||
: @image-url("../assets/icons/sun-moon.svg");
|
||
width: 16px;
|
||
height: 16px;
|
||
tint: Theme.text-secondary;
|
||
}
|
||
filter-ta := TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => {
|
||
AppearanceState.theme-filter = Math.mod(AppearanceState.theme-filter + 1, 3);
|
||
AppearanceState.appearance-select("theme-filter", AppearanceState.theme-filter);
|
||
}
|
||
}
|
||
}
|
||
QbzSelect {
|
||
menu-width: 220px;
|
||
// 36 registered themes — a name filter (Tauri parity). The search
|
||
// box hides non-matching rows but preserves model order, so
|
||
// `selected(i)` stays the index into the (polarity-filtered)
|
||
// `themes` model that the Rust "theme" handler maps.
|
||
searchable: true;
|
||
options: AppearanceState.themes;
|
||
current-index: AppearanceState.theme-index;
|
||
selected(i) => {
|
||
AppearanceState.theme-index = i;
|
||
// The Rust handler ("theme" arm) maps this dropdown index to
|
||
// a stable theme id, persists the slug, and hot-switches the
|
||
// palette via Theme.set_c. ThemeState.mode is kept in sync
|
||
// only as a legacy index reference (no longer drives colors).
|
||
ThemeState.mode = i;
|
||
AppearanceState.appearance-select("theme", i);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingRow {
|
||
label: @tr("Album header gradient");
|
||
description: @tr("Use artwork-derived blur as a backdrop in album and artist detail views.");
|
||
QbzToggle {
|
||
checked: AppearanceState.album-header-gradient;
|
||
toggled(v) => {
|
||
AppearanceState.album-header-gradient = v;
|
||
AppearanceState.appearance-bool("album-header-gradient", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
if AppearanceState.app-background-available: SettingRow {
|
||
label: @tr("Dynamic background");
|
||
description: @tr("Animated album-art background behind the whole app. High resource use — GPU accelerated.");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.app-background-modes;
|
||
current-index: AppearanceState.app-background-mode-index;
|
||
selected(i) => {
|
||
AppearanceState.app-background-mode-index = i;
|
||
AppearanceState.appearance-select("app-background", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingRow {
|
||
label: @tr("Intelligent Search");
|
||
description: @tr("Smart search cache, ranking, and the search preview dropdown. On by default.");
|
||
QbzToggle {
|
||
checked: AppearanceState.intelligent-search;
|
||
toggled(v) => {
|
||
AppearanceState.intelligent-search = v;
|
||
AppearanceState.appearance-bool("intelligent-search", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-Theme controls — only when the "Auto (dynamic)" theme is selected.
|
||
if AppearanceState.theme-is-auto: SettingRow {
|
||
label: @tr("Source");
|
||
description: @tr("Generate a color theme from your system wallpaper or a custom image");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.auto-theme-sources;
|
||
current-index: AppearanceState.auto-theme-source-index;
|
||
selected(i) => {
|
||
AppearanceState.auto-theme-source-index = i;
|
||
AppearanceState.appearance-select("auto-theme-source", i);
|
||
}
|
||
}
|
||
}
|
||
if AppearanceState.theme-is-auto && AppearanceState.auto-theme-source-index == 2: SettingRow {
|
||
label: AppearanceState.auto-theme-custom-path != ""
|
||
? AppearanceState.auto-theme-custom-path
|
||
: @tr("Select Image...");
|
||
SecondaryButton {
|
||
label: @tr("Select Image...");
|
||
clicked => {
|
||
AppearanceState.appearance-action("auto-theme-select-image");
|
||
}
|
||
}
|
||
}
|
||
if AppearanceState.theme-is-auto && AppearanceState.auto-theme-detected-de != "": SettingRow {
|
||
label: @tr("Detected: ") + AppearanceState.auto-theme-detected-de;
|
||
description: @tr("Experimental: theme may not match your system exactly.");
|
||
}
|
||
if AppearanceState.theme-is-auto: SettingRow {
|
||
label: @tr("Regenerate");
|
||
SecondaryButton {
|
||
label: @tr("Regenerate");
|
||
enabled: !AppearanceState.auto-theme-generating;
|
||
clicked => {
|
||
AppearanceState.appearance-action("auto-theme-regenerate");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Custom-theme editor — only when the appended "Custom" theme is selected.
|
||
// "Start from current theme" re-seeds the base from the live palette; the
|
||
// "Dark theme" toggle flips polarity; the grouped grid edits each base token
|
||
// (the rest of the palette is derived live in Rust on every change).
|
||
if AppearanceState.theme-is-custom: SettingRow {
|
||
label: @tr("Start from current theme");
|
||
description: @tr("Copy the colors of the currently applied theme into the editor as a starting point.");
|
||
SecondaryButton {
|
||
label: @tr("Use current colors");
|
||
clicked => {
|
||
AppearanceState.custom-seed-from-current();
|
||
}
|
||
}
|
||
}
|
||
if AppearanceState.theme-is-custom: SettingRow {
|
||
label: @tr("Dark theme");
|
||
description: @tr("Set the overall light or dark polarity. Affects derived shades, borders and overlays.");
|
||
QbzToggle {
|
||
checked: AppearanceState.custom-is-dark;
|
||
toggled(v) => {
|
||
AppearanceState.custom-toggle-dark(v);
|
||
}
|
||
}
|
||
}
|
||
if AppearanceState.theme-is-custom: VerticalLayout {
|
||
padding-top: 8px;
|
||
padding-bottom: 8px;
|
||
spacing: 14px;
|
||
|
||
// Inline swatch grid — groups side by side, cells inline within each
|
||
// group; the shared ColorPicker for the open token expands below.
|
||
HorizontalLayout {
|
||
spacing: 28px;
|
||
alignment: start;
|
||
VerticalLayout {
|
||
spacing: 8px;
|
||
alignment: start;
|
||
TokenGroupLabel { text: @tr("SURFACES"); }
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
CustomTokenCell { token-key: "surface-main"; label: @tr("Background"); swatch-color: AppearanceState.custom-surface-main; }
|
||
CustomTokenCell { token-key: "surface-card"; label: @tr("Surface"); swatch-color: AppearanceState.custom-surface-card; }
|
||
CustomTokenCell { token-key: "surface-elevated"; label: @tr("Elevated"); swatch-color: AppearanceState.custom-surface-elevated; }
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
spacing: 8px;
|
||
alignment: start;
|
||
TokenGroupLabel { text: @tr("TEXT"); }
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
CustomTokenCell { token-key: "text-primary"; label: @tr("Text"); swatch-color: AppearanceState.custom-text-primary; }
|
||
CustomTokenCell { token-key: "text-secondary"; label: @tr("Secondary text"); swatch-color: AppearanceState.custom-text-secondary; }
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
spacing: 8px;
|
||
alignment: start;
|
||
TokenGroupLabel { text: @tr("ACCENT"); }
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
CustomTokenCell { token-key: "accent"; label: @tr("Accent"); swatch-color: AppearanceState.custom-accent; }
|
||
}
|
||
}
|
||
}
|
||
HorizontalLayout {
|
||
spacing: 28px;
|
||
alignment: start;
|
||
VerticalLayout {
|
||
spacing: 8px;
|
||
alignment: start;
|
||
TokenGroupLabel { text: @tr("STATUS"); }
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
CustomTokenCell { token-key: "danger"; label: @tr("Danger"); swatch-color: AppearanceState.custom-danger; }
|
||
CustomTokenCell { token-key: "warning"; label: @tr("Warning"); swatch-color: AppearanceState.custom-warning; }
|
||
CustomTokenCell { token-key: "success"; label: @tr("Success"); swatch-color: AppearanceState.custom-success; }
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
spacing: 8px;
|
||
alignment: start;
|
||
TokenGroupLabel { text: @tr("OTHER"); }
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
CustomTokenCell { token-key: "border"; label: @tr("Border"); swatch-color: AppearanceState.custom-border; }
|
||
CustomTokenCell { token-key: "favorite"; label: @tr("Favorite"); swatch-color: AppearanceState.custom-favorite; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shared picker — one instance for whichever token is open.
|
||
if AppearanceState.custom-open-token != "": ColorPicker {
|
||
value: AppearanceState.custom-open-token == "surface-main" ? AppearanceState.custom-surface-main
|
||
: AppearanceState.custom-open-token == "surface-card" ? AppearanceState.custom-surface-card
|
||
: AppearanceState.custom-open-token == "surface-elevated" ? AppearanceState.custom-surface-elevated
|
||
: AppearanceState.custom-open-token == "text-primary" ? AppearanceState.custom-text-primary
|
||
: AppearanceState.custom-open-token == "text-secondary" ? AppearanceState.custom-text-secondary
|
||
: AppearanceState.custom-open-token == "accent" ? AppearanceState.custom-accent
|
||
: AppearanceState.custom-open-token == "danger" ? AppearanceState.custom-danger
|
||
: AppearanceState.custom-open-token == "warning" ? AppearanceState.custom-warning
|
||
: AppearanceState.custom-open-token == "success" ? AppearanceState.custom-success
|
||
: AppearanceState.custom-open-token == "border" ? AppearanceState.custom-border
|
||
: AppearanceState.custom-favorite;
|
||
changed(c) => {
|
||
AppearanceState.custom-set-token(AppearanceState.custom-open-token, c);
|
||
}
|
||
hex-committed(s) => {
|
||
AppearanceState.custom-set-token-hex(AppearanceState.custom-open-token, s);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// TYPOGRAPHY & LANGUAGE
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("TYPOGRAPHY & LANGUAGE"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Language");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.languages;
|
||
current-index: AppearanceState.language-index;
|
||
selected(i) => {
|
||
AppearanceState.language-index = i;
|
||
AppearanceState.appearance-select("language", i);
|
||
}
|
||
}
|
||
}
|
||
// Interface size — the working replacement for the old percentage "UI
|
||
// Scale" placeholder that was parked here (commented, unwired). Window
|
||
// scale-factor preset via SLINT_SCALE_FACTOR (main.rs); restart to apply.
|
||
SettingRow {
|
||
label: @tr("Interface size");
|
||
description: @tr("Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.ui-scale-options;
|
||
current-index: AppearanceState.ui-scale-index;
|
||
selected(i) => {
|
||
AppearanceState.ui-scale-index = i;
|
||
AppearanceState.appearance-select("ui-scale", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingRow {
|
||
label: @tr("Immersive search");
|
||
description: @tr("What selecting a result in the Immersive search does. Disabled turns the in-immersive search off.");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.immersive-search-actions;
|
||
current-index: AppearanceState.immersive-search-action-index;
|
||
selected(i) => {
|
||
AppearanceState.immersive-search-action-index = i;
|
||
AppearanceState.appearance-select("immersive-search-action", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingRow {
|
||
label: @tr("Immersive default view");
|
||
description: @tr("Which immersive view opens by default. 'Remember last' restores your last view.");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.immersive-default-views;
|
||
current-index: AppearanceState.immersive-default-view-index;
|
||
selected(i) => {
|
||
AppearanceState.immersive-default-view-index = i;
|
||
AppearanceState.appearance-select("immersive-default-view", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// LIBRARY & VISUALS
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("LIBRARY & VISUALS"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Show navigation in sidebar");
|
||
description: @tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar.");
|
||
QbzToggle {
|
||
checked: ShellState.nav-in-sidebar;
|
||
toggled(v) => {
|
||
ShellState.nav-in-sidebar = v;
|
||
ShellState.persist-nav(v);
|
||
}
|
||
}
|
||
}
|
||
// Compact header nav — only meaningful while the nav lives in the header,
|
||
// so the row mounts only when the toggle above is OFF (ADR-010).
|
||
if !ShellState.nav-in-sidebar: SettingRow {
|
||
label: @tr("Compact header navigation");
|
||
description: @tr("Use the icon-only section navigation in the header even while the sidebar is open.");
|
||
QbzToggle {
|
||
checked: ShellState.nav-header-compact;
|
||
toggled(v) => {
|
||
ShellState.nav-header-compact = v;
|
||
ShellState.persist-nav-compact(v);
|
||
}
|
||
}
|
||
}
|
||
// "My QBZ" nav branding — RENAME ONLY (DQ3, owner decision: no custom icon).
|
||
// The icon is fixed to QBZ's monochrome (tray) glyph. The label input
|
||
// commits on edit (blank coerces to "My QBZ" in Rust); the reset button
|
||
// clears the name back to the default. The icon-change backend (pick-icon /
|
||
// reset-icon / custom-icon) stays in Rust but is no longer surfaced.
|
||
SettingRow {
|
||
label: @tr("My QBZ");
|
||
description: @tr("Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default.");
|
||
HorizontalLayout {
|
||
spacing: 8px;
|
||
// Fixed icon preview — QBZ's monochrome glyph, tinted to text.
|
||
Rectangle {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: Radius.sm;
|
||
border-width: 1px;
|
||
border-color: Theme.border-subtle;
|
||
background: Theme.surface-elevated;
|
||
QbzIcon {
|
||
source: @image-url("../assets/icons/qbz-symbolic.svg");
|
||
width: 18px;
|
||
height: 18px;
|
||
tint: Theme.text-secondary;
|
||
}
|
||
}
|
||
// Label input — commits on edit; the placeholder is the default.
|
||
VerticalLayout {
|
||
alignment: center;
|
||
width: 150px;
|
||
name-edit := LineEdit {
|
||
text: MyQbzBrandingState.label;
|
||
placeholder-text: @tr("My QBZ");
|
||
// Hotkey-guard probe: a use-site `changed has-focus` on a std-widgets
|
||
// input never fires (has-focus is an alias into the inner TextInput),
|
||
// so observe it through a local property instead (#619).
|
||
property <bool> guard-focused: self.has-focus;
|
||
changed guard-focused => { UiFocusState.text-input-focused = self.guard-focused; }
|
||
edited(s) => {
|
||
MyQbzBrandingState.set-label(s);
|
||
}
|
||
}
|
||
}
|
||
// Reset name — a square glyph button (matches the Output-device
|
||
// refresh button), clears the custom name to the default.
|
||
VerticalLayout {
|
||
alignment: center;
|
||
reset-name := Rectangle {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: Radius.sm;
|
||
border-width: 1px;
|
||
border-color: Theme.border-subtle;
|
||
background: reset-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||
QbzIcon {
|
||
width: 16px;
|
||
height: 16px;
|
||
source: @image-url("../assets/icons/rotate-ccw.svg");
|
||
tint: reset-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||
}
|
||
reset-ta := TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => {
|
||
MyQbzBrandingState.set-label("");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Show playlist cover collages in sidebar");
|
||
description: @tr("Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images.");
|
||
QbzToggle {
|
||
// Wired: this toggle drives the already-functional sidebar collage flag.
|
||
checked: SidebarState.playlist-collage;
|
||
toggled(v) => {
|
||
SidebarState.playlist-collage = v;
|
||
AppearanceState.sidebar-playlist-collage = v;
|
||
AppearanceState.appearance-bool("sidebar-playlist-collage", v);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Show album artwork in Local Library track list");
|
||
description: @tr("Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost.");
|
||
QbzToggle {
|
||
checked: AppearanceState.local-library-track-artwork;
|
||
toggled(v) => {
|
||
AppearanceState.local-library-track-artwork = v;
|
||
AppearanceState.appearance-bool("local-library-track-artwork", v);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Play indicator animation");
|
||
description: @tr("Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU.");
|
||
QbzToggle {
|
||
checked: AppearanceState.play-indicator-animation;
|
||
toggled(v) => {
|
||
AppearanceState.play-indicator-animation = v;
|
||
AppearanceState.appearance-bool("play-indicator-animation", v);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Invert swipe navigation direction");
|
||
description: @tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.");
|
||
QbzToggle {
|
||
checked: AppearanceState.invert-swipe-navigation;
|
||
toggled(v) => {
|
||
AppearanceState.invert-swipe-navigation = v;
|
||
AppearanceState.appearance-bool("invert-swipe-navigation", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// NOTIFICATIONS
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("NOTIFICATIONS"); }
|
||
|
||
SettingRow {
|
||
label: @tr("In-app toasts notifications");
|
||
QbzToggle {
|
||
checked: AppearanceState.in-app-toasts;
|
||
toggled(v) => {
|
||
AppearanceState.in-app-toasts = v;
|
||
ToastState.enabled = v;
|
||
AppearanceState.appearance-bool("in-app-toasts", v);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("System Notifications");
|
||
QbzToggle {
|
||
checked: AppearanceState.system-notifications;
|
||
toggled(v) => {
|
||
AppearanceState.system-notifications = v;
|
||
AppearanceState.appearance-bool("system-notifications", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// WINDOW TITLE
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("WINDOW TITLE"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Show track in window title");
|
||
QbzToggle {
|
||
checked: AppearanceState.window-title-show;
|
||
toggled(v) => {
|
||
AppearanceState.window-title-show = v;
|
||
AppearanceState.appearance-bool("window-title-show", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Title-template row — hidden per owner request. The window title uses a
|
||
// fixed format (see AppWindow.title in app.slint). Kept commented.
|
||
/*
|
||
if AppearanceState.window-title-show: SettingRow {
|
||
unsupported: true;
|
||
label: @tr("Title template");
|
||
description: @tr("Placeholders: {{artist}}, {{track}}, {{album}}");
|
||
HorizontalLayout {
|
||
width: 240px;
|
||
VerticalLayout {
|
||
alignment: center;
|
||
horizontal-stretch: 1;
|
||
LineEdit {
|
||
text: AppearanceState.window-title-template;
|
||
placeholder-text: "{artist} - {track}";
|
||
// Hotkey-guard probe: a use-site `changed has-focus` on a std-widgets
|
||
// input never fires (has-focus is an alias into the inner TextInput),
|
||
// so observe it through a local property instead (#619).
|
||
property <bool> guard-focused: self.has-focus;
|
||
changed guard-focused => { UiFocusState.text-input-focused = self.guard-focused; }
|
||
edited(s) => {
|
||
AppearanceState.window-title-template = s;
|
||
AppearanceState.appearance-text("window-title-template", s);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
*/
|
||
|
||
if !AppearanceState.is-macos: Rectangle { height: 12px; }
|
||
if !AppearanceState.is-macos: Divider { }
|
||
if !AppearanceState.is-macos: Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// TITLE BAR (custom window chrome — the header IS the title bar: it is
|
||
// always present; custom mode adds window controls + drag to it and
|
||
// drops the system decorations. Linux defaults to system chrome; macOS
|
||
// keeps the native overlay (traffic lights over the header), so this
|
||
// whole section is hidden there.)
|
||
// ===================================================================
|
||
if !AppearanceState.is-macos: GroupHeader { text: @tr("TITLE BAR"); }
|
||
|
||
if !AppearanceState.is-macos: SettingRow {
|
||
label: @tr("Use system title bar");
|
||
description: @tr("Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ.");
|
||
QbzToggle {
|
||
checked: AppearanceState.use-system-title-bar;
|
||
toggled(v) => {
|
||
AppearanceState.use-system-title-bar = v;
|
||
AppearanceState.appearance-bool("use-system-title-bar", v);
|
||
}
|
||
}
|
||
}
|
||
// Tiling-WM row: Linux-only — on macOS the native traffic lights are
|
||
// always present (overlay or system bar), so a "hide completely"
|
||
// frameless mode does not exist there (same gating idiom as the
|
||
// WINDOW CONTROLS rows below).
|
||
if !AppearanceState.is-macos: SettingRow {
|
||
label: @tr("Hide title bar");
|
||
description: @tr("Frameless window without window controls or header drag (for tiling window manager users)");
|
||
enabled: !AppearanceState.use-system-title-bar;
|
||
QbzToggle {
|
||
checked: AppearanceState.hide-title-bar;
|
||
enabled: !AppearanceState.use-system-title-bar;
|
||
toggled(v) => {
|
||
AppearanceState.hide-title-bar = v;
|
||
AppearanceState.appearance-bool("hide-title-bar", v);
|
||
}
|
||
}
|
||
}
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// WINDOW CONTROLS (drawn in-app on Linux custom chrome; macOS keeps
|
||
// the native traffic lights so these rows are hidden there)
|
||
// ===================================================================
|
||
if !AppearanceState.is-macos: GroupHeader { text: @tr("WINDOW CONTROLS"); }
|
||
|
||
if !AppearanceState.is-macos: SettingRow {
|
||
label: @tr("Window controls position");
|
||
description: @tr("Place the window control buttons on the left or right side of the title bar");
|
||
enabled: !root.tb-locked;
|
||
QbzSelect {
|
||
menu-width: 160px;
|
||
options: AppearanceState.wc-positions;
|
||
current-index: AppearanceState.wc-position-index;
|
||
enabled: !root.tb-locked;
|
||
selected(i) => {
|
||
AppearanceState.wc-position-index = i;
|
||
AppearanceState.appearance-select("wc-position", i);
|
||
}
|
||
}
|
||
}
|
||
if !AppearanceState.is-macos: SettingRow {
|
||
label: @tr("Show window controls");
|
||
description: @tr("Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these.");
|
||
enabled: !root.tb-locked;
|
||
QbzToggle {
|
||
checked: AppearanceState.show-window-controls;
|
||
enabled: !root.tb-locked;
|
||
toggled(v) => {
|
||
AppearanceState.show-window-controls = v;
|
||
AppearanceState.appearance-bool("show-window-controls", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// PLAYER VIEWS / STARTUP
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("PLAYER VIEWS"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Show volume +/- buttons");
|
||
description: @tr("Add discrete plus and minus buttons next to the volume slider in the player bar.");
|
||
QbzToggle {
|
||
checked: AppearanceState.show-volume-steppers;
|
||
toggled(v) => {
|
||
AppearanceState.show-volume-steppers = v;
|
||
AppearanceState.appearance-bool("show-volume-steppers", v);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Mini player default view");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.miniplayer-views;
|
||
current-index: AppearanceState.miniplayer-view-index;
|
||
selected(i) => {
|
||
AppearanceState.miniplayer-view-index = i;
|
||
AppearanceState.appearance-select("miniplayer-view", i);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Startup page");
|
||
description: @tr("Choose which page to show when the app starts");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.startup-pages;
|
||
current-index: AppearanceState.startup-page-index;
|
||
selected(i) => {
|
||
AppearanceState.startup-page-index = i;
|
||
AppearanceState.appearance-select("startup-page", i);
|
||
}
|
||
}
|
||
}
|
||
SettingRow {
|
||
label: @tr("Show Purchases");
|
||
description: @tr("Show the Purchases section in the sidebar for browsing and downloading your purchased music");
|
||
QbzToggle {
|
||
checked: AppearanceState.show-purchases;
|
||
toggled(v) => {
|
||
AppearanceState.show-purchases = v;
|
||
AppearanceState.appearance-bool("show-purchases", v);
|
||
}
|
||
}
|
||
}
|
||
// Purchases title-bar placement — moves the Purchases nav entry into the
|
||
// custom title bar instead of the sidebar (mirrors Tauri `purchasesInTitlebar`,
|
||
// default OFF). Disabled when the custom title bar is hidden or the system
|
||
// title bar is in use (no custom title bar to place it in).
|
||
SettingRow {
|
||
label: @tr("Purchases in title bar");
|
||
description: @tr("Place the Purchases entry in the custom title bar instead of the sidebar");
|
||
enabled: !root.tb-locked;
|
||
QbzToggle {
|
||
checked: AppearanceState.nav-tb-purchases;
|
||
enabled: !root.tb-locked;
|
||
toggled(v) => {
|
||
AppearanceState.nav-tb-purchases = v;
|
||
AppearanceState.appearance-bool("nav-tb-purchases", v);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// SYSTEM TRAY (macOS: "MENU BAR")
|
||
// ===================================================================
|
||
GroupHeader { text: AppearanceState.is-macos ? @tr("MENU BAR") : @tr("SYSTEM TRAY"); }
|
||
|
||
SettingRow {
|
||
label: @tr("Enable tray icon");
|
||
description: @tr("Show icon in system tray (requires restart)");
|
||
QbzToggle {
|
||
checked: AppearanceState.tray-enable;
|
||
toggled(v) => {
|
||
AppearanceState.tray-enable = v;
|
||
AppearanceState.appearance-bool("tray-enable", v);
|
||
}
|
||
}
|
||
}
|
||
// 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 {
|
||
label: @tr("Close to tray");
|
||
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;
|
||
enabled: AppearanceState.tray-enable;
|
||
toggled(v) => {
|
||
AppearanceState.tray-close-to-tray = v;
|
||
AppearanceState.appearance-bool("tray-close-to-tray", v);
|
||
}
|
||
}
|
||
}
|
||
// 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 {
|
||
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 {
|
||
menu-width: 160px;
|
||
options: AppearanceState.tray-icon-themes;
|
||
current-index: AppearanceState.tray-icon-theme-index;
|
||
selected(i) => {
|
||
AppearanceState.tray-icon-theme-index = i;
|
||
AppearanceState.appearance-select("tray-icon-theme", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
// IMMERSIVE block — hidden per owner request. Code kept commented for
|
||
// re-enable; do NOT delete.
|
||
/*
|
||
Rectangle { height: 12px; }
|
||
Divider { }
|
||
Rectangle { height: 12px; }
|
||
|
||
// ===================================================================
|
||
// IMMERSIVE
|
||
// ===================================================================
|
||
GroupHeader { text: @tr("IMMERSIVE"); }
|
||
|
||
SettingRow {
|
||
unsupported: true;
|
||
label: @tr("Background effect");
|
||
description: @tr("Controls how the blurred artwork background is rendered in immersive mode.");
|
||
QbzSelect {
|
||
menu-width: 160px;
|
||
options: AppearanceState.immersive-background-modes;
|
||
current-index: AppearanceState.immersive-background-mode-index;
|
||
selected(i) => {
|
||
AppearanceState.immersive-background-mode-index = i;
|
||
AppearanceState.appearance-select("immersive-background-mode", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collapsible: Immersive Panels (per-panel enable/disable lives behind
|
||
// this header in Tauri; here the header is replicated, body deferred).
|
||
CollapsibleHeader {
|
||
title: @tr("Immersive Panels");
|
||
summary: @tr("Enable or disable individual immersive view panels");
|
||
collapsed: AppearanceState.immersive-panels-collapsed;
|
||
toggled => {
|
||
AppearanceState.immersive-panels-collapsed = !AppearanceState.immersive-panels-collapsed;
|
||
}
|
||
}
|
||
if !AppearanceState.immersive-panels-collapsed: Text {
|
||
text: @tr("Disabled panels are hidden from the visualizer menu so you don't land on them by mistake.");
|
||
color: Theme.text-muted;
|
||
font-size: 12px;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Collapsible: Animation Frame Rate (per-panel FPS lives behind this
|
||
// header in Tauri; here the header is replicated, body deferred).
|
||
CollapsibleHeader {
|
||
title: @tr("Animation Frame Rate");
|
||
summary: @tr("Per-animation FPS control for immersive mode");
|
||
collapsed: AppearanceState.immersive-fps-collapsed;
|
||
toggled => {
|
||
AppearanceState.immersive-fps-collapsed = !AppearanceState.immersive-fps-collapsed;
|
||
}
|
||
}
|
||
if !AppearanceState.immersive-fps-collapsed: Text {
|
||
text: @tr("Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply.");
|
||
color: Theme.text-muted;
|
||
font-size: 12px;
|
||
wrap: word-wrap;
|
||
}
|
||
*/
|
||
|
||
// ===================================================================
|
||
// RENDERER (Linux only — visibility seeded from Rust; macOS is
|
||
// always Skia/Metal so the group never shows there)
|
||
// ===================================================================
|
||
if AppearanceState.renderer-setting-visible: Rectangle { height: 12px; }
|
||
if AppearanceState.renderer-setting-visible: Divider { }
|
||
if AppearanceState.renderer-setting-visible: Rectangle { height: 12px; }
|
||
if AppearanceState.renderer-setting-visible: GroupHeader { text: @tr("RENDERER"); }
|
||
if AppearanceState.renderer-setting-visible: SettingRow {
|
||
label: @tr("Rendering backend");
|
||
description: @tr("Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)");
|
||
QbzSelect {
|
||
menu-width: 200px;
|
||
options: AppearanceState.renderer-options;
|
||
current-index: AppearanceState.renderer-index;
|
||
selected(i) => {
|
||
AppearanceState.renderer-index = i;
|
||
AppearanceState.appearance-select("renderer", i);
|
||
}
|
||
}
|
||
}
|
||
if AppearanceState.renderer-setting-visible: SettingRow {
|
||
label: @tr("Preferred GPU");
|
||
description: @tr("Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)");
|
||
QbzSelect {
|
||
menu-width: 260px;
|
||
options: AppearanceState.gpu-power-modes;
|
||
current-index: AppearanceState.gpu-power-index;
|
||
selected(i) => {
|
||
AppearanceState.gpu-power-index = i;
|
||
AppearanceState.appearance-select("gpu-power", i);
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 40px; }
|
||
}
|