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
@@ -21,6 +21,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
||||
urlencoding = "2"
|
||||
open = "5"
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
|
||||
@@ -21,6 +21,7 @@ mod artwork;
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod home;
|
||||
mod settings;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -41,6 +42,7 @@ async fn enter_shell(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
image_cache: artwork::ImageCache,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
user_name: String,
|
||||
) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
@@ -49,6 +51,24 @@ async fn enter_shell(
|
||||
w.set_screen(AppScreen::Shell);
|
||||
});
|
||||
|
||||
// Load Audio + Playback settings into the Settings page in the
|
||||
// background — store reads and device enumeration are blocking.
|
||||
{
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
tokio::spawn(async move {
|
||||
match tokio::task::spawn_blocking(move || settings::load_snapshot(&settings_ctx)).await
|
||||
{
|
||||
Ok(snap) => {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
settings::apply_snapshot(&w, snap);
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
match home::load_home(&runtime).await {
|
||||
Ok(data) => {
|
||||
// Collect artwork jobs before the data is consumed by apply_home.
|
||||
@@ -115,6 +135,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let image_cache = artwork::open_cache();
|
||||
artwork::spawn_evict(image_cache.clone());
|
||||
|
||||
// Audio + Playback settings stores, opened once for the app lifetime.
|
||||
let settings_ctx = settings::SettingsCtx::open();
|
||||
|
||||
// Startup: initialize the core, then try to restore a saved session.
|
||||
// A valid saved token jumps straight to the shell; otherwise the
|
||||
// login screen stays.
|
||||
@@ -122,6 +145,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
tokio_rt.spawn(async move {
|
||||
if let Err(e) = runtime.init().await {
|
||||
log::error!("[qbz-slint] core init failed: {e}");
|
||||
@@ -129,7 +153,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match auth::restore_saved_session(&runtime).await {
|
||||
Ok(Some((user_id, user_name))) => {
|
||||
log::info!("[qbz-slint] session restored for user {user_id}");
|
||||
enter_shell(runtime, weak, image_cache, user_name).await;
|
||||
enter_shell(runtime, weak, image_cache, settings_ctx, user_name).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
log::info!("[qbz-slint] no saved session — showing login");
|
||||
@@ -151,15 +175,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
move || {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
handle.spawn(async move {
|
||||
match auth::login_via_system_browser(&runtime).await {
|
||||
Ok((user_id, user_name)) => {
|
||||
log::info!("[qbz-slint] authenticated as user {user_id}");
|
||||
enter_shell(runtime, weak, image_cache, user_name).await;
|
||||
enter_shell(runtime, weak, image_cache, settings_ctx, user_name).await;
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"),
|
||||
}
|
||||
@@ -311,6 +337,41 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Settings — a toggle changed: persist it and apply audio ones to the
|
||||
// live player.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_settings_bool(move |key, value| {
|
||||
let runtime = runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let key = key.to_string();
|
||||
handle.spawn(async move {
|
||||
settings::handle_bool(&settings_ctx, &runtime, &key, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Settings — a dropdown changed: persist it, apply audio ones, and
|
||||
// re-enumerate devices on a backend switch.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_settings_select(move |key, index| {
|
||||
let runtime = runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
let key = key.to_string();
|
||||
let index = index.max(0) as usize;
|
||||
handle.spawn(async move {
|
||||
settings::handle_select(settings_ctx, runtime, weak, key, index).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.on_close_app(|| {
|
||||
log::info!("[qbz-slint] closing");
|
||||
let _ = slint::quit_event_loop();
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Settings controller — Audio and Playback preferences.
|
||||
//!
|
||||
//! Owns the two persistence stores (`AudioSettingsStore` from `qbz-audio`,
|
||||
//! `PlaybackPreferencesStore` from `qbz-app`) and bridges them to the
|
||||
//! `SettingsState` Slint global. Audio changes are persisted and then
|
||||
//! applied to the live `Player`: routing-critical changes (backend,
|
||||
//! device, sample rate, exclusive mode, DAC passthrough) trigger a device
|
||||
//! re-init; the rest only reload the settings struct.
|
||||
//!
|
||||
//! Neither store is exposed by `AppRuntime`, so this module opens them
|
||||
//! directly at the shared global path — the same path `AppRuntime::new`
|
||||
//! reads to seed the `Player`, so the two stay consistent.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use qbz_app::settings::playback::{
|
||||
AutoplayMode, PlaybackPreferencesState, PlaybackPreferencesStore,
|
||||
};
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_audio::backend::{AudioBackendType, BackendManager};
|
||||
use qbz_audio::settings::{AudioSettingsState, AudioSettingsStore};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::{AppWindow, SettingsState};
|
||||
|
||||
/// Sample-rate dropdown options. Index 0 is "Auto" (`None`).
|
||||
const SAMPLE_RATES: &[(&str, Option<u32>)] = &[
|
||||
("Auto", None),
|
||||
("44.1 kHz", Some(44_100)),
|
||||
("48 kHz", Some(48_000)),
|
||||
("88.2 kHz", Some(88_200)),
|
||||
("96 kHz", Some(96_000)),
|
||||
("176.4 kHz", Some(176_400)),
|
||||
("192 kHz", Some(192_000)),
|
||||
];
|
||||
|
||||
/// Autoplay-mode dropdown options.
|
||||
const AUTOPLAY_MODES: &[(&str, AutoplayMode)] = &[
|
||||
("Continue within source", AutoplayMode::ContinueWithinSource),
|
||||
("Play track only", AutoplayMode::PlayTrackOnly),
|
||||
("Infinite radio", AutoplayMode::InfiniteRadio),
|
||||
];
|
||||
|
||||
/// What a persisted audio change requires of the live `Player`.
|
||||
enum Apply {
|
||||
/// Not an audio setting — nothing to apply.
|
||||
None,
|
||||
/// Settings struct refresh only (gapless, normalization, ...).
|
||||
Reload,
|
||||
/// Routing-critical — also re-init the output device.
|
||||
Reinit,
|
||||
}
|
||||
|
||||
/// Index -> value maps the dropdown callbacks resolve against. The label
|
||||
/// lists live in `SettingsState`; these are the parallel value lists.
|
||||
#[derive(Default)]
|
||||
struct SettingsMaps {
|
||||
backends: Vec<AudioBackendType>,
|
||||
/// Device ids, parallel to `SettingsState.devices` labels. An empty
|
||||
/// id is the "System default" entry (`output_device = None`).
|
||||
devices: Vec<String>,
|
||||
}
|
||||
|
||||
/// Owns the settings stores and the dropdown index maps.
|
||||
pub struct SettingsCtx {
|
||||
audio: AudioSettingsState,
|
||||
playback: PlaybackPreferencesState,
|
||||
maps: Mutex<SettingsMaps>,
|
||||
}
|
||||
|
||||
impl SettingsCtx {
|
||||
/// Open both stores at the shared global path. A store that fails to
|
||||
/// open degrades to an empty (no-op) handle rather than aborting.
|
||||
pub fn open() -> Arc<Self> {
|
||||
let audio = AudioSettingsState::new().unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-slint] audio settings store unavailable: {e}");
|
||||
AudioSettingsState::new_empty()
|
||||
});
|
||||
let playback = PlaybackPreferencesState::new().unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-slint] playback preferences store unavailable: {e}");
|
||||
PlaybackPreferencesState::new_empty()
|
||||
});
|
||||
Arc::new(Self {
|
||||
audio,
|
||||
playback,
|
||||
maps: Mutex::new(SettingsMaps::default()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain, `Send` settings data built off the UI thread.
|
||||
pub struct SettingsSnapshot {
|
||||
backends: Vec<String>,
|
||||
backend_index: i32,
|
||||
devices: Vec<String>,
|
||||
device_index: i32,
|
||||
sample_rates: Vec<String>,
|
||||
sample_rate_index: i32,
|
||||
exclusive_mode: bool,
|
||||
dac_passthrough: bool,
|
||||
pw_force_bitperfect: bool,
|
||||
gapless: bool,
|
||||
normalization: bool,
|
||||
allow_quality_fallback: bool,
|
||||
autoplay_modes: Vec<String>,
|
||||
autoplay_index: i32,
|
||||
show_context_icon: bool,
|
||||
persist_session: bool,
|
||||
resume_position: bool,
|
||||
}
|
||||
|
||||
/// Devices enumerated for one backend: parallel label / id lists.
|
||||
struct DeviceList {
|
||||
labels: Vec<String>,
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn backend_label(t: AudioBackendType) -> String {
|
||||
match t {
|
||||
AudioBackendType::PipeWire => "PipeWire",
|
||||
AudioBackendType::Alsa => "ALSA",
|
||||
AudioBackendType::Pulse => "PulseAudio",
|
||||
AudioBackendType::SystemDefault => "System default",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Enumerate output devices for a backend. Always leads with a "System
|
||||
/// default" entry (empty id). Blocking — call off the UI thread.
|
||||
fn enumerate_devices(backend: AudioBackendType) -> DeviceList {
|
||||
let mut labels = vec!["System default".to_string()];
|
||||
let mut ids = vec![String::new()];
|
||||
match BackendManager::create_backend(backend).and_then(|b| b.enumerate_devices()) {
|
||||
Ok(devices) => {
|
||||
for d in devices {
|
||||
let label = match d.description.as_deref() {
|
||||
Some(desc) if !desc.is_empty() => desc.to_string(),
|
||||
_ => d.name.clone(),
|
||||
};
|
||||
labels.push(label);
|
||||
ids.push(d.id);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-slint] device enumeration failed: {e}"),
|
||||
}
|
||||
DeviceList { labels, ids }
|
||||
}
|
||||
|
||||
fn with_audio<T>(
|
||||
audio: &AudioSettingsState,
|
||||
f: impl FnOnce(&AudioSettingsStore) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let guard = audio
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "audio settings lock poisoned".to_string())?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "audio settings store not open".to_string())?;
|
||||
f(store)
|
||||
}
|
||||
|
||||
fn with_playback<T>(
|
||||
playback: &PlaybackPreferencesState,
|
||||
f: impl FnOnce(&PlaybackPreferencesStore) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let guard = playback
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "playback preferences lock poisoned".to_string())?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "playback preferences store not open".to_string())?;
|
||||
f(store)
|
||||
}
|
||||
|
||||
/// Read both stores and enumerate audio devices. Blocking (SQLite + device
|
||||
/// enumeration) — run inside `spawn_blocking`. Also fills the index maps.
|
||||
pub fn load_snapshot(ctx: &SettingsCtx) -> SettingsSnapshot {
|
||||
let audio = with_audio(&ctx.audio, |s| s.get_settings()).unwrap_or_default();
|
||||
let prefs = with_playback(&ctx.playback, |s| s.get_preferences()).unwrap_or_default();
|
||||
|
||||
let backend_types = BackendManager::available_backends();
|
||||
let current_backend = audio.backend_type.unwrap_or_default();
|
||||
let backend_index = backend_types
|
||||
.iter()
|
||||
.position(|t| *t == current_backend)
|
||||
.unwrap_or(0);
|
||||
let active_backend = backend_types
|
||||
.get(backend_index)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
|
||||
let device_list = enumerate_devices(active_backend);
|
||||
let device_index = match &audio.output_device {
|
||||
None => 0,
|
||||
Some(id) => device_list.ids.iter().position(|d| d == id).unwrap_or(0),
|
||||
};
|
||||
|
||||
let sample_rate_index = SAMPLE_RATES
|
||||
.iter()
|
||||
.position(|(_, r)| *r == audio.preferred_sample_rate)
|
||||
.unwrap_or(0);
|
||||
let autoplay_index = AUTOPLAY_MODES
|
||||
.iter()
|
||||
.position(|(_, m)| *m == prefs.autoplay_mode)
|
||||
.unwrap_or(0);
|
||||
|
||||
{
|
||||
let mut maps = ctx.maps.lock().unwrap_or_else(|e| e.into_inner());
|
||||
maps.backends = backend_types.clone();
|
||||
maps.devices = device_list.ids.clone();
|
||||
}
|
||||
|
||||
SettingsSnapshot {
|
||||
backends: backend_types.iter().map(|t| backend_label(*t)).collect(),
|
||||
backend_index: backend_index as i32,
|
||||
devices: device_list.labels,
|
||||
device_index: device_index as i32,
|
||||
sample_rates: SAMPLE_RATES.iter().map(|(l, _)| l.to_string()).collect(),
|
||||
sample_rate_index: sample_rate_index as i32,
|
||||
exclusive_mode: audio.exclusive_mode,
|
||||
dac_passthrough: audio.dac_passthrough,
|
||||
pw_force_bitperfect: audio.pw_force_bitperfect,
|
||||
gapless: audio.gapless_enabled,
|
||||
normalization: audio.normalization_enabled,
|
||||
allow_quality_fallback: audio.allow_quality_fallback,
|
||||
autoplay_modes: AUTOPLAY_MODES.iter().map(|(l, _)| l.to_string()).collect(),
|
||||
autoplay_index: autoplay_index as i32,
|
||||
show_context_icon: prefs.show_context_icon,
|
||||
persist_session: prefs.persist_session,
|
||||
resume_position: prefs.resume_playback_position,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_model(items: Vec<String>) -> ModelRc<SharedString> {
|
||||
ModelRc::new(VecModel::from(
|
||||
items
|
||||
.into_iter()
|
||||
.map(SharedString::from)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Push a snapshot onto the `SettingsState` global. Runs on the UI thread.
|
||||
pub fn apply_snapshot(window: &AppWindow, snap: SettingsSnapshot) {
|
||||
let st = window.global::<SettingsState>();
|
||||
st.set_backends(string_model(snap.backends));
|
||||
st.set_backend_index(snap.backend_index);
|
||||
st.set_devices(string_model(snap.devices));
|
||||
st.set_device_index(snap.device_index);
|
||||
st.set_sample_rates(string_model(snap.sample_rates));
|
||||
st.set_sample_rate_index(snap.sample_rate_index);
|
||||
st.set_exclusive_mode(snap.exclusive_mode);
|
||||
st.set_dac_passthrough(snap.dac_passthrough);
|
||||
st.set_pw_force_bitperfect(snap.pw_force_bitperfect);
|
||||
st.set_gapless(snap.gapless);
|
||||
st.set_normalization(snap.normalization);
|
||||
st.set_allow_quality_fallback(snap.allow_quality_fallback);
|
||||
st.set_autoplay_modes(string_model(snap.autoplay_modes));
|
||||
st.set_autoplay_index(snap.autoplay_index);
|
||||
st.set_show_context_icon(snap.show_context_icon);
|
||||
st.set_persist_session(snap.persist_session);
|
||||
st.set_resume_position(snap.resume_position);
|
||||
st.set_loading(false);
|
||||
}
|
||||
|
||||
/// Re-read the persisted audio settings and apply them to the live player.
|
||||
fn apply_audio(ctx: &SettingsCtx, runtime: &AppRuntime<SlintAdapter>, apply: Apply) {
|
||||
let reinit = match apply {
|
||||
Apply::None => return,
|
||||
Apply::Reload => false,
|
||||
Apply::Reinit => true,
|
||||
};
|
||||
let fresh = match with_audio(&ctx.audio, |s| s.get_settings()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] re-read audio settings failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let player = runtime.core().player();
|
||||
if let Err(e) = player.reload_settings(fresh.clone()) {
|
||||
log::error!("[qbz-slint] player.reload_settings failed: {e}");
|
||||
}
|
||||
if reinit {
|
||||
if let Err(e) = player.reinit_device(fresh.output_device.clone()) {
|
||||
log::error!("[qbz-slint] player.reinit_device failed: {e}");
|
||||
}
|
||||
}
|
||||
log::info!("[qbz-slint] audio settings applied to player (reinit={reinit})");
|
||||
}
|
||||
|
||||
/// Handle a toggle change: persist it, then apply audio ones to the player.
|
||||
pub fn handle_bool(ctx: &SettingsCtx, runtime: &AppRuntime<SlintAdapter>, key: &str, value: bool) {
|
||||
let outcome: Result<Apply, String> = match key {
|
||||
"exclusive-mode" => {
|
||||
with_audio(&ctx.audio, |s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit)
|
||||
}
|
||||
"dac-passthrough" => {
|
||||
with_audio(&ctx.audio, |s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit)
|
||||
}
|
||||
"pw-force-bitperfect" => {
|
||||
with_audio(&ctx.audio, |s| s.set_pw_force_bitperfect(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"gapless" => {
|
||||
with_audio(&ctx.audio, |s| s.set_gapless_enabled(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"normalization" => {
|
||||
with_audio(&ctx.audio, |s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"allow-quality-fallback" => {
|
||||
with_audio(&ctx.audio, |s| s.set_allow_quality_fallback(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"show-context-icon" => {
|
||||
with_playback(&ctx.playback, |s| s.set_show_context_icon(value)).map(|_| Apply::None)
|
||||
}
|
||||
"persist-session" => {
|
||||
with_playback(&ctx.playback, |s| s.set_persist_session(value)).map(|_| Apply::None)
|
||||
}
|
||||
"resume-position" => with_playback(&ctx.playback, |s| {
|
||||
s.set_resume_playback_position(value)
|
||||
})
|
||||
.map(|_| Apply::None),
|
||||
other => {
|
||||
log::warn!("[qbz-slint] unknown settings bool key: {other}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(apply) => apply_audio(ctx, runtime, apply),
|
||||
Err(e) => log::error!("[qbz-slint] failed to persist '{key}': {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a dropdown change: persist it, apply audio ones to the player,
|
||||
/// and — for a backend switch — re-enumerate devices into `SettingsState`.
|
||||
pub async fn handle_select(
|
||||
ctx: Arc<SettingsCtx>,
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
key: String,
|
||||
index: usize,
|
||||
) {
|
||||
match key.as_str() {
|
||||
"backend" => {
|
||||
let backend = ctx
|
||||
.maps
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.backends
|
||||
.get(index)
|
||||
.copied();
|
||||
let Some(backend) = backend else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_backend_type(Some(backend))) {
|
||||
log::error!("[qbz-slint] persist backend failed: {e}");
|
||||
return;
|
||||
}
|
||||
// A backend switch invalidates the device list; re-enumerate
|
||||
// and reset routing to the system default.
|
||||
let device_list =
|
||||
match tokio::task::spawn_blocking(move || enumerate_devices(backend)).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] device enumeration task failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_output_device(None)) {
|
||||
log::error!("[qbz-slint] reset output device failed: {e}");
|
||||
}
|
||||
{
|
||||
let mut maps = ctx.maps.lock().unwrap_or_else(|e| e.into_inner());
|
||||
maps.devices = device_list.ids.clone();
|
||||
}
|
||||
let labels = device_list.labels;
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<SettingsState>();
|
||||
st.set_devices(string_model(labels));
|
||||
st.set_device_index(0);
|
||||
});
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
"device" => {
|
||||
let id = ctx
|
||||
.maps
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.devices
|
||||
.get(index)
|
||||
.cloned();
|
||||
let Some(id) = id else {
|
||||
return;
|
||||
};
|
||||
let device_opt = if id.is_empty() { None } else { Some(id.as_str()) };
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_output_device(device_opt)) {
|
||||
log::error!("[qbz-slint] persist device failed: {e}");
|
||||
return;
|
||||
}
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
"sample-rate" => {
|
||||
let rate = SAMPLE_RATES.get(index).map(|(_, r)| *r).unwrap_or(None);
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_sample_rate(rate)) {
|
||||
log::error!("[qbz-slint] persist sample rate failed: {e}");
|
||||
return;
|
||||
}
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
"autoplay-mode" => {
|
||||
let mode = AUTOPLAY_MODES
|
||||
.get(index)
|
||||
.map(|(_, m)| *m)
|
||||
.unwrap_or_default();
|
||||
if let Err(e) = with_playback(&ctx.playback, |s| s.set_autoplay_mode(mode)) {
|
||||
log::error!("[qbz-slint] persist autoplay mode failed: {e}");
|
||||
}
|
||||
}
|
||||
other => log::warn!("[qbz-slint] unknown settings select key: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_rate_table_starts_with_auto() {
|
||||
assert_eq!(SAMPLE_RATES[0].1, None);
|
||||
assert_eq!(SAMPLE_RATES[4].1, Some(96_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoplay_table_matches_default() {
|
||||
assert_eq!(AUTOPLAY_MODES[0].1, AutoplayMode::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_labels_are_distinct() {
|
||||
let labels: Vec<_> = [
|
||||
AudioBackendType::PipeWire,
|
||||
AudioBackendType::Alsa,
|
||||
AudioBackendType::Pulse,
|
||||
AudioBackendType::SystemDefault,
|
||||
]
|
||||
.iter()
|
||||
.map(|t| backend_label(*t))
|
||||
.collect();
|
||||
let mut deduped = labels.clone();
|
||||
deduped.sort();
|
||||
deduped.dedup();
|
||||
assert_eq!(labels.len(), deduped.len());
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user