diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index ac07ab7e6..ac7bc7225 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -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" } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index c576a4d1b..ead4a07b5 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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>, weak: slint::Weak, image_cache: artwork::ImageCache, + settings_ctx: Arc, 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> { 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> { 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> { 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> { 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> { }); } + // 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(); diff --git a/crates/qbz-slint/src/settings.rs b/crates/qbz-slint/src/settings.rs new file mode 100644 index 000000000..38b719168 --- /dev/null +++ b/crates/qbz-slint/src/settings.rs @@ -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)] = &[ + ("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, + /// Device ids, parallel to `SettingsState.devices` labels. An empty + /// id is the "System default" entry (`output_device = None`). + devices: Vec, +} + +/// Owns the settings stores and the dropdown index maps. +pub struct SettingsCtx { + audio: AudioSettingsState, + playback: PlaybackPreferencesState, + maps: Mutex, +} + +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 { + 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, + backend_index: i32, + devices: Vec, + device_index: i32, + sample_rates: Vec, + 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, + 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, + ids: Vec, +} + +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( + audio: &AudioSettingsState, + f: impl FnOnce(&AudioSettingsStore) -> Result, +) -> Result { + 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( + playback: &PlaybackPreferencesState, + f: impl FnOnce(&PlaybackPreferencesStore) -> Result, +) -> Result { + 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) -> ModelRc { + ModelRc::new(VecModel::from( + items + .into_iter() + .map(SharedString::from) + .collect::>(), + )) +} + +/// Push a snapshot onto the `SettingsState` global. Runs on the UI thread. +pub fn apply_snapshot(window: &AppWindow, snap: SettingsSnapshot) { + let st = window.global::(); + 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, 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, key: &str, value: bool) { + let outcome: Result = 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, + runtime: Arc>, + weak: slint::Weak, + 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::(); + 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()); + } +} diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 04993628d..45228a43b 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -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(); } diff --git a/crates/qbz-slint/ui/assets/icons/chevron-down.svg b/crates/qbz-slint/ui/assets/icons/chevron-down.svg new file mode 100644 index 000000000..d11026754 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index 59a331cad..57f32c964 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -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; + } + } } } } diff --git a/crates/qbz-slint/ui/primitives/QbzSelect.slint b/crates/qbz-slint/ui/primitives/QbzSelect.slint new file mode 100644 index 000000000..6198067f9 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/QbzSelect.slint @@ -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 current-index: 0; + in property menu-width: 240px; + in property 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); + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/QbzToggle.slint b/crates/qbz-slint/ui/primitives/QbzToggle.slint new file mode 100644 index 000000000..b6e123034 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/QbzToggle.slint @@ -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 checked: false; + in property 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); + } + } + } +} diff --git a/crates/qbz-slint/ui/settings/AudioSettings.slint b/crates/qbz-slint/ui/settings/AudioSettings.slint new file mode 100644 index 000000000..836e3ee43 --- /dev/null +++ b/crates/qbz-slint/ui/settings/AudioSettings.slint @@ -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); + } + } + } +} diff --git a/crates/qbz-slint/ui/settings/PlaybackSettings.slint b/crates/qbz-slint/ui/settings/PlaybackSettings.slint new file mode 100644 index 000000000..1ce842f11 --- /dev/null +++ b/crates/qbz-slint/ui/settings/PlaybackSettings.slint @@ -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); + } + } + } +} diff --git a/crates/qbz-slint/ui/settings/SettingRow.slint b/crates/qbz-slint/ui/settings/SettingRow.slint new file mode 100644 index 000000000..f15ecc787 --- /dev/null +++ b/crates/qbz-slint/ui/settings/SettingRow.slint @@ -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 label; + in property description; + in property 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 + } +} diff --git a/crates/qbz-slint/ui/settings/SettingsView.slint b/crates/qbz-slint/ui/settings/SettingsView.slint new file mode 100644 index 000000000..45c14ebb3 --- /dev/null +++ b/crates/qbz-slint/ui/settings/SettingsView.slint @@ -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 icon; + in property label; + in property 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); + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index c2fb5fb9c..1f3d54fd0 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -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); + } + } } } diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index b972454b4..3b6065f01 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -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"); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 87b51ea8f..30f528333 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -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 section: 0; + in property 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 backend-index: 0; + in-out property <[string]> devices: []; + in-out property device-index: 0; + in-out property <[string]> sample-rates: []; + in-out property sample-rate-index: 0; + + // Audio — toggles. + in-out property exclusive-mode: false; + in-out property dac-passthrough: false; + in-out property pw-force-bitperfect: false; + in-out property gapless: true; + in-out property normalization: false; + in-out property allow-quality-fallback: false; + + // Playback. + in-out property <[string]> autoplay-modes: []; + in-out property autoplay-index: 0; + in-out property show-context-icon: false; + in-out property persist-session: false; + in-out property resume-position: false; +} export global NavState { in-out property view: ContentView.home;