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:
15 files changed
+1190
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user