feat(slint): Settings Audio/Playback 1:1 with Tauri
Rebuild both Settings panels to match the Tauri app — every option present, on the correct tab, and fully functional (persisted and applied to the live player). Audio: streaming quality, limit-quality-to-device, maximum sample rate, backend, output device, ALSA plugin, hardware volume, exclusive mode, reserve DAC, DAC passthrough, force bit-perfect, allow quality fallback, sync-on-startup, lock output device, and a reset button. Conditional rows (ALSA plugin, hardware volume, max sample rate, force bit-perfect, lock output device) are gated on Rust-computed flags. Playback: continue-after-track, show context icon, gapless, restore session, resume position, stream uncached, initial buffer size (slider), streaming only, quality-retry behavior. Note gapless and the streaming options live on the Playback tab though the qbz-audio struct stores them. - ui_prefs.rs: JSON store for the frontend-only streaming-quality pref - QbzSlider primitive for the integer buffer-size control - volume normalization row removed (Tauri hides it) Caveat: toggling reserve-DAC persists and is read live, but re-arming the device reservation guard mid-session needs a player-side API that does not exist yet; it takes effect on next launch. cc
This commit is contained in:
9 files changed
+799
-138
No files matched your search
@@ -24,6 +24,7 @@ mod home;
|
||||
mod nav;
|
||||
mod recently;
|
||||
mod settings;
|
||||
mod ui_prefs;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -521,6 +522,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Settings — a slider changed (Initial Buffer Size): persist it and
|
||||
// reload the player settings.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_settings_slider(move |key, value| {
|
||||
let runtime = runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let key = key.to_string();
|
||||
handle.spawn(async move {
|
||||
settings::handle_slider(&settings_ctx, &runtime, &key, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Settings — Reset: restore Audio + Playback defaults, rebuild the
|
||||
// snapshot, and re-apply the audio settings to the player.
|
||||
{
|
||||
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_reset(move || {
|
||||
let runtime = runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
settings::handle_reset(settings_ctx, runtime, weak).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Context-menu / overlay media actions. Playback wiring lands with the
|
||||
// playback session; for now the action is logged.
|
||||
window.on_media_action(move |kind, id, action| {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! 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.
|
||||
//! `PlaybackPreferencesStore` from `qbz-app`) plus the JSON `ui_prefs`
|
||||
//! store (Streaming Quality), and bridges them to the `SettingsState`
|
||||
//! Slint global.
|
||||
//!
|
||||
//! 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.
|
||||
//! Audio changes are persisted and then applied to the live `Player`:
|
||||
//! routing-critical changes (backend, output device, max sample rate,
|
||||
//! exclusive mode, DAC passthrough, ALSA plugin) trigger a device
|
||||
//! re-init; the rest only reload the settings struct. Playback-preference
|
||||
//! changes (autoplay, show-context, persist, resume) just persist.
|
||||
//!
|
||||
//! Neither domain 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};
|
||||
|
||||
@@ -17,36 +22,48 @@ use qbz_app::settings::playback::{
|
||||
AutoplayMode, PlaybackPreferencesState, PlaybackPreferencesStore,
|
||||
};
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_audio::backend::{AudioBackendType, BackendManager};
|
||||
use qbz_audio::backend::{AlsaPlugin, AudioBackendType, BackendManager};
|
||||
use qbz_audio::settings::{AudioSettingsState, AudioSettingsStore};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::ui_prefs::{self, STREAMING_QUALITIES};
|
||||
use crate::{AppWindow, SettingsState};
|
||||
|
||||
/// Sample-rate dropdown options. Index 0 is "Auto" (`None`).
|
||||
const SAMPLE_RATES: &[(&str, Option<u32>)] = &[
|
||||
("Auto", None),
|
||||
/// Maximum-sample-rate dropdown options. Index 0 is "No limit" (`None`).
|
||||
/// Backs `device_max_sample_rate`.
|
||||
const MAX_SAMPLE_RATES: &[(&str, Option<u32>)] = &[
|
||||
("No limit", 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)),
|
||||
("352.8 kHz", Some(352_800)),
|
||||
("384 kHz", Some(384_000)),
|
||||
];
|
||||
|
||||
/// Autoplay-mode dropdown options.
|
||||
const AUTOPLAY_MODES: &[(&str, AutoplayMode)] = &[
|
||||
("Continue within source", AutoplayMode::ContinueWithinSource),
|
||||
("Play track only", AutoplayMode::PlayTrackOnly),
|
||||
("Infinite radio", AutoplayMode::InfiniteRadio),
|
||||
/// ALSA-plugin dropdown options.
|
||||
const ALSA_PLUGINS: &[(&str, AlsaPlugin)] = &[
|
||||
("hw (Direct Hardware)", AlsaPlugin::Hw),
|
||||
("plughw (Auto-convert)", AlsaPlugin::PlugHw),
|
||||
("pcm (Most compatible)", AlsaPlugin::Pcm),
|
||||
];
|
||||
|
||||
/// "When quality retries fail" dropdown options. The value is the
|
||||
/// `quality_fallback_behavior` DB string.
|
||||
const RETRY_BEHAVIORS: &[(&str, &str)] = &[
|
||||
("Ask me", "ask"),
|
||||
("Always try lowest quality", "always_fallback"),
|
||||
("Always skip track", "always_skip"),
|
||||
];
|
||||
|
||||
/// What a persisted audio change requires of the live `Player`.
|
||||
enum Apply {
|
||||
/// Not an audio setting — nothing to apply.
|
||||
/// Not a player-applied setting — nothing to apply.
|
||||
None,
|
||||
/// Settings struct refresh only (gapless, normalization, ...).
|
||||
/// Settings struct refresh only (gapless, fallback, stream-*, ...).
|
||||
Reload,
|
||||
/// Routing-critical — also re-init the output device.
|
||||
Reinit,
|
||||
@@ -70,8 +87,9 @@ pub struct SettingsCtx {
|
||||
}
|
||||
|
||||
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.
|
||||
/// Open both domain 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}");
|
||||
@@ -91,23 +109,42 @@ impl SettingsCtx {
|
||||
|
||||
/// Plain, `Send` settings data built off the UI thread.
|
||||
pub struct SettingsSnapshot {
|
||||
// Audio — dropdowns.
|
||||
streaming_qualities: Vec<String>,
|
||||
streaming_quality_index: i32,
|
||||
sample_rates: Vec<String>,
|
||||
sample_rate_index: i32,
|
||||
backends: Vec<String>,
|
||||
backend_index: i32,
|
||||
devices: Vec<String>,
|
||||
device_index: i32,
|
||||
sample_rates: Vec<String>,
|
||||
sample_rate_index: i32,
|
||||
alsa_plugins: Vec<String>,
|
||||
alsa_plugin_index: i32,
|
||||
// Audio — toggles.
|
||||
limit_quality_to_device: bool,
|
||||
alsa_hardware_volume: bool,
|
||||
exclusive_mode: bool,
|
||||
reserve_dac: bool,
|
||||
dac_passthrough: bool,
|
||||
pw_force_bitperfect: bool,
|
||||
gapless: bool,
|
||||
normalization: bool,
|
||||
allow_quality_fallback: bool,
|
||||
autoplay_modes: Vec<String>,
|
||||
autoplay_index: i32,
|
||||
sync_audio_on_startup: bool,
|
||||
skip_sink_switch: bool,
|
||||
// Audio — conditional flags.
|
||||
backend_is_alsa: bool,
|
||||
backend_is_pipewire: bool,
|
||||
alsa_plugin_is_hw: bool,
|
||||
// Playback.
|
||||
continue_playback: bool,
|
||||
show_context_icon: bool,
|
||||
persist_session: bool,
|
||||
resume_position: bool,
|
||||
gapless: bool,
|
||||
stream_uncached: bool,
|
||||
streaming_only: bool,
|
||||
buffer_seconds: i32,
|
||||
retry_behaviors: Vec<String>,
|
||||
retry_behavior_index: i32,
|
||||
}
|
||||
|
||||
/// Devices enumerated for one backend: parallel label / id lists.
|
||||
@@ -175,12 +212,15 @@ fn with_playback<T>(
|
||||
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();
|
||||
|
||||
/// Build a snapshot from already-read settings. Splitting this out lets
|
||||
/// `load_snapshot` and a post-reset rebuild share the device-enumeration
|
||||
/// and index-mapping logic.
|
||||
fn build_snapshot(
|
||||
ctx: &SettingsCtx,
|
||||
audio: qbz_audio::settings::AudioSettings,
|
||||
prefs: qbz_app::settings::playback::PlaybackPreferences,
|
||||
streaming_quality_key: &str,
|
||||
) -> SettingsSnapshot {
|
||||
let backend_types = BackendManager::available_backends();
|
||||
let current_backend = audio.backend_type.unwrap_or_default();
|
||||
let backend_index = backend_types
|
||||
@@ -198,14 +238,24 @@ pub fn load_snapshot(ctx: &SettingsCtx) -> SettingsSnapshot {
|
||||
Some(id) => device_list.ids.iter().position(|d| d == id).unwrap_or(0),
|
||||
};
|
||||
|
||||
let sample_rate_index = SAMPLE_RATES
|
||||
let sample_rate_index = MAX_SAMPLE_RATES
|
||||
.iter()
|
||||
.position(|(_, r)| *r == audio.preferred_sample_rate)
|
||||
.position(|(_, r)| *r == audio.device_max_sample_rate)
|
||||
.unwrap_or(0);
|
||||
let autoplay_index = AUTOPLAY_MODES
|
||||
let alsa_plugin = audio.alsa_plugin.unwrap_or(AlsaPlugin::Hw);
|
||||
let alsa_plugin_index = ALSA_PLUGINS
|
||||
.iter()
|
||||
.position(|(_, m)| *m == prefs.autoplay_mode)
|
||||
.position(|(_, p)| *p == alsa_plugin)
|
||||
.unwrap_or(0);
|
||||
let retry_behavior_index = RETRY_BEHAVIORS
|
||||
.iter()
|
||||
.position(|(_, v)| *v == audio.quality_fallback_behavior)
|
||||
.unwrap_or(0);
|
||||
|
||||
let backend_is_alsa = active_backend == AudioBackendType::Alsa;
|
||||
let backend_is_pipewire = active_backend == AudioBackendType::PipeWire;
|
||||
let alsa_plugin_is_hw = alsa_plugin == AlsaPlugin::Hw;
|
||||
let continue_playback = prefs.autoplay_mode == AutoplayMode::ContinueWithinSource;
|
||||
|
||||
{
|
||||
let mut maps = ctx.maps.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -214,26 +264,54 @@ pub fn load_snapshot(ctx: &SettingsCtx) -> SettingsSnapshot {
|
||||
}
|
||||
|
||||
SettingsSnapshot {
|
||||
streaming_qualities: STREAMING_QUALITIES
|
||||
.iter()
|
||||
.map(|q| q.label.to_string())
|
||||
.collect(),
|
||||
streaming_quality_index: ui_prefs::streaming_quality_index(streaming_quality_key) as i32,
|
||||
sample_rates: MAX_SAMPLE_RATES.iter().map(|(l, _)| l.to_string()).collect(),
|
||||
sample_rate_index: sample_rate_index as i32,
|
||||
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,
|
||||
alsa_plugins: ALSA_PLUGINS.iter().map(|(l, _)| l.to_string()).collect(),
|
||||
alsa_plugin_index: alsa_plugin_index as i32,
|
||||
limit_quality_to_device: audio.limit_quality_to_device,
|
||||
alsa_hardware_volume: audio.alsa_hardware_volume,
|
||||
exclusive_mode: audio.exclusive_mode,
|
||||
reserve_dac: audio.reserve_dac_while_running,
|
||||
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,
|
||||
sync_audio_on_startup: audio.sync_audio_on_startup,
|
||||
skip_sink_switch: audio.skip_sink_switch,
|
||||
backend_is_alsa,
|
||||
backend_is_pipewire,
|
||||
alsa_plugin_is_hw,
|
||||
continue_playback,
|
||||
show_context_icon: prefs.show_context_icon,
|
||||
persist_session: prefs.persist_session,
|
||||
resume_position: prefs.resume_playback_position,
|
||||
gapless: audio.gapless_enabled,
|
||||
stream_uncached: audio.stream_first_track,
|
||||
streaming_only: audio.streaming_only,
|
||||
buffer_seconds: audio.stream_buffer_seconds as i32,
|
||||
retry_behaviors: RETRY_BEHAVIORS.iter().map(|(l, _)| l.to_string()).collect(),
|
||||
retry_behavior_index: retry_behavior_index as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read both domain stores, the JSON UI prefs, 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 ui = ui_prefs::load();
|
||||
build_snapshot(ctx, audio, prefs, &ui.streaming_quality)
|
||||
}
|
||||
|
||||
fn string_model(items: Vec<String>) -> ModelRc<SharedString> {
|
||||
ModelRc::new(VecModel::from(
|
||||
items
|
||||
@@ -246,23 +324,42 @@ fn string_model(items: Vec<String>) -> ModelRc<SharedString> {
|
||||
/// 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>();
|
||||
// Audio — dropdowns.
|
||||
st.set_streaming_qualities(string_model(snap.streaming_qualities));
|
||||
st.set_streaming_quality_index(snap.streaming_quality_index);
|
||||
st.set_sample_rates(string_model(snap.sample_rates));
|
||||
st.set_sample_rate_index(snap.sample_rate_index);
|
||||
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_alsa_plugins(string_model(snap.alsa_plugins));
|
||||
st.set_alsa_plugin_index(snap.alsa_plugin_index);
|
||||
// Audio — toggles.
|
||||
st.set_limit_quality_to_device(snap.limit_quality_to_device);
|
||||
st.set_alsa_hardware_volume(snap.alsa_hardware_volume);
|
||||
st.set_exclusive_mode(snap.exclusive_mode);
|
||||
st.set_reserve_dac(snap.reserve_dac);
|
||||
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_sync_audio_on_startup(snap.sync_audio_on_startup);
|
||||
st.set_skip_sink_switch(snap.skip_sink_switch);
|
||||
// Audio — conditional flags.
|
||||
st.set_backend_is_alsa(snap.backend_is_alsa);
|
||||
st.set_backend_is_pipewire(snap.backend_is_pipewire);
|
||||
st.set_alsa_plugin_is_hw(snap.alsa_plugin_is_hw);
|
||||
// Playback.
|
||||
st.set_continue_playback(snap.continue_playback);
|
||||
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_gapless(snap.gapless);
|
||||
st.set_stream_uncached(snap.stream_uncached);
|
||||
st.set_streaming_only(snap.streaming_only);
|
||||
st.set_buffer_seconds(snap.buffer_seconds);
|
||||
st.set_retry_behaviors(string_model(snap.retry_behaviors));
|
||||
st.set_retry_behavior_index(snap.retry_behavior_index);
|
||||
st.set_loading(false);
|
||||
}
|
||||
|
||||
@@ -292,26 +389,100 @@ fn apply_audio(ctx: &SettingsCtx, runtime: &AppRuntime<SlintAdapter>, apply: App
|
||||
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) {
|
||||
/// Recompute the backend/ALSA conditional flags from the current audio
|
||||
/// settings and push them onto `SettingsState`. Called after a backend or
|
||||
/// ALSA-plugin change so the `.slint` panels re-gate the conditional rows.
|
||||
fn push_conditional_flags(ctx: &SettingsCtx, weak: &slint::Weak<AppWindow>) {
|
||||
let audio = match with_audio(&ctx.audio, |s| s.get_settings()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] re-read audio settings for flags failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let backend = audio.backend_type.unwrap_or_default();
|
||||
let plugin = audio.alsa_plugin.unwrap_or(AlsaPlugin::Hw);
|
||||
let is_alsa = backend == AudioBackendType::Alsa;
|
||||
let is_pipewire = backend == AudioBackendType::PipeWire;
|
||||
let plugin_is_hw = plugin == AlsaPlugin::Hw;
|
||||
let plugin_index = ALSA_PLUGINS
|
||||
.iter()
|
||||
.position(|(_, p)| *p == plugin)
|
||||
.unwrap_or(0) as i32;
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<SettingsState>();
|
||||
st.set_backend_is_alsa(is_alsa);
|
||||
st.set_backend_is_pipewire(is_pipewire);
|
||||
st.set_alsa_plugin_is_hw(plugin_is_hw);
|
||||
st.set_alsa_plugin_index(plugin_index);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle a toggle change: persist it, then apply audio ones to the
|
||||
/// player.
|
||||
///
|
||||
/// No audio toggle moves the backend or ALSA plugin, so no conditional
|
||||
/// flag recompute is needed here — the rows gated on toggle state
|
||||
/// (`dac-passthrough`, `limit-quality-to-device`, `stream-uncached`,
|
||||
/// `persist-session`) read `SettingsState` booleans directly in Slint.
|
||||
/// Backend / ALSA-plugin flag recomputes happen in `handle_select`.
|
||||
pub fn handle_bool(
|
||||
ctx: &SettingsCtx,
|
||||
runtime: &AppRuntime<SlintAdapter>,
|
||||
key: &str,
|
||||
value: bool,
|
||||
) {
|
||||
let outcome: Result<Apply, String> = match key {
|
||||
// --- Audio toggles -------------------------------------------------
|
||||
"limit-quality-to-device" => {
|
||||
with_audio(&ctx.audio, |s| s.set_limit_quality_to_device(value))
|
||||
.map(|_| Apply::Reload)
|
||||
}
|
||||
"alsa-hardware-volume" => {
|
||||
with_audio(&ctx.audio, |s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit)
|
||||
}
|
||||
"exclusive-mode" => {
|
||||
with_audio(&ctx.audio, |s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit)
|
||||
}
|
||||
"reserve-dac" => {
|
||||
with_audio(&ctx.audio, |s| s.set_reserve_dac_while_running(value))
|
||||
.map(|_| Apply::Reload)
|
||||
}
|
||||
"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)
|
||||
}
|
||||
"allow-quality-fallback" => {
|
||||
with_audio(&ctx.audio, |s| s.set_allow_quality_fallback(value))
|
||||
.map(|_| Apply::Reload)
|
||||
}
|
||||
"sync-audio-on-startup" => {
|
||||
with_audio(&ctx.audio, |s| s.set_sync_audio_on_startup(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"skip-sink-switch" => {
|
||||
with_audio(&ctx.audio, |s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit)
|
||||
}
|
||||
// --- Playback toggles backed by AudioSettings ----------------------
|
||||
"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)
|
||||
"stream-uncached" => {
|
||||
with_audio(&ctx.audio, |s| s.set_stream_first_track(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
"allow-quality-fallback" => {
|
||||
with_audio(&ctx.audio, |s| s.set_allow_quality_fallback(value)).map(|_| Apply::Reload)
|
||||
"streaming-only" => {
|
||||
with_audio(&ctx.audio, |s| s.set_streaming_only(value)).map(|_| Apply::Reload)
|
||||
}
|
||||
// --- Playback toggles backed by PlaybackPreferences ----------------
|
||||
"continue-playback" => {
|
||||
// On = ContinueWithinSource, off = PlayTrackOnly.
|
||||
let mode = if value {
|
||||
AutoplayMode::ContinueWithinSource
|
||||
} else {
|
||||
AutoplayMode::PlayTrackOnly
|
||||
};
|
||||
with_playback(&ctx.playback, |s| s.set_autoplay_mode(mode)).map(|_| Apply::None)
|
||||
}
|
||||
"show-context-icon" => {
|
||||
with_playback(&ctx.playback, |s| s.set_show_context_icon(value)).map(|_| Apply::None)
|
||||
@@ -334,8 +505,29 @@ pub fn handle_bool(ctx: &SettingsCtx, runtime: &AppRuntime<SlintAdapter>, key: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a slider change: persist it and reload the player settings.
|
||||
/// Currently only the Initial Buffer Size slider exists.
|
||||
pub fn handle_slider(
|
||||
ctx: &SettingsCtx,
|
||||
runtime: &AppRuntime<SlintAdapter>,
|
||||
key: &str,
|
||||
value: i32,
|
||||
) {
|
||||
match key {
|
||||
"buffer-seconds" => {
|
||||
let seconds = value.clamp(1, 10) as u8;
|
||||
match with_audio(&ctx.audio, |s| s.set_stream_buffer_seconds(seconds)) {
|
||||
Ok(()) => apply_audio(ctx, runtime, Apply::Reload),
|
||||
Err(e) => log::error!("[qbz-slint] persist buffer seconds failed: {e}"),
|
||||
}
|
||||
}
|
||||
other => log::warn!("[qbz-slint] unknown settings slider key: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a dropdown change: persist it, apply audio ones to the player,
|
||||
/// and — for a backend switch — re-enumerate devices into `SettingsState`.
|
||||
/// and — for a backend switch — re-enumerate devices and recompute the
|
||||
/// conditional flags into `SettingsState`.
|
||||
pub async fn handle_select(
|
||||
ctx: Arc<SettingsCtx>,
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
@@ -344,6 +536,23 @@ pub async fn handle_select(
|
||||
index: usize,
|
||||
) {
|
||||
match key.as_str() {
|
||||
"streaming-quality" => {
|
||||
// UI-only preference, persisted to ui_prefs.json.
|
||||
let Some(quality) = STREAMING_QUALITIES.get(index) else {
|
||||
return;
|
||||
};
|
||||
let mut prefs = ui_prefs::load();
|
||||
prefs.streaming_quality = quality.key.to_string();
|
||||
ui_prefs::save(&prefs);
|
||||
}
|
||||
"sample-rate" => {
|
||||
let rate = MAX_SAMPLE_RATES.get(index).map(|(_, r)| *r).unwrap_or(None);
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_device_max_sample_rate(rate)) {
|
||||
log::error!("[qbz-slint] persist max sample rate failed: {e}");
|
||||
return;
|
||||
}
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
"backend" => {
|
||||
let backend = ctx
|
||||
.maps
|
||||
@@ -382,6 +591,8 @@ pub async fn handle_select(
|
||||
st.set_devices(string_model(labels));
|
||||
st.set_device_index(0);
|
||||
});
|
||||
// Recompute conditional flags (backend-is-alsa / -pipewire).
|
||||
push_conditional_flags(&ctx, &weak);
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
"device" => {
|
||||
@@ -402,40 +613,86 @@ pub async fn handle_select(
|
||||
}
|
||||
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}");
|
||||
"alsa-plugin" => {
|
||||
let plugin = ALSA_PLUGINS.get(index).map(|(_, p)| *p);
|
||||
let Some(plugin) = plugin else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_alsa_plugin(Some(plugin))) {
|
||||
log::error!("[qbz-slint] persist ALSA plugin failed: {e}");
|
||||
return;
|
||||
}
|
||||
// ALSA plugin gates the Hardware Volume Control row.
|
||||
push_conditional_flags(&ctx, &weak);
|
||||
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}");
|
||||
"retry-behavior" => {
|
||||
let behavior = RETRY_BEHAVIORS.get(index).map(|(_, v)| *v).unwrap_or("ask");
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.set_quality_fallback_behavior(behavior)) {
|
||||
log::error!("[qbz-slint] persist retry behavior failed: {e}");
|
||||
return;
|
||||
}
|
||||
apply_audio(&ctx, &runtime, Apply::Reload);
|
||||
}
|
||||
other => log::warn!("[qbz-slint] unknown settings select key: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all Audio + Playback settings to defaults, rebuild the snapshot,
|
||||
/// push it onto `SettingsState`, and re-apply the audio settings to the
|
||||
/// player. Streaming Quality (a UI-only pref) is intentionally left
|
||||
/// untouched — it is not part of either domain store.
|
||||
pub async fn handle_reset(
|
||||
ctx: Arc<SettingsCtx>,
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
) {
|
||||
if let Err(e) = with_audio(&ctx.audio, |s| s.reset_all()) {
|
||||
log::error!("[qbz-slint] audio reset_all failed: {e}");
|
||||
}
|
||||
if let Err(e) = with_playback(&ctx.playback, |s| s.reset_all()) {
|
||||
log::error!("[qbz-slint] playback reset_all failed: {e}");
|
||||
}
|
||||
// Rebuild the snapshot off the UI thread (device enumeration blocks).
|
||||
let snap = {
|
||||
let ctx = ctx.clone();
|
||||
match tokio::task::spawn_blocking(move || load_snapshot(&ctx)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] settings reset rebuild task failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
apply_snapshot(&w, snap);
|
||||
});
|
||||
// Routing-critical defaults changed — re-init the device.
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
|
||||
#[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));
|
||||
fn max_sample_rate_table_starts_with_no_limit() {
|
||||
assert_eq!(MAX_SAMPLE_RATES[0].1, None);
|
||||
assert_eq!(MAX_SAMPLE_RATES.last().unwrap().1, Some(384_000));
|
||||
assert_eq!(MAX_SAMPLE_RATES.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoplay_table_matches_default() {
|
||||
assert_eq!(AUTOPLAY_MODES[0].1, AutoplayMode::default());
|
||||
fn alsa_plugin_table_first_is_hw() {
|
||||
assert_eq!(ALSA_PLUGINS[0].1, AlsaPlugin::Hw);
|
||||
assert_eq!(ALSA_PLUGINS.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_behavior_table_first_is_ask() {
|
||||
assert_eq!(RETRY_BEHAVIORS[0].1, "ask");
|
||||
assert_eq!(RETRY_BEHAVIORS[1].1, "always_fallback");
|
||||
assert_eq!(RETRY_BEHAVIORS[2].1, "always_skip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Tiny JSON-backed UI preference store.
|
||||
//!
|
||||
//! Some settings the Tauri app exposes are not part of any domain store
|
||||
//! (`AudioSettings`, `PlaybackPreferences`). Streaming Quality is one: it
|
||||
//! is a pure UI/request preference. Rather than thread it into a domain
|
||||
//! store, this module persists those preferences to a small JSON file
|
||||
//! next to the other QBZ data (`<data_dir>/qbz/ui_prefs.json`).
|
||||
//!
|
||||
//! The store is intentionally minimal — read-modify-write the whole file
|
||||
//! on every set. The file is tiny and writes are rare (a settings change).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Streaming-quality tiers, mirroring the Tauri app's dropdown. The
|
||||
/// `format_id` is the Qobuz format identifier the request layer expects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StreamingQuality {
|
||||
/// Stable key persisted to JSON.
|
||||
pub key: &'static str,
|
||||
/// Human-facing label for the dropdown.
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// The four streaming-quality options, in on-screen order.
|
||||
pub const STREAMING_QUALITIES: &[StreamingQuality] = &[
|
||||
StreamingQuality { key: "mp3", label: "MP3" },
|
||||
StreamingQuality { key: "cd", label: "CD Quality" },
|
||||
StreamingQuality { key: "hires", label: "Hi-Res" },
|
||||
StreamingQuality { key: "hires_plus", label: "Hi-Res+" },
|
||||
];
|
||||
|
||||
/// Default streaming-quality key (`Hi-Res+`).
|
||||
pub const DEFAULT_STREAMING_QUALITY: &str = "hires_plus";
|
||||
|
||||
/// Persisted UI preferences. New fields must default sanely so an older
|
||||
/// file (missing the field) still deserializes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UiPrefs {
|
||||
/// Streaming-quality key — one of `STREAMING_QUALITIES[*].key`.
|
||||
#[serde(default = "default_streaming_quality")]
|
||||
pub streaming_quality: String,
|
||||
}
|
||||
|
||||
fn default_streaming_quality() -> String {
|
||||
DEFAULT_STREAMING_QUALITY.to_string()
|
||||
}
|
||||
|
||||
impl Default for UiPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
streaming_quality: default_streaming_quality(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `<data_dir>/qbz/ui_prefs.json`.
|
||||
fn prefs_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
|
||||
}
|
||||
|
||||
/// Load the UI preferences. A missing or unreadable file degrades to
|
||||
/// `UiPrefs::default()` rather than erroring.
|
||||
pub fn load() -> UiPrefs {
|
||||
let Some(path) = prefs_path() else {
|
||||
return UiPrefs::default();
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-slint] ui_prefs.json parse failed, using defaults: {e}");
|
||||
UiPrefs::default()
|
||||
}),
|
||||
Err(_) => UiPrefs::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the UI preferences. Best-effort — failures are logged.
|
||||
pub fn save(prefs: &UiPrefs) {
|
||||
let Some(path) = prefs_path() else {
|
||||
log::warn!("[qbz-slint] ui_prefs.json: data dir unavailable, not saving");
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::error!("[qbz-slint] ui_prefs.json: create dir failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_string_pretty(prefs) {
|
||||
Ok(text) => {
|
||||
if let Err(e) = std::fs::write(&path, text) {
|
||||
log::error!("[qbz-slint] ui_prefs.json: write failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] ui_prefs.json: serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of `key` in `STREAMING_QUALITIES`, falling back to the default
|
||||
/// (`Hi-Res+`) when the stored key is unknown.
|
||||
pub fn streaming_quality_index(key: &str) -> usize {
|
||||
STREAMING_QUALITIES
|
||||
.iter()
|
||||
.position(|q| q.key == key)
|
||||
.unwrap_or_else(|| {
|
||||
STREAMING_QUALITIES
|
||||
.iter()
|
||||
.position(|q| q.key == DEFAULT_STREAMING_QUALITY)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_hires_plus() {
|
||||
assert_eq!(UiPrefs::default().streaming_quality, "hires_plus");
|
||||
assert_eq!(STREAMING_QUALITIES.len(), 4);
|
||||
assert_eq!(STREAMING_QUALITIES[3].key, "hires_plus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_resolves_to_default_index() {
|
||||
// Default is hires_plus, which is index 3.
|
||||
assert_eq!(streaming_quality_index("bogus"), 3);
|
||||
assert_eq!(streaming_quality_index("mp3"), 0);
|
||||
assert_eq!(streaming_quality_index("cd"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_field_deserializes() {
|
||||
let prefs: UiPrefs = serde_json::from_str("{}").expect("empty object deserializes");
|
||||
assert_eq!(prefs.streaming_quality, "hires_plus");
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export component AppWindow inherits Window {
|
||||
callback media-action(string, string, string);
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
callback logout();
|
||||
callback close-app();
|
||||
|
||||
@@ -72,6 +74,12 @@ export component AppWindow inherits Window {
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-slider(k, v) => {
|
||||
root.settings-slider(k, v);
|
||||
}
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
logout => {
|
||||
root.logout();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// 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.
|
||||
// Audio settings panel — streaming quality, output routing, and
|
||||
// bit-perfect options. 1:1 with the Tauri app. All rows are backed by
|
||||
// the SettingsState global and persisted through the Rust settings
|
||||
// controller. Conditional rows are `if`-gated on Rust-computed flags.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { SettingsState } from "../state.slint";
|
||||
import { QbzToggle } from "../primitives/QbzToggle.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
@@ -24,9 +26,55 @@ component Divider inherits Rectangle {
|
||||
export component AudioSettings inherits VerticalLayout {
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
// Emitted by the Reset button.
|
||||
callback settings-reset();
|
||||
|
||||
spacing: 4px;
|
||||
|
||||
GroupHeader { text: @tr("STREAMING"); }
|
||||
|
||||
SettingRow {
|
||||
label: @tr("Streaming quality");
|
||||
description: @tr("The quality tier QBZ requests for playback.");
|
||||
QbzSelect {
|
||||
menu-width: 200px;
|
||||
options: SettingsState.streaming-qualities;
|
||||
current-index: SettingsState.streaming-quality-index;
|
||||
selected(i) => {
|
||||
SettingsState.streaming-quality-index = i;
|
||||
root.settings-select("streaming-quality", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("Limit quality to device");
|
||||
description: @tr("Cap streaming quality at the output device's maximum sample rate.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.limit-quality-to-device;
|
||||
toggled(v) => {
|
||||
SettingsState.limit-quality-to-device = v;
|
||||
root.settings-bool("limit-quality-to-device", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if SettingsState.limit-quality-to-device: SettingRow {
|
||||
label: @tr("Maximum sample rate");
|
||||
description: @tr("The highest sample rate streamed tracks may use.");
|
||||
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("OUTPUT"); }
|
||||
|
||||
SettingRow {
|
||||
@@ -55,16 +103,27 @@ export component AudioSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("Preferred sample rate");
|
||||
description: @tr("Force a sample rate, or let the track decide.");
|
||||
if SettingsState.backend-is-alsa: SettingRow {
|
||||
label: @tr("ALSA plugin");
|
||||
description: @tr("How ALSA opens the device — hw is bit-perfect, plughw converts.");
|
||||
QbzSelect {
|
||||
menu-width: 170px;
|
||||
options: SettingsState.sample-rates;
|
||||
current-index: SettingsState.sample-rate-index;
|
||||
menu-width: 220px;
|
||||
options: SettingsState.alsa-plugins;
|
||||
current-index: SettingsState.alsa-plugin-index;
|
||||
selected(i) => {
|
||||
SettingsState.sample-rate-index = i;
|
||||
root.settings-select("sample-rate", i);
|
||||
SettingsState.alsa-plugin-index = i;
|
||||
root.settings-select("alsa-plugin", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if SettingsState.backend-is-alsa && SettingsState.alsa-plugin-is-hw: SettingRow {
|
||||
label: @tr("Hardware volume control");
|
||||
description: @tr("Use the ALSA mixer for volume instead of software gain.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.alsa-hardware-volume;
|
||||
toggled(v) => {
|
||||
SettingsState.alsa-hardware-volume = v;
|
||||
root.settings-bool("alsa-hardware-volume", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +145,17 @@ export component AudioSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("Reserve DAC while running");
|
||||
description: @tr("Hold the device reserved so other apps can't grab it.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.reserve-dac;
|
||||
toggled(v) => {
|
||||
SettingsState.reserve-dac = v;
|
||||
root.settings-bool("reserve-dac", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("DAC passthrough");
|
||||
description: @tr("Send the bitstream untouched to the DAC.");
|
||||
@@ -97,8 +167,8 @@ export component AudioSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("PipeWire force bit-perfect");
|
||||
if SettingsState.dac-passthrough && SettingsState.backend-is-pipewire: SettingRow {
|
||||
label: @tr("Force bit-perfect");
|
||||
description: @tr("Pin the PipeWire quantum and rate for the active track.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.pw-force-bitperfect;
|
||||
@@ -108,35 +178,6 @@ export component AudioSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
@@ -148,4 +189,62 @@ export component AudioSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 12px; }
|
||||
Divider { }
|
||||
Rectangle { height: 12px; }
|
||||
|
||||
GroupHeader { text: @tr("STARTUP"); }
|
||||
|
||||
SettingRow {
|
||||
label: @tr("Sync audio settings on startup");
|
||||
description: @tr("Reload saved audio settings into the player when QBZ launches.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.sync-audio-on-startup;
|
||||
toggled(v) => {
|
||||
SettingsState.sync-audio-on-startup = v;
|
||||
root.settings-bool("sync-audio-on-startup", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if SettingsState.backend-is-pipewire: SettingRow {
|
||||
label: @tr("Lock output device");
|
||||
description: @tr("Keep external routing intact — skip switching the default sink.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.skip-sink-switch;
|
||||
toggled(v) => {
|
||||
SettingsState.skip-sink-switch = v;
|
||||
root.settings-bool("skip-sink-switch", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 20px; }
|
||||
|
||||
// Reset — restores Audio + Playback defaults.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
reset-btn := Rectangle {
|
||||
width: 200px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
background: reset-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
Text {
|
||||
text: @tr("Reset to defaults");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
reset-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.settings-reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// Playback settings panel — end-of-source behavior and session
|
||||
// persistence. Backed by the SettingsState global and persisted through
|
||||
// the Rust settings controller.
|
||||
// Playback settings panel — end-of-source behavior, session persistence,
|
||||
// and streaming/caching options. 1:1 with the Tauri app. 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 { QbzSlider } from "../primitives/QbzSlider.slint";
|
||||
import { SettingRow } from "SettingRow.slint";
|
||||
|
||||
component GroupHeader inherits Text {
|
||||
@@ -24,26 +26,25 @@ component Divider inherits Rectangle {
|
||||
export component PlaybackSettings inherits VerticalLayout {
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
|
||||
spacing: 4px;
|
||||
|
||||
GroupHeader { text: @tr("AUTOPLAY"); }
|
||||
GroupHeader { text: @tr("PLAYBACK"); }
|
||||
|
||||
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);
|
||||
label: @tr("Continue playback after track ends");
|
||||
description: @tr("Keep playing the rest of the album or playlist instead of stopping.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.continue-playback;
|
||||
toggled(v) => {
|
||||
SettingsState.continue-playback = v;
|
||||
root.settings-bool("continue-playback", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("Show context icon");
|
||||
label: @tr("Show track playing context");
|
||||
description: @tr("Display the context-stack icon in the player.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.show-context-icon;
|
||||
@@ -53,6 +54,17 @@ export component PlaybackSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 12px; }
|
||||
Divider { }
|
||||
@@ -61,7 +73,7 @@ export component PlaybackSettings inherits VerticalLayout {
|
||||
GroupHeader { text: @tr("SESSION"); }
|
||||
|
||||
SettingRow {
|
||||
label: @tr("Remember session");
|
||||
label: @tr("Restore session on startup");
|
||||
description: @tr("Restore the queue and current track on the next launch.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.persist-session;
|
||||
@@ -84,4 +96,74 @@ export component PlaybackSettings inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 12px; }
|
||||
Divider { }
|
||||
Rectangle { height: 12px; }
|
||||
|
||||
GroupHeader { text: @tr("STREAMING"); }
|
||||
|
||||
SettingRow {
|
||||
label: @tr("Stream uncached tracks");
|
||||
description: @tr("Start uncached tracks via streaming instead of waiting for the full download.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.stream-uncached;
|
||||
toggled(v) => {
|
||||
SettingsState.stream-uncached = v;
|
||||
root.settings-bool("stream-uncached", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if SettingsState.stream-uncached: SettingRow {
|
||||
label: @tr("Initial buffer size");
|
||||
description: @tr("Seconds of audio buffered before streaming playback starts.");
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSlider {
|
||||
minimum: 1;
|
||||
maximum: 10;
|
||||
value: SettingsState.buffer-seconds;
|
||||
changed(v) => {
|
||||
SettingsState.buffer-seconds = v;
|
||||
root.settings-slider("buffer-seconds", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: SettingsState.buffer-seconds + @tr("s");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("Streaming only");
|
||||
description: @tr("Skip writing tracks to the local cache while streaming.");
|
||||
QbzToggle {
|
||||
checked: SettingsState.streaming-only;
|
||||
toggled(v) => {
|
||||
SettingsState.streaming-only = v;
|
||||
root.settings-bool("streaming-only", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: @tr("When quality retries fail");
|
||||
description: @tr("What to do when every quality tier for a track is unavailable.");
|
||||
QbzSelect {
|
||||
menu-width: 240px;
|
||||
options: SettingsState.retry-behaviors;
|
||||
current-index: SettingsState.retry-behavior-index;
|
||||
selected(i) => {
|
||||
SettingsState.retry-behavior-index = i;
|
||||
root.settings-select("retry-behavior", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ component SubNavItem inherits Rectangle {
|
||||
export component SettingsView inherits Rectangle {
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
@@ -146,6 +148,9 @@ export component SettingsView inherits Rectangle {
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
}
|
||||
if !SettingsState.loading && SettingsState.section == 1: PlaybackSettings {
|
||||
settings-bool(k, v) => {
|
||||
@@ -154,6 +159,9 @@ export component SettingsView inherits Rectangle {
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-slider(k, v) => {
|
||||
root.settings-slider(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export component AppShell inherits Rectangle {
|
||||
callback media-action(string, string, string);
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
callback logout();
|
||||
callback close-app();
|
||||
|
||||
@@ -95,6 +97,12 @@ export component AppShell inherits Rectangle {
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-slider(k, v) => {
|
||||
root.settings-slider(k, v);
|
||||
}
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,35 +86,62 @@ 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.
|
||||
// changes flow back through the settings-bool / settings-select /
|
||||
// settings-slider callbacks.
|
||||
export global SettingsState {
|
||||
// Sub-section: 0 = Audio, 1 = Playback.
|
||||
in-out property <int> section: 0;
|
||||
in property <bool> loading: true;
|
||||
|
||||
// --- AUDIO -------------------------------------------------------------
|
||||
|
||||
// Audio — dropdowns. Each list is paired with a selected index; the
|
||||
// Rust controller owns the index -> value mapping.
|
||||
in-out property <[string]> streaming-qualities: [];
|
||||
in-out property <int> streaming-quality-index: 0;
|
||||
in-out property <[string]> sample-rates: [];
|
||||
in-out property <int> sample-rate-index: 0;
|
||||
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;
|
||||
in-out property <[string]> alsa-plugins: [];
|
||||
in-out property <int> alsa-plugin-index: 0;
|
||||
|
||||
// Audio — toggles.
|
||||
in-out property <bool> limit-quality-to-device: false;
|
||||
in-out property <bool> alsa-hardware-volume: false;
|
||||
in-out property <bool> exclusive-mode: false;
|
||||
in-out property <bool> reserve-dac: 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;
|
||||
in-out property <bool> sync-audio-on-startup: false;
|
||||
in-out property <bool> skip-sink-switch: false;
|
||||
|
||||
// Playback.
|
||||
in-out property <[string]> autoplay-modes: [];
|
||||
in-out property <int> autoplay-index: 0;
|
||||
in-out property <bool> show-context-icon: false;
|
||||
// Audio — Rust-computed conditional flags driving `if`-gated rows.
|
||||
in-out property <bool> backend-is-alsa: false;
|
||||
in-out property <bool> backend-is-pipewire: false;
|
||||
in-out property <bool> alsa-plugin-is-hw: false;
|
||||
|
||||
// --- PLAYBACK ----------------------------------------------------------
|
||||
|
||||
// Playback — toggles. `continue-playback` maps to the autoplay mode
|
||||
// (on = ContinueWithinSource, off = PlayTrackOnly).
|
||||
in-out property <bool> continue-playback: true;
|
||||
in-out property <bool> show-context-icon: true;
|
||||
in-out property <bool> persist-session: false;
|
||||
in-out property <bool> resume-position: false;
|
||||
in-out property <bool> gapless: true;
|
||||
in-out property <bool> stream-uncached: false;
|
||||
in-out property <bool> streaming-only: false;
|
||||
|
||||
// Playback — Initial Buffer Size slider (seconds, 1-10).
|
||||
in-out property <int> buffer-seconds: 3;
|
||||
|
||||
// Playback — "When quality retries fail" dropdown.
|
||||
in-out property <[string]> retry-behaviors: [];
|
||||
in-out property <int> retry-behavior-index: 0;
|
||||
}
|
||||
|
||||
export global NavState {
|
||||
|
||||
Reference in new issue
Block a user