feat(slint): add Settings page with Audio and Playback panels

Introduce the Settings page, reachable from the Discover gear and the
header menu. Two sub-sections backed by the existing UI-agnostic stores.

- Audio panel: backend, output device and sample-rate dropdowns;
  toggles for exclusive mode, DAC passthrough, PipeWire force
  bit-perfect, gapless, normalization and quality fallback
- Playback panel: autoplay mode, show context icon, remember session
  and resume position
- settings.rs controller owns AudioSettingsStore + PlaybackPreferencesStore
  at the shared global path; persists changes and applies audio ones to
  the live Player (reload_settings, plus reinit_device for routing-
  critical changes); a backend switch re-enumerates output devices
- QbzToggle (switch) and QbzSelect (PopupWindow dropdown) primitives,
  SettingRow layout helper, chevron-down icon
- SettingsState global, ContentView::settings, settings-bool /
  settings-select callbacks wired through the shell

cc
This commit is contained in:
vicrodh committed 2026-05-18 17:08:24 -06:00
1 parent 1662b3824e
commit 5d78fb0a09
15 files changed
+1190 -7

No files matched your search

+9 -1
View File
@@ -10,7 +10,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, AlbumState, ArtistState, NavState, SessionState } from "state.slint";
export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -33,6 +33,8 @@ export component AppWindow inherits Window {
callback open-tos();
callback open-album(string);
callback open-artist(string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback logout();
callback close-app();
@@ -60,6 +62,12 @@ export component AppWindow inherits Window {
open-artist(id) => {
root.open-artist(id);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
logout => {
root.logout();
}
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 212 B

+6 -2
View File
@@ -8,7 +8,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { HomeState } from "../state.slint";
import { HomeState, NavState, ContentView } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { GridSection } from "GridSection.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
@@ -144,7 +144,11 @@ export component HomeView inherits Rectangle {
HorizontalLayout {
spacing: 6px;
GenreButton { }
GearButton { }
GearButton {
clicked => {
NavState.view = ContentView.settings;
}
}
}
}
}
@@ -0,0 +1,105 @@
// Dropdown select primitive — the standard option picker for settings.
//
// The QBZ UI uses dropdowns, never pill-style option buttons. The select
// shows the current option and opens a PopupWindow list on click; picking
// an option emits `selected(index)` and the caller updates the bound
// state.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "QbzIcon.slint";
export component QbzSelect inherits Rectangle {
in property <[string]> options;
in property <int> current-index: 0;
in property <length> menu-width: 240px;
in property <bool> enabled: true;
callback selected(int);
width: root.menu-width;
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;
HorizontalLayout {
padding-left: 12px;
padding-right: 10px;
spacing: 8px;
Text {
text: root.current-index >= 0 && root.current-index < root.options.length
? root.options[root.current-index]
: "";
color: Theme.text-primary;
font-size: Typography.body;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/chevron-down.svg");
width: 16px;
height: 16px;
tint: Theme.text-muted;
}
}
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
popup.show();
}
}
}
popup := PopupWindow {
x: 0;
y: root.height + 4px;
width: root.menu-width;
height: root.options.length * 32px + 10px;
Rectangle {
background: Theme.surface-elevated;
border-radius: Radius.md;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 20px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 5px;
VerticalLayout {
padding-top: 5px;
padding-bottom: 5px;
for opt[i] in root.options: Rectangle {
height: 32px;
background: opt-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Text {
text: opt;
color: i == root.current-index ? Theme.accent : Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
opt-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.selected(i);
}
}
}
}
}
}
}
@@ -0,0 +1,38 @@
// Switch toggle primitive — the standard on/off control for settings.
//
// The toggle does not flip itself: it emits `toggled(new-value)` and the
// caller updates the bound state. This keeps a single source of truth in
// the SettingsState global.
import { Theme } from "../foundation/semantic-colors.slint";
export component QbzToggle inherits Rectangle {
in property <bool> checked: false;
in property <bool> enabled: true;
callback toggled(bool);
width: 40px;
height: 22px;
border-radius: 11px;
background: root.checked ? Theme.accent : Theme.surface-elevated;
opacity: root.enabled ? 1.0 : 0.4;
Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.text-primary;
y: 3px;
x: root.checked ? root.width - self.width - 3px : 3px;
animate x { duration: 120ms; easing: ease-out; }
}
TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.toggled(!root.checked);
}
}
}
}
@@ -0,0 +1,151 @@
// Audio settings panel — output routing, bit-perfect options, and
// playback-quality preferences. All rows are backed by the SettingsState
// global and persisted through the Rust settings controller.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { SettingsState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { SettingRow } from "SettingRow.slint";
component GroupHeader inherits Text {
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
font-weight: Typography.semibold;
}
component Divider inherits Rectangle {
height: 1px;
background: Theme.border-subtle;
}
export component AudioSettings inherits VerticalLayout {
callback settings-bool(string, bool);
callback settings-select(string, int);
spacing: 4px;
GroupHeader { text: @tr("OUTPUT"); }
SettingRow {
label: @tr("Audio backend");
description: @tr("The audio stack QBZ routes playback through.");
QbzSelect {
menu-width: 220px;
options: SettingsState.backends;
current-index: SettingsState.backend-index;
selected(i) => {
SettingsState.backend-index = i;
root.settings-select("backend", i);
}
}
}
SettingRow {
label: @tr("Output device");
description: @tr("The DAC or sound device that receives audio.");
QbzSelect {
menu-width: 300px;
options: SettingsState.devices;
current-index: SettingsState.device-index;
selected(i) => {
SettingsState.device-index = i;
root.settings-select("device", i);
}
}
}
SettingRow {
label: @tr("Preferred sample rate");
description: @tr("Force a sample rate, or let the track decide.");
QbzSelect {
menu-width: 170px;
options: SettingsState.sample-rates;
current-index: SettingsState.sample-rate-index;
selected(i) => {
SettingsState.sample-rate-index = i;
root.settings-select("sample-rate", i);
}
}
}
Rectangle { height: 12px; }
Divider { }
Rectangle { height: 12px; }
GroupHeader { text: @tr("BIT-PERFECT"); }
SettingRow {
label: @tr("Exclusive mode");
description: @tr("Lock the device so no other app can resample it.");
QbzToggle {
checked: SettingsState.exclusive-mode;
toggled(v) => {
SettingsState.exclusive-mode = v;
root.settings-bool("exclusive-mode", v);
}
}
}
SettingRow {
label: @tr("DAC passthrough");
description: @tr("Send the bitstream untouched to the DAC.");
QbzToggle {
checked: SettingsState.dac-passthrough;
toggled(v) => {
SettingsState.dac-passthrough = v;
root.settings-bool("dac-passthrough", v);
}
}
}
SettingRow {
label: @tr("PipeWire force bit-perfect");
description: @tr("Pin the PipeWire quantum and rate for the active track.");
QbzToggle {
checked: SettingsState.pw-force-bitperfect;
toggled(v) => {
SettingsState.pw-force-bitperfect = v;
root.settings-bool("pw-force-bitperfect", v);
}
}
}
Rectangle { height: 12px; }
Divider { }
Rectangle { height: 12px; }
GroupHeader { text: @tr("PLAYBACK QUALITY"); }
SettingRow {
label: @tr("Gapless playback");
description: @tr("Play consecutive same-format tracks without a gap.");
QbzToggle {
checked: SettingsState.gapless;
toggled(v) => {
SettingsState.gapless = v;
root.settings-bool("gapless", v);
}
}
}
SettingRow {
label: @tr("Volume normalization");
description: @tr("Apply ReplayGain. Off keeps the pipeline bit-perfect.");
QbzToggle {
checked: SettingsState.normalization;
toggled(v) => {
SettingsState.normalization = v;
root.settings-bool("normalization", v);
}
}
}
SettingRow {
label: @tr("Allow quality fallback");
description: @tr("Drop to a lower tier when the requested one is unavailable.");
QbzToggle {
checked: SettingsState.allow-quality-fallback;
toggled(v) => {
SettingsState.allow-quality-fallback = v;
root.settings-bool("allow-quality-fallback", v);
}
}
}
}
@@ -0,0 +1,87 @@
// Playback settings panel — end-of-source behavior and session
// persistence. Backed by the SettingsState global and persisted through
// the Rust settings controller.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { SettingsState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { SettingRow } from "SettingRow.slint";
component GroupHeader inherits Text {
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
font-weight: Typography.semibold;
}
component Divider inherits Rectangle {
height: 1px;
background: Theme.border-subtle;
}
export component PlaybackSettings inherits VerticalLayout {
callback settings-bool(string, bool);
callback settings-select(string, int);
spacing: 4px;
GroupHeader { text: @tr("AUTOPLAY"); }
SettingRow {
label: @tr("When the source ends");
description: @tr("What happens after the last track of an album or playlist.");
QbzSelect {
menu-width: 240px;
options: SettingsState.autoplay-modes;
current-index: SettingsState.autoplay-index;
selected(i) => {
SettingsState.autoplay-index = i;
root.settings-select("autoplay-mode", i);
}
}
}
SettingRow {
label: @tr("Show context icon");
description: @tr("Display the context-stack icon in the player.");
QbzToggle {
checked: SettingsState.show-context-icon;
toggled(v) => {
SettingsState.show-context-icon = v;
root.settings-bool("show-context-icon", v);
}
}
}
Rectangle { height: 12px; }
Divider { }
Rectangle { height: 12px; }
GroupHeader { text: @tr("SESSION"); }
SettingRow {
label: @tr("Remember session");
description: @tr("Restore the queue and current track on the next launch.");
QbzToggle {
checked: SettingsState.persist-session;
toggled(v) => {
SettingsState.persist-session = v;
root.settings-bool("persist-session", v);
}
}
}
SettingRow {
label: @tr("Resume playback position");
description: @tr("Also seek back to where the saved track left off.");
enabled: SettingsState.persist-session;
QbzToggle {
checked: SettingsState.resume-position;
enabled: SettingsState.persist-session;
toggled(v) => {
SettingsState.resume-position = v;
root.settings-bool("resume-position", v);
}
}
}
}
@@ -0,0 +1,38 @@
// One settings row — a label and optional description on the left, the
// control (toggle or select, passed as a child) on the right.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
export component SettingRow inherits HorizontalLayout {
in property <string> label;
in property <string> description;
in property <bool> enabled: true;
height: root.description == "" ? 52px : 64px;
spacing: 24px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 3px;
opacity: root.enabled ? 1.0 : 0.45;
Text {
text: root.label;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
}
if root.description != "": Text {
text: root.description;
color: Theme.text-muted;
font-size: 12px;
wrap: word-wrap;
}
}
VerticalLayout {
alignment: center;
@children
}
}
@@ -0,0 +1,183 @@
// Settings page — a back/title header, a left sub-navigation column
// (Audio, Playback), and the active settings panel in a scroll area.
//
// Navigation between sub-sections is pure UI state (SettingsState.section);
// setting changes bubble up as settings-bool / settings-select callbacks
// to the Rust layer, which persists them and applies them to the player.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, SettingsState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { AudioSettings } from "AudioSettings.slint";
import { PlaybackSettings } from "PlaybackSettings.slint";
import { ScrollView } from "std-widgets.slint";
component SubNavItem inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active: false;
callback clicked();
height: 38px;
border-radius: Radius.sm;
background: root.active ? Theme.surface-elevated
: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 10px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
}
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: Typography.body;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component SettingsView inherits Rectangle {
callback settings-bool(string, bool);
callback settings-select(string, int);
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- Header: back button + title -----------------------------------
Rectangle {
height: 92px;
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 14px;
spacing: 12px;
alignment: start;
back := TouchArea {
width: back-row.preferred-width;
height: 26px;
mouse-cursor: pointer;
clicked => {
NavState.view = ContentView.home;
}
back-row := HorizontalLayout {
spacing: 6px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: back.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: Typography.body;
vertical-alignment: center;
}
}
}
Text {
text: @tr("Settings");
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
}
}
}
// --- Sub-nav + active panel ----------------------------------------
HorizontalLayout {
vertical-stretch: 1;
// Left sub-navigation.
Rectangle {
width: 232px;
VerticalLayout {
padding-left: 24px;
padding-right: 12px;
padding-top: 4px;
spacing: 4px;
alignment: start;
SubNavItem {
icon: @image-url("../assets/icons/volume-2.svg");
label: @tr("Audio");
active: SettingsState.section == 0;
clicked => {
SettingsState.section = 0;
}
}
SubNavItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Playback");
active: SettingsState.section == 1;
clicked => {
SettingsState.section = 1;
}
}
}
}
// Active panel.
ScrollView {
horizontal-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
padding-left: 20px;
padding-right: 40px;
padding-top: 4px;
padding-bottom: 60px;
alignment: start;
if SettingsState.loading: Text {
text: @tr("Loading settings...");
color: Theme.text-muted;
font-size: Typography.body;
}
if !SettingsState.loading && SettingsState.section == 0: AudioSettings {
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
}
if !SettingsState.loading && SettingsState.section == 1: PlaybackSettings {
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
}
}
}
}
}
}
+11
View File
@@ -13,10 +13,13 @@ import { PlayerBar } from "PlayerBar.slint";
import { HomeView } from "../discover/HomeView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
export component AppShell inherits Rectangle {
callback open-album(string);
callback open-artist(string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback logout();
callback close-app();
@@ -60,6 +63,14 @@ export component AppShell inherits Rectangle {
root.open-album(id);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
}
}
}
+4 -1
View File
@@ -10,7 +10,7 @@ import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { SessionState } from "../state.slint";
import { SessionState, NavState, ContentView } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavTab inherits Rectangle {
@@ -245,6 +245,9 @@ export component HeaderBar inherits Rectangle {
MenuItem {
icon: @image-url("../assets/icons/settings.svg");
label: @tr("Settings");
clicked => {
NavState.view = ContentView.settings;
}
}
MenuItem {
icon: @image-url("../assets/icons/keyboard.svg");
+34 -1
View File
@@ -61,7 +61,40 @@ export global AlbumState {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist }
export enum ContentView { home, album, artist, settings }
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
// changes flow back through the settings-bool / settings-select callbacks.
export global SettingsState {
// Sub-section: 0 = Audio, 1 = Playback.
in-out property <int> section: 0;
in property <bool> loading: true;
// Audio — dropdowns. Each list is paired with a selected index; the
// Rust controller owns the index -> value mapping.
in-out property <[string]> backends: [];
in-out property <int> backend-index: 0;
in-out property <[string]> devices: [];
in-out property <int> device-index: 0;
in-out property <[string]> sample-rates: [];
in-out property <int> sample-rate-index: 0;
// Audio — toggles.
in-out property <bool> exclusive-mode: false;
in-out property <bool> dac-passthrough: false;
in-out property <bool> pw-force-bitperfect: false;
in-out property <bool> gapless: true;
in-out property <bool> normalization: false;
in-out property <bool> allow-quality-fallback: false;
// Playback.
in-out property <[string]> autoplay-modes: [];
in-out property <int> autoplay-index: 0;
in-out property <bool> show-context-icon: false;
in-out property <bool> persist-session: false;
in-out property <bool> resume-position: false;
}
export global NavState {
in-out property <ContentView> view: ContentView.home;