Merge pre-release: 1.2.13 (400+ stars)
cc
This commit is contained in:
219 files changed
+21347
-12936
No files matched your search
@@ -18,8 +18,10 @@ jobs:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
artifact: qbz-macos-aarch64
|
||||
arch: aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
artifact: qbz-macos-x86_64
|
||||
arch: x86_64
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -102,13 +104,20 @@ jobs:
|
||||
run: npm run tauri build -- --target ${{ matrix.target }}
|
||||
|
||||
- name: Collect artifacts
|
||||
env:
|
||||
BUNDLE_DIR: src-tauri/target/${{ matrix.target }}/release/bundle
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
# DMG for manual distribution
|
||||
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg dist/ || true
|
||||
# .app.tar.gz + .sig for auto-updater
|
||||
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz dist/ || true
|
||||
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz.sig dist/ || true
|
||||
# DMG for manual distribution (filename already includes arch).
|
||||
cp -v "$BUNDLE_DIR"/dmg/*.dmg dist/
|
||||
# .app.tar.gz + .sig for auto-updater. Rename with arch suffix so the
|
||||
# two matrix jobs don't overwrite each other on the GitHub Release,
|
||||
# and so release-updater-manifest.yml's regex picks them up
|
||||
# ("aarch64.app.tar.gz$" / "(x64|x86_64).app.tar.gz$").
|
||||
cp -v "$BUNDLE_DIR/macos/QBZ.app.tar.gz" "dist/QBZ_${ARCH}.app.tar.gz"
|
||||
cp -v "$BUNDLE_DIR/macos/QBZ.app.tar.gz.sig" "dist/QBZ_${ARCH}.app.tar.gz.sig"
|
||||
ls -la dist
|
||||
|
||||
- name: Upload artifacts
|
||||
|
||||
@@ -424,8 +424,10 @@ Contributions welcome. Please read `CONTRIBUTING.md` before submitting issues or
|
||||
- [@boxdot](https://github.com/boxdot)
|
||||
- [@arminfelder](https://github.com/arminfelder)
|
||||
- [@afonsojramos](https://github.com/afonsojramos) — macOS port
|
||||
- [@Vudgekek](https://github.com/Vudgekek) — macOS audio
|
||||
- [@GwendalBeaumont](https://github.com/GwendalBeaumont) — i18n
|
||||
- [@AdamArstall](https://github.com/AdamArstall)
|
||||
- [@DoubleGate](https://github.com/DoubleGate)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Generated
+16
@@ -2903,6 +2903,22 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qbz-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"log",
|
||||
"md-5",
|
||||
"rand 0.10.1",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-audio"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
|
||||
members = [
|
||||
"qbz-app",
|
||||
"qbz-models",
|
||||
"qbz-audio",
|
||||
"qbz-player",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "qbz-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
dirs = "6"
|
||||
chrono = { workspace = true }
|
||||
md-5 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
rand = "0.10"
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Framework-agnostic boot watchdog decision logic.
|
||||
//!
|
||||
//! The Tauri shell owns marker-file IO, settings persistence, and startup env
|
||||
//! mutation. This module owns the pure crash-recovery state transition that can
|
||||
//! be reused by any future native shell.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Snapshot of the risky settings attempted in a boot. Compared against the
|
||||
/// previous boot's snapshot to decide which setting "caused" a crash.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BootAttempt {
|
||||
pub hardware_acceleration: bool,
|
||||
pub force_dmabuf: bool,
|
||||
pub preferred_gpu: String,
|
||||
pub force_x11: bool,
|
||||
}
|
||||
|
||||
/// Sticky flags written when a setting was auto-reverted after a crash.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CrashFlags {
|
||||
/// Last boot crashed with hardware_acceleration ON; we reverted it.
|
||||
pub hardware_acceleration_disabled: bool,
|
||||
pub hardware_acceleration_consecutive_failures: u8,
|
||||
/// Same for force_dmabuf.
|
||||
pub force_dmabuf_disabled: bool,
|
||||
pub force_dmabuf_consecutive_failures: u8,
|
||||
/// Same for preferred_gpu (reset to "auto").
|
||||
pub preferred_gpu_disabled: bool,
|
||||
pub preferred_gpu_consecutive_failures: u8,
|
||||
/// Lockout - set when consecutive_failures >= 2 for a given knob.
|
||||
pub hardware_acceleration_locked: bool,
|
||||
pub force_dmabuf_locked: bool,
|
||||
pub preferred_gpu_locked: bool,
|
||||
}
|
||||
|
||||
/// Resolved settings the host shell should actually use for this boot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WatchdogResolution {
|
||||
pub hardware_acceleration: bool,
|
||||
pub force_dmabuf: bool,
|
||||
pub preferred_gpu: String,
|
||||
/// Reverted-in-this-boot messages so the UI knows which setting was
|
||||
/// just rolled back. Persisted in CrashFlags as well.
|
||||
pub recovery_messages: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pure output for a boot watchdog decision. The host adapter decides how to
|
||||
/// persist marker files and settings writes from this value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BootWatchdogDecision {
|
||||
pub resolved_attempt: BootAttempt,
|
||||
pub crash_flags: CrashFlags,
|
||||
pub recovery_messages: Vec<String>,
|
||||
pub should_persist_reverts: bool,
|
||||
}
|
||||
|
||||
impl BootWatchdogDecision {
|
||||
pub fn resolution(&self) -> WatchdogResolution {
|
||||
WatchdogResolution {
|
||||
hardware_acceleration: self.resolved_attempt.hardware_acceleration,
|
||||
force_dmabuf: self.resolved_attempt.force_dmabuf,
|
||||
preferred_gpu: self.resolved_attempt.preferred_gpu.clone(),
|
||||
recovery_messages: self.recovery_messages.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_boot_attempt(
|
||||
intended: BootAttempt,
|
||||
pending: Option<BootAttempt>,
|
||||
mut flags: CrashFlags,
|
||||
) -> BootWatchdogDecision {
|
||||
let mut resolved = intended.clone();
|
||||
let mut messages: Vec<String> = Vec::new();
|
||||
let mut any_revert = false;
|
||||
|
||||
if let Some(prev) = pending {
|
||||
if prev.hardware_acceleration && resolved.hardware_acceleration {
|
||||
flags.hardware_acceleration_consecutive_failures = flags
|
||||
.hardware_acceleration_consecutive_failures
|
||||
.saturating_add(1);
|
||||
resolved.hardware_acceleration = false;
|
||||
flags.hardware_acceleration_disabled = true;
|
||||
if flags.hardware_acceleration_consecutive_failures >= 2 {
|
||||
flags.hardware_acceleration_locked = true;
|
||||
}
|
||||
messages.push(
|
||||
"Hardware acceleration was disabled because the previous launch crashed during \
|
||||
graphics init."
|
||||
.to_string(),
|
||||
);
|
||||
any_revert = true;
|
||||
}
|
||||
|
||||
if prev.force_dmabuf && resolved.force_dmabuf {
|
||||
flags.force_dmabuf_consecutive_failures =
|
||||
flags.force_dmabuf_consecutive_failures.saturating_add(1);
|
||||
resolved.force_dmabuf = false;
|
||||
flags.force_dmabuf_disabled = true;
|
||||
if flags.force_dmabuf_consecutive_failures >= 2 {
|
||||
flags.force_dmabuf_locked = true;
|
||||
}
|
||||
messages.push(
|
||||
"DMA-BUF renderer was disabled because the previous launch crashed during \
|
||||
graphics init."
|
||||
.to_string(),
|
||||
);
|
||||
any_revert = true;
|
||||
}
|
||||
|
||||
if prev.preferred_gpu == resolved.preferred_gpu && resolved.preferred_gpu != "auto" {
|
||||
flags.preferred_gpu_consecutive_failures =
|
||||
flags.preferred_gpu_consecutive_failures.saturating_add(1);
|
||||
resolved.preferred_gpu = "auto".to_string();
|
||||
flags.preferred_gpu_disabled = true;
|
||||
if flags.preferred_gpu_consecutive_failures >= 2 {
|
||||
flags.preferred_gpu_locked = true;
|
||||
}
|
||||
messages.push(format!(
|
||||
"Rendering GPU preference was reset to Auto because pinning to {:?} crashed the \
|
||||
previous launch.",
|
||||
prev.preferred_gpu
|
||||
));
|
||||
any_revert = true;
|
||||
}
|
||||
|
||||
if any_revert {
|
||||
if resolved.hardware_acceleration {
|
||||
resolved.hardware_acceleration = false;
|
||||
flags.hardware_acceleration_disabled = true;
|
||||
messages.push(
|
||||
"Hardware acceleration was also disabled as a safety fallback to land in \
|
||||
CPU mode."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if resolved.force_dmabuf {
|
||||
resolved.force_dmabuf = false;
|
||||
flags.force_dmabuf_disabled = true;
|
||||
}
|
||||
if resolved.preferred_gpu != "auto" {
|
||||
resolved.preferred_gpu = "auto".to_string();
|
||||
flags.preferred_gpu_disabled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !flags.hardware_acceleration_locked {
|
||||
flags.hardware_acceleration_disabled = false;
|
||||
flags.hardware_acceleration_consecutive_failures = 0;
|
||||
}
|
||||
if !flags.force_dmabuf_locked {
|
||||
flags.force_dmabuf_disabled = false;
|
||||
flags.force_dmabuf_consecutive_failures = 0;
|
||||
}
|
||||
if !flags.preferred_gpu_locked {
|
||||
flags.preferred_gpu_disabled = false;
|
||||
flags.preferred_gpu_consecutive_failures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let should_persist_reverts = resolved != intended;
|
||||
BootWatchdogDecision {
|
||||
resolved_attempt: resolved,
|
||||
crash_flags: flags,
|
||||
recovery_messages: messages,
|
||||
should_persist_reverts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_boot_success_flags(attempt: &BootAttempt, mut flags: CrashFlags) -> CrashFlags {
|
||||
if attempt.hardware_acceleration {
|
||||
flags.hardware_acceleration_consecutive_failures = 0;
|
||||
flags.hardware_acceleration_disabled = false;
|
||||
flags.hardware_acceleration_locked = false;
|
||||
}
|
||||
if attempt.force_dmabuf {
|
||||
flags.force_dmabuf_consecutive_failures = 0;
|
||||
flags.force_dmabuf_disabled = false;
|
||||
flags.force_dmabuf_locked = false;
|
||||
}
|
||||
if attempt.preferred_gpu != "auto" {
|
||||
flags.preferred_gpu_consecutive_failures = 0;
|
||||
flags.preferred_gpu_disabled = false;
|
||||
flags.preferred_gpu_locked = false;
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
pub fn clear_crash_flag_value(mut flags: CrashFlags, flag: &str) -> Option<CrashFlags> {
|
||||
match flag {
|
||||
"hardware_acceleration" => {
|
||||
flags.hardware_acceleration_disabled = false;
|
||||
flags.hardware_acceleration_consecutive_failures = 0;
|
||||
flags.hardware_acceleration_locked = false;
|
||||
}
|
||||
"force_dmabuf" => {
|
||||
flags.force_dmabuf_disabled = false;
|
||||
flags.force_dmabuf_consecutive_failures = 0;
|
||||
flags.force_dmabuf_locked = false;
|
||||
}
|
||||
"preferred_gpu" => {
|
||||
flags.preferred_gpu_disabled = false;
|
||||
flags.preferred_gpu_consecutive_failures = 0;
|
||||
flags.preferred_gpu_locked = false;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(flags)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attempt() -> BootAttempt {
|
||||
BootAttempt {
|
||||
hardware_acceleration: true,
|
||||
force_dmabuf: true,
|
||||
preferred_gpu: "auto".to_string(),
|
||||
force_x11: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pending_returns_intended_and_clears_non_locked_flags() {
|
||||
let flags = CrashFlags {
|
||||
hardware_acceleration_disabled: true,
|
||||
hardware_acceleration_consecutive_failures: 1,
|
||||
force_dmabuf_disabled: true,
|
||||
force_dmabuf_consecutive_failures: 1,
|
||||
preferred_gpu_disabled: true,
|
||||
preferred_gpu_consecutive_failures: 1,
|
||||
..CrashFlags::default()
|
||||
};
|
||||
|
||||
let decision = resolve_boot_attempt(attempt(), None, flags);
|
||||
|
||||
assert_eq!(decision.resolved_attempt, attempt());
|
||||
assert!(!decision.should_persist_reverts);
|
||||
assert_eq!(decision.crash_flags, CrashFlags::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pending_preserves_locked_flags() {
|
||||
let flags = CrashFlags {
|
||||
hardware_acceleration_disabled: true,
|
||||
hardware_acceleration_consecutive_failures: 2,
|
||||
hardware_acceleration_locked: true,
|
||||
..CrashFlags::default()
|
||||
};
|
||||
|
||||
let decision = resolve_boot_attempt(attempt(), None, flags);
|
||||
|
||||
assert!(decision.crash_flags.hardware_acceleration_disabled);
|
||||
assert_eq!(
|
||||
decision
|
||||
.crash_flags
|
||||
.hardware_acceleration_consecutive_failures,
|
||||
2
|
||||
);
|
||||
assert!(decision.crash_flags.hardware_acceleration_locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_hardware_acceleration_crash_disables_hardware_acceleration() {
|
||||
let decision = resolve_boot_attempt(attempt(), Some(attempt()), CrashFlags::default());
|
||||
|
||||
assert!(!decision.resolved_attempt.hardware_acceleration);
|
||||
assert!(!decision.resolved_attempt.force_dmabuf);
|
||||
assert_eq!(decision.resolved_attempt.preferred_gpu, "auto");
|
||||
assert!(decision.crash_flags.hardware_acceleration_disabled);
|
||||
assert_eq!(
|
||||
decision
|
||||
.crash_flags
|
||||
.hardware_acceleration_consecutive_failures,
|
||||
1
|
||||
);
|
||||
assert!(decision.should_persist_reverts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_dmabuf_crash_disables_dmabuf() {
|
||||
let mut intended = attempt();
|
||||
intended.hardware_acceleration = false;
|
||||
let pending = intended.clone();
|
||||
|
||||
let decision = resolve_boot_attempt(intended, Some(pending), CrashFlags::default());
|
||||
|
||||
assert!(!decision.resolved_attempt.force_dmabuf);
|
||||
assert!(decision.crash_flags.force_dmabuf_disabled);
|
||||
assert_eq!(decision.crash_flags.force_dmabuf_consecutive_failures, 1);
|
||||
assert!(decision.should_persist_reverts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_same_preferred_gpu_resets_to_auto() {
|
||||
let mut intended = attempt();
|
||||
intended.hardware_acceleration = false;
|
||||
intended.force_dmabuf = false;
|
||||
intended.preferred_gpu = "0000:01:00.0".to_string();
|
||||
let pending = intended.clone();
|
||||
|
||||
let decision = resolve_boot_attempt(intended, Some(pending), CrashFlags::default());
|
||||
|
||||
assert_eq!(decision.resolved_attempt.preferred_gpu, "auto");
|
||||
assert!(decision.crash_flags.preferred_gpu_disabled);
|
||||
assert_eq!(decision.crash_flags.preferred_gpu_consecutive_failures, 1);
|
||||
assert!(decision.should_persist_reverts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_different_preferred_gpu_does_not_blame_gpu_choice() {
|
||||
let mut intended = attempt();
|
||||
intended.hardware_acceleration = false;
|
||||
intended.force_dmabuf = false;
|
||||
intended.preferred_gpu = "0000:02:00.0".to_string();
|
||||
let mut pending = intended.clone();
|
||||
pending.preferred_gpu = "0000:01:00.0".to_string();
|
||||
|
||||
let decision = resolve_boot_attempt(intended.clone(), Some(pending), CrashFlags::default());
|
||||
|
||||
assert_eq!(decision.resolved_attempt, intended);
|
||||
assert!(!decision.crash_flags.preferred_gpu_disabled);
|
||||
assert!(!decision.should_persist_reverts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_failure_locks_setting() {
|
||||
let flags = CrashFlags {
|
||||
hardware_acceleration_consecutive_failures: 1,
|
||||
..CrashFlags::default()
|
||||
};
|
||||
|
||||
let decision = resolve_boot_attempt(attempt(), Some(attempt()), flags);
|
||||
|
||||
assert_eq!(
|
||||
decision
|
||||
.crash_flags
|
||||
.hardware_acceleration_consecutive_failures,
|
||||
2
|
||||
);
|
||||
assert!(decision.crash_flags.hardware_acceleration_locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_with_enabled_knobs_clears_matching_flags() {
|
||||
let flags = CrashFlags {
|
||||
hardware_acceleration_disabled: true,
|
||||
hardware_acceleration_consecutive_failures: 2,
|
||||
hardware_acceleration_locked: true,
|
||||
force_dmabuf_disabled: true,
|
||||
force_dmabuf_consecutive_failures: 2,
|
||||
force_dmabuf_locked: true,
|
||||
preferred_gpu_disabled: true,
|
||||
preferred_gpu_consecutive_failures: 2,
|
||||
preferred_gpu_locked: true,
|
||||
};
|
||||
let mut boot = attempt();
|
||||
boot.preferred_gpu = "0000:01:00.0".to_string();
|
||||
|
||||
let updated = mark_boot_success_flags(&boot, flags);
|
||||
|
||||
assert_eq!(updated, CrashFlags::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_with_cpu_fallback_does_not_clear_disabled_flags() {
|
||||
let flags = CrashFlags {
|
||||
hardware_acceleration_disabled: true,
|
||||
hardware_acceleration_consecutive_failures: 1,
|
||||
force_dmabuf_disabled: true,
|
||||
force_dmabuf_consecutive_failures: 1,
|
||||
preferred_gpu_disabled: true,
|
||||
preferred_gpu_consecutive_failures: 1,
|
||||
..CrashFlags::default()
|
||||
};
|
||||
let boot = BootAttempt {
|
||||
hardware_acceleration: false,
|
||||
force_dmabuf: false,
|
||||
preferred_gpu: "auto".to_string(),
|
||||
force_x11: false,
|
||||
};
|
||||
|
||||
let updated = mark_boot_success_flags(&boot, flags.clone());
|
||||
|
||||
assert_eq!(updated, flags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_known_flag_resets_disabled_counter_and_lock() {
|
||||
let flags = CrashFlags {
|
||||
force_dmabuf_disabled: true,
|
||||
force_dmabuf_consecutive_failures: 2,
|
||||
force_dmabuf_locked: true,
|
||||
..CrashFlags::default()
|
||||
};
|
||||
|
||||
let updated = clear_crash_flag_value(flags, "force_dmabuf").expect("known flag");
|
||||
|
||||
assert!(!updated.force_dmabuf_disabled);
|
||||
assert_eq!(updated.force_dmabuf_consecutive_failures, 0);
|
||||
assert!(!updated.force_dmabuf_locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_unknown_flag_is_noop() {
|
||||
assert_eq!(
|
||||
clear_crash_flag_value(CrashFlags::default(), "force_x11"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Framework-agnostic graphics auto-configuration recommendation logic.
|
||||
//!
|
||||
//! This module detects the host graphics environment and computes the settings
|
||||
//! profile QBZ should recommend. Applying that profile is intentionally left to
|
||||
//! the shell adapter because it writes to persisted settings and currently
|
||||
//! targets the Tauri/WebKit startup path.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Detected environment information.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct Environment {
|
||||
pub display_server: String,
|
||||
pub gpu_nvidia: bool,
|
||||
pub gpu_amd: bool,
|
||||
pub gpu_intel: bool,
|
||||
pub gpu_name: String,
|
||||
pub desktop: String,
|
||||
pub is_vm: bool,
|
||||
}
|
||||
|
||||
/// Recommended configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct Recommendation {
|
||||
pub hardware_acceleration: bool,
|
||||
pub force_x11: bool,
|
||||
pub gsk_renderer: Option<String>,
|
||||
pub disable_dmabuf: bool,
|
||||
pub disable_blur_background: bool,
|
||||
pub rationale: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn detect_environment() -> Environment {
|
||||
let display_server = detect_display_server();
|
||||
let gpu_nvidia = is_nvidia_gpu();
|
||||
let gpu_amd = is_amd_gpu();
|
||||
let gpu_intel = is_intel_gpu();
|
||||
let gpu_name = detect_gpu_name(gpu_nvidia, gpu_amd, gpu_intel);
|
||||
let desktop = detect_desktop();
|
||||
let is_vm = is_virtual_machine();
|
||||
|
||||
Environment {
|
||||
display_server,
|
||||
gpu_nvidia,
|
||||
gpu_amd,
|
||||
gpu_intel,
|
||||
gpu_name,
|
||||
desktop,
|
||||
is_vm,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_display_server() -> String {
|
||||
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
|
||||
|
||||
if is_wayland {
|
||||
"Wayland".to_string()
|
||||
} else {
|
||||
"X11".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_nvidia_gpu() -> bool {
|
||||
std::path::Path::new("/proc/driver/nvidia/version").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("nvidia")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_amd_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/amdgpu").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("amdgpu")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_intel_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/i915").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("i915")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_virtual_machine() -> bool {
|
||||
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
|
||||
let p = product.trim().to_lowercase();
|
||||
if p.contains("virtualbox")
|
||||
|| p.contains("vmware")
|
||||
|| p.contains("qemu")
|
||||
|| p.contains("bochs")
|
||||
|| p.contains("hyper-v")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") {
|
||||
let v = vendor.trim().to_lowercase();
|
||||
if v.contains("innotek")
|
||||
|| v.contains("vmware")
|
||||
|| v.contains("qemu")
|
||||
|| v.contains("xen")
|
||||
|| v.contains("parallels")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(h) = std::fs::read_to_string("/sys/hypervisor/type") {
|
||||
if !h.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn detect_gpu_name(nvidia: bool, amd: bool, intel: bool) -> String {
|
||||
// Hybrid laptops have more than one of these set; join the names so
|
||||
// diagnostics surface the full picture instead of returning only the
|
||||
// first vendor matched.
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if nvidia {
|
||||
parts.push(nvidia_name());
|
||||
}
|
||||
if amd {
|
||||
parts.push(amd_name());
|
||||
}
|
||||
if intel {
|
||||
parts.push(intel_name());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"Unknown / None detected".to_string()
|
||||
} else {
|
||||
parts.join(" + ")
|
||||
}
|
||||
}
|
||||
|
||||
fn nvidia_name() -> String {
|
||||
if let Ok(version) = std::fs::read_to_string("/proc/driver/nvidia/version") {
|
||||
if let Some(line) = version.lines().next() {
|
||||
return format!("NVIDIA ({})", line.trim());
|
||||
}
|
||||
}
|
||||
"NVIDIA (driver loaded)".to_string()
|
||||
}
|
||||
|
||||
fn amd_name() -> String {
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("card") && !name.contains('-') {
|
||||
let model_path = entry.path().join("device/product_name");
|
||||
if let Ok(model) = std::fs::read_to_string(&model_path) {
|
||||
let model = model.trim();
|
||||
if !model.is_empty() {
|
||||
return format!("AMD {}", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"AMD (amdgpu driver loaded)".to_string()
|
||||
}
|
||||
|
||||
fn intel_name() -> String {
|
||||
"Intel (i915/xe driver loaded)".to_string()
|
||||
}
|
||||
|
||||
fn detect_desktop() -> String {
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||
let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default();
|
||||
let de = std::env::var("DESKTOP_SESSION").unwrap_or_default();
|
||||
|
||||
if !desktop.is_empty() {
|
||||
desktop
|
||||
} else if !session.is_empty() {
|
||||
session
|
||||
} else if !de.is_empty() {
|
||||
de
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_recommendation(env: &Environment) -> Recommendation {
|
||||
let mut rationale = Vec::new();
|
||||
|
||||
// VM: software rendering, no blur
|
||||
if env.is_vm {
|
||||
rationale.push("Virtual machine detected: using software rendering".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: false,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("cairo".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
let is_wayland = env.display_server == "Wayland";
|
||||
let has_hybrid_igpu = env.gpu_nvidia && (env.gpu_intel || env.gpu_amd);
|
||||
|
||||
// Hybrid laptops (NVIDIA dGPU + Intel/AMD iGPU). WebKit composes via
|
||||
// the iGPU through EGL/GLX defaults - the NVIDIA card sits idle for
|
||||
// PRIME render offload. Auto (None) lets GTK4 pick NGL/Vulkan as
|
||||
// appropriate for the iGPU. DMA-BUF is safe here: the compositing
|
||||
// path runs on the stable iGPU stack, not the NVIDIA driver, so the
|
||||
// instability that affects NVIDIA-only systems does not apply. The
|
||||
// user must still pick the iGPU explicitly in the rendering GPU
|
||||
// selector - leaving it on Auto can resolve to the NVIDIA card.
|
||||
if has_hybrid_igpu {
|
||||
let igpu_label = if env.gpu_intel { "Intel" } else { "AMD" };
|
||||
rationale.push(format!(
|
||||
"NVIDIA + {} hybrid: iGPU handles WebKit compositing, leaving GSK at Auto",
|
||||
igpu_label
|
||||
));
|
||||
rationale.push(format!(
|
||||
"Select the {} integrated GPU explicitly in the rendering GPU selector instead of Auto",
|
||||
igpu_label
|
||||
));
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// NVIDIA-only (no iGPU to offload WebKit compositing to). WebKitGTK
|
||||
// on the proprietary NVIDIA driver has long-standing compositing
|
||||
// stutter that XWayland does not solve. Rather than recommend
|
||||
// hardware acceleration that visibly stutters, fall back to CPU
|
||||
// rendering: trimmed but smooth. DMA-BUF stays off - there is no
|
||||
// stable GPU compositing path on this configuration.
|
||||
if env.gpu_nvidia {
|
||||
rationale.push("NVIDIA-only system: using CPU rendering for a smooth UI".to_string());
|
||||
rationale
|
||||
.push("WebKit GPU compositing on the NVIDIA driver has unresolved stutter".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: false,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("cairo".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + Wayland
|
||||
if env.gpu_amd && is_wayland {
|
||||
rationale.push("AMD + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + X11
|
||||
if env.gpu_amd {
|
||||
rationale.push("AMD + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + Wayland
|
||||
if env.gpu_intel && is_wayland {
|
||||
rationale.push("Intel + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + X11
|
||||
if env.gpu_intel {
|
||||
rationale.push("Intel + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown GPU: safe defaults
|
||||
rationale.push("No known GPU detected: using safe defaults".to_string());
|
||||
Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env(display_server: &str) -> Environment {
|
||||
Environment {
|
||||
display_server: display_server.to_string(),
|
||||
gpu_nvidia: false,
|
||||
gpu_amd: false,
|
||||
gpu_intel: false,
|
||||
gpu_name: "test".to_string(),
|
||||
desktop: "Unknown".to_string(),
|
||||
is_vm: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_uses_cpu_fallback() {
|
||||
let mut environment = env("Wayland");
|
||||
environment.is_vm = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert!(!recommendation.hardware_acceleration);
|
||||
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
|
||||
assert!(recommendation.disable_dmabuf);
|
||||
assert!(recommendation.disable_blur_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_hybrid_leaves_renderer_auto_and_allows_dmabuf() {
|
||||
let mut environment = env("Wayland");
|
||||
environment.gpu_nvidia = true;
|
||||
environment.gpu_intel = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert!(recommendation.hardware_acceleration);
|
||||
assert_eq!(recommendation.gsk_renderer, None);
|
||||
// DMA-BUF is safe on hybrid: the iGPU does the compositing.
|
||||
assert!(!recommendation.disable_dmabuf);
|
||||
// The user is told to pick the iGPU explicitly, not leave Auto.
|
||||
assert!(recommendation
|
||||
.rationale
|
||||
.iter()
|
||||
.any(|line| line.contains("integrated GPU")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_only_wayland_uses_cpu_rendering() {
|
||||
let mut environment = env("Wayland");
|
||||
environment.gpu_nvidia = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert!(!recommendation.hardware_acceleration);
|
||||
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
|
||||
assert!(recommendation.disable_dmabuf);
|
||||
assert!(recommendation.disable_blur_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_only_x11_uses_cpu_rendering() {
|
||||
let mut environment = env("X11");
|
||||
environment.gpu_nvidia = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
// X11/XWayland does not rescue NVIDIA-only: CPU rendering either way.
|
||||
assert!(!recommendation.hardware_acceleration);
|
||||
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
|
||||
assert!(recommendation.disable_dmabuf);
|
||||
assert!(!recommendation.force_x11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amd_wayland_uses_ngl_and_allows_dmabuf() {
|
||||
let mut environment = env("Wayland");
|
||||
environment.gpu_amd = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("ngl"));
|
||||
assert!(!recommendation.disable_dmabuf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_wayland_uses_ngl_and_allows_dmabuf() {
|
||||
let mut environment = env("Wayland");
|
||||
environment.gpu_intel = true;
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("ngl"));
|
||||
assert!(!recommendation.disable_dmabuf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_gpu_keeps_safe_defaults() {
|
||||
let environment = env("X11");
|
||||
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
|
||||
assert!(recommendation.hardware_acceleration);
|
||||
assert!(!recommendation.force_x11);
|
||||
assert_eq!(recommendation.gsk_renderer, None);
|
||||
assert!(!recommendation.disable_dmabuf);
|
||||
assert!(!recommendation.disable_blur_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_name_combines_hybrid_vendors() {
|
||||
let name = detect_gpu_name(true, false, true);
|
||||
|
||||
assert!(name.contains("NVIDIA"));
|
||||
assert!(name.contains("Intel"));
|
||||
assert!(name.contains(" + "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Host GPU enumeration for the current desktop shell.
|
||||
//!
|
||||
//! Cross-references the PCI devices visible under `/sys/class/drm/` (real
|
||||
//! physical GPUs) with the EGL vendor JSONs available under
|
||||
//! `/usr/share/glvnd/egl_vendor.d/` (stacks the running process can
|
||||
//! actually use). The intersection is what the host shell can expose to
|
||||
//! the user.
|
||||
//!
|
||||
//! Inside Flatpak/Snap the `/sys` path is still mounted (read-only) so
|
||||
//! PCI detection works; the `/usr/share/glvnd/...` view reflects the
|
||||
//! sandbox's own vendor JSONs which is the correct thing to read.
|
||||
//!
|
||||
//! Companion to graphics auto-configuration logic, which decides "what
|
||||
//! config should we recommend". This module answers "what hardware/stacks
|
||||
//! do we actually have".
|
||||
//!
|
||||
//! Cross-platform contract: the public API (`enumerate_gpus`,
|
||||
//! `env_vars_for_preferred_gpu`, `PreferredGpu`) compiles on every
|
||||
//! platform. macOS/Windows fall through to empty enumerations and a
|
||||
//! no-op env-var application; the Software entry still round-trips
|
||||
//! through storage without breaking the build.
|
||||
|
||||
use serde::Serialize;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::Path;
|
||||
|
||||
/// PCI vendor IDs we recognize.
|
||||
const VENDOR_INTEL: &str = "0x8086";
|
||||
const VENDOR_AMD: &str = "0x1002";
|
||||
const VENDOR_NVIDIA: &str = "0x10de";
|
||||
|
||||
/// Coarse classification of a GPU's role on the system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GpuKind {
|
||||
Integrated,
|
||||
Discrete,
|
||||
/// Pure-software rendering (llvmpipe / cpu fallback). Not a real device.
|
||||
Software,
|
||||
}
|
||||
|
||||
/// One detected GPU with everything the picker needs to render and the
|
||||
/// env-var application path needs to act on the user's choice.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct DetectedGpu {
|
||||
/// Stable identifier across launches: the PCI slot string for real
|
||||
/// devices (e.g. "0000:01:00.0") or a sentinel for software.
|
||||
pub id: String,
|
||||
pub vendor: String,
|
||||
/// Human-readable model where available.
|
||||
pub name: String,
|
||||
/// PCI ID as `vendor:device` for diagnostics.
|
||||
pub pci_id: Option<String>,
|
||||
pub kind: GpuKind,
|
||||
/// Absolute path of the EGL vendor JSON that exposes this GPU's stack to
|
||||
/// the running process, if one is available.
|
||||
pub egl_vendor_json: Option<String>,
|
||||
/// True when the GPU is both physically present and reachable via an EGL
|
||||
/// vendor JSON.
|
||||
pub is_usable: bool,
|
||||
}
|
||||
|
||||
/// Enumerate every GPU the user could pick. Always returns at least the
|
||||
/// Software entry so the dropdown is never empty on systems with no detectable
|
||||
/// hardware.
|
||||
pub fn enumerate_gpus() -> Vec<DetectedGpu> {
|
||||
let physical = detect_physical_gpus();
|
||||
let egl = enumerate_egl_vendor_jsons();
|
||||
|
||||
let mut result: Vec<DetectedGpu> = physical
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let egl_path = match p.vendor.as_str() {
|
||||
"NVIDIA" => egl.nvidia.clone(),
|
||||
_ => egl.mesa.clone(),
|
||||
};
|
||||
DetectedGpu {
|
||||
id: p.pci_slot.clone(),
|
||||
vendor: p.vendor.clone(),
|
||||
name: p.name.clone(),
|
||||
pci_id: Some(format!("{}:{}", p.pci_vendor_hex, p.pci_device_hex)),
|
||||
kind: p.kind,
|
||||
is_usable: egl_path.is_some(),
|
||||
egl_vendor_json: egl_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.push(DetectedGpu {
|
||||
id: "software".to_string(),
|
||||
vendor: "Software".to_string(),
|
||||
name: "Software (CPU, llvmpipe)".to_string(),
|
||||
pci_id: None,
|
||||
kind: GpuKind::Software,
|
||||
egl_vendor_json: egl.mesa.clone(),
|
||||
is_usable: egl.mesa.is_some(),
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PhysicalGpu {
|
||||
pci_slot: String,
|
||||
pci_vendor_hex: String,
|
||||
pci_device_hex: String,
|
||||
vendor: String,
|
||||
name: String,
|
||||
kind: GpuKind,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn enumerate_egl_vendor_jsons() -> EglVendors {
|
||||
EglVendors::default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
|
||||
let drm_root = Path::new("/sys/class/drm");
|
||||
let entries = match fs::read_dir(drm_root) {
|
||||
Ok(it) => it,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut gpus: Vec<PhysicalGpu> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("card") || name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let device_path = entry.path().join("device");
|
||||
let canonical = match fs::canonicalize(&device_path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let pci_slot = pci_slot_from_path(&canonical).unwrap_or_else(|| name.clone());
|
||||
|
||||
let pci_vendor_hex = read_trimmed(&device_path.join("vendor")).unwrap_or_default();
|
||||
let pci_device_hex = read_trimmed(&device_path.join("device")).unwrap_or_default();
|
||||
let vendor = classify_vendor(&pci_vendor_hex);
|
||||
if vendor == "Unknown" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let product_name =
|
||||
read_trimmed(&device_path.join("product_name")).filter(|s| !s.is_empty());
|
||||
let name = build_human_name(&vendor, product_name.as_deref());
|
||||
|
||||
gpus.push(PhysicalGpu {
|
||||
pci_slot,
|
||||
pci_vendor_hex: strip_hex_prefix(&pci_vendor_hex),
|
||||
pci_device_hex: strip_hex_prefix(&pci_device_hex),
|
||||
vendor,
|
||||
name,
|
||||
kind: GpuKind::Discrete,
|
||||
});
|
||||
}
|
||||
|
||||
classify_kinds(&mut gpus);
|
||||
gpus
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pci_slot_from_path(path: &Path) -> Option<String> {
|
||||
path.file_name().map(|s| s.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_trimmed(p: &Path) -> Option<String> {
|
||||
fs::read_to_string(p).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn strip_hex_prefix(s: &str) -> String {
|
||||
s.strip_prefix("0x").unwrap_or(s).to_string()
|
||||
}
|
||||
|
||||
fn classify_vendor(pci_vendor: &str) -> String {
|
||||
match pci_vendor {
|
||||
VENDOR_INTEL => "Intel".to_string(),
|
||||
VENDOR_AMD => "AMD".to_string(),
|
||||
VENDOR_NVIDIA => "NVIDIA".to_string(),
|
||||
_ => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_human_name(vendor: &str, product: Option<&str>) -> String {
|
||||
match (vendor, product) {
|
||||
(_, Some(p)) if !p.is_empty() => p.to_string(),
|
||||
("Intel", None) => "Intel Graphics".to_string(),
|
||||
("AMD", None) => "AMD Graphics".to_string(),
|
||||
("NVIDIA", None) => "NVIDIA Graphics".to_string(),
|
||||
_ => "GPU".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_kinds(gpus: &mut [PhysicalGpu]) {
|
||||
let has_nvidia = gpus.iter().any(|g| g.vendor == "NVIDIA");
|
||||
let has_amd = gpus.iter().any(|g| g.vendor == "AMD");
|
||||
|
||||
for g in gpus.iter_mut() {
|
||||
g.kind = match g.vendor.as_str() {
|
||||
"NVIDIA" => GpuKind::Discrete,
|
||||
"Intel" => {
|
||||
if has_nvidia || has_amd || slot_is_root_bus(&g.pci_slot) {
|
||||
GpuKind::Integrated
|
||||
} else {
|
||||
GpuKind::Discrete
|
||||
}
|
||||
}
|
||||
"AMD" => {
|
||||
if has_nvidia || slot_is_root_bus(&g.pci_slot) {
|
||||
GpuKind::Integrated
|
||||
} else {
|
||||
GpuKind::Discrete
|
||||
}
|
||||
}
|
||||
_ => GpuKind::Discrete,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_is_root_bus(slot: &str) -> bool {
|
||||
slot.split(':').nth(1).map(|bb| bb == "00").unwrap_or(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct EglVendors {
|
||||
mesa: Option<String>,
|
||||
nvidia: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enumerate_egl_vendor_jsons() -> EglVendors {
|
||||
let candidates: [&str; 2] = ["/usr/share/glvnd/egl_vendor.d", "/etc/glvnd/egl_vendor.d"];
|
||||
let mut out = EglVendors::default();
|
||||
for root in candidates {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let lower = file_name.to_lowercase();
|
||||
let abs = path.to_string_lossy().to_string();
|
||||
if lower.contains("nvidia") {
|
||||
if out.nvidia.is_none() {
|
||||
out.nvidia = Some(abs);
|
||||
}
|
||||
} else if lower.contains("mesa") && out.mesa.is_none() {
|
||||
out.mesa = Some(abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An action for the host shell to apply to the process environment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EnvAction {
|
||||
Set(String, String),
|
||||
Unset(String),
|
||||
}
|
||||
|
||||
/// Decide what `PreferredGpu::Auto` should pin on this system.
|
||||
pub fn auto_resolves_to(detected: &[DetectedGpu]) -> Option<&DetectedGpu> {
|
||||
let integrated = detected
|
||||
.iter()
|
||||
.find(|g| g.kind == GpuKind::Integrated && g.is_usable);
|
||||
let has_discrete = detected
|
||||
.iter()
|
||||
.any(|g| g.kind == GpuKind::Discrete && g.is_usable);
|
||||
if integrated.is_some() && has_discrete {
|
||||
integrated
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the env actions that pin the running process to a specific GPU stack.
|
||||
/// Returning an empty vec means "auto with no resolution".
|
||||
pub fn env_vars_for_preferred_gpu(
|
||||
preferred: &PreferredGpu,
|
||||
detected: &[DetectedGpu],
|
||||
) -> Vec<EnvAction> {
|
||||
match preferred {
|
||||
PreferredGpu::Auto => match auto_resolves_to(detected) {
|
||||
Some(g) => env_vars_for_gpu(g),
|
||||
None => Vec::new(),
|
||||
},
|
||||
PreferredGpu::Software => vec![
|
||||
EnvAction::Set("LIBGL_ALWAYS_SOFTWARE".to_string(), "1".to_string()),
|
||||
EnvAction::Set(
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER".to_string(),
|
||||
"1".to_string(),
|
||||
),
|
||||
EnvAction::Set("GSK_RENDERER".to_string(), "cairo".to_string()),
|
||||
EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string()),
|
||||
],
|
||||
PreferredGpu::Kind(kind) => {
|
||||
let target = detected.iter().find(|g| g.kind == *kind && g.is_usable);
|
||||
let Some(g) = target else {
|
||||
return Vec::new();
|
||||
};
|
||||
env_vars_for_gpu(g)
|
||||
}
|
||||
PreferredGpu::SpecificId(id) => {
|
||||
let target = detected.iter().find(|g| &g.id == id);
|
||||
let Some(g) = target else {
|
||||
return Vec::new();
|
||||
};
|
||||
env_vars_for_gpu(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_vars_for_gpu(g: &DetectedGpu) -> Vec<EnvAction> {
|
||||
let mut env: Vec<EnvAction> = Vec::new();
|
||||
if let Some(path) = &g.egl_vendor_json {
|
||||
env.push(EnvAction::Set(
|
||||
"__EGL_VENDOR_LIBRARY_FILENAMES".to_string(),
|
||||
path.clone(),
|
||||
));
|
||||
}
|
||||
match g.vendor.as_str() {
|
||||
"Intel" | "AMD" => {
|
||||
env.push(EnvAction::Set(
|
||||
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
|
||||
"mesa".to_string(),
|
||||
));
|
||||
env.push(EnvAction::Set("DRI_PRIME".to_string(), "0".to_string()));
|
||||
env.push(EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string()));
|
||||
}
|
||||
"NVIDIA" => {
|
||||
env.push(EnvAction::Set(
|
||||
"__NV_PRIME_RENDER_OFFLOAD".to_string(),
|
||||
"1".to_string(),
|
||||
));
|
||||
env.push(EnvAction::Set(
|
||||
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
|
||||
"nvidia".to_string(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// User-facing preference. Mirrors the column in graphics_settings.db.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PreferredGpu {
|
||||
Auto,
|
||||
Software,
|
||||
Kind(GpuKind),
|
||||
SpecificId(String),
|
||||
}
|
||||
|
||||
impl PreferredGpu {
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw.trim().to_lowercase().as_str() {
|
||||
"" | "auto" => Self::Auto,
|
||||
"software" | "cpu" => Self::Software,
|
||||
"integrated" | "igpu" => Self::Kind(GpuKind::Integrated),
|
||||
"discrete" | "dgpu" => Self::Kind(GpuKind::Discrete),
|
||||
other => Self::SpecificId(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_storage_string(&self) -> String {
|
||||
match self {
|
||||
Self::Auto => "auto".to_string(),
|
||||
Self::Software => "software".to_string(),
|
||||
Self::Kind(GpuKind::Integrated) => "integrated".to_string(),
|
||||
Self::Kind(GpuKind::Discrete) => "discrete".to_string(),
|
||||
Self::Kind(GpuKind::Software) => "software".to_string(),
|
||||
Self::SpecificId(id) => id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn detected_gpu(
|
||||
id: &str,
|
||||
vendor: &str,
|
||||
kind: GpuKind,
|
||||
is_usable: bool,
|
||||
egl_vendor_json: Option<&str>,
|
||||
) -> DetectedGpu {
|
||||
DetectedGpu {
|
||||
id: id.to_string(),
|
||||
vendor: vendor.to_string(),
|
||||
name: format!("{vendor} Graphics"),
|
||||
pci_id: None,
|
||||
kind,
|
||||
egl_vendor_json: egl_vendor_json.map(str::to_string),
|
||||
is_usable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_gpu_round_trip() {
|
||||
for raw in ["auto", "software", "integrated", "discrete", "0000:01:00.0"] {
|
||||
let parsed = PreferredGpu::parse(raw);
|
||||
assert_eq!(parsed.as_storage_string(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_root_bus_classification() {
|
||||
assert!(slot_is_root_bus("0000:00:02.0"));
|
||||
assert!(slot_is_root_bus("0000:00:01.0"));
|
||||
assert!(!slot_is_root_bus("0000:01:00.0"));
|
||||
assert!(!slot_is_root_bus("0000:02:00.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_classification() {
|
||||
assert_eq!(classify_vendor(VENDOR_INTEL), "Intel");
|
||||
assert_eq!(classify_vendor(VENDOR_AMD), "AMD");
|
||||
assert_eq!(classify_vendor(VENDOR_NVIDIA), "NVIDIA");
|
||||
assert_eq!(classify_vendor("0xdeadbeef"), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_resolves_to_integrated_gpu_on_hybrid_system() {
|
||||
let gpus = vec![
|
||||
detected_gpu(
|
||||
"0000:00:02.0",
|
||||
"Intel",
|
||||
GpuKind::Integrated,
|
||||
true,
|
||||
Some("/mesa.json"),
|
||||
),
|
||||
detected_gpu(
|
||||
"0000:01:00.0",
|
||||
"NVIDIA",
|
||||
GpuKind::Discrete,
|
||||
true,
|
||||
Some("/nvidia.json"),
|
||||
),
|
||||
];
|
||||
|
||||
let resolved = auto_resolves_to(&gpus).expect("hybrid should resolve");
|
||||
|
||||
assert_eq!(resolved.id, "0000:00:02.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_does_not_resolve_without_usable_discrete_gpu() {
|
||||
let gpus = vec![
|
||||
detected_gpu(
|
||||
"0000:00:02.0",
|
||||
"Intel",
|
||||
GpuKind::Integrated,
|
||||
true,
|
||||
Some("/mesa.json"),
|
||||
),
|
||||
detected_gpu(
|
||||
"0000:01:00.0",
|
||||
"NVIDIA",
|
||||
GpuKind::Discrete,
|
||||
false,
|
||||
Some("/nvidia.json"),
|
||||
),
|
||||
];
|
||||
|
||||
assert!(auto_resolves_to(&gpus).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_preference_returns_cpu_env_actions() {
|
||||
let actions = env_vars_for_preferred_gpu(&PreferredGpu::Software, &[]);
|
||||
|
||||
assert!(actions.contains(&EnvAction::Set(
|
||||
"LIBGL_ALWAYS_SOFTWARE".to_string(),
|
||||
"1".to_string()
|
||||
)));
|
||||
assert!(actions.contains(&EnvAction::Set(
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER".to_string(),
|
||||
"1".to_string()
|
||||
)));
|
||||
assert!(actions.contains(&EnvAction::Set(
|
||||
"GSK_RENDERER".to_string(),
|
||||
"cairo".to_string()
|
||||
)));
|
||||
assert!(actions.contains(&EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod boot_watchdog;
|
||||
pub mod graphics_autoconfig;
|
||||
pub mod graphics_detection;
|
||||
pub mod runtime;
|
||||
pub mod settings;
|
||||
pub mod user_data;
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Runtime Session Contract
|
||||
//!
|
||||
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
|
||||
//!
|
||||
//! Key concepts:
|
||||
//! - Single bootstrap entrypoint
|
||||
//! - Canonical state machine (Uninitialized -> Ready)
|
||||
//! - Typed errors (no string matching)
|
||||
//! - Command gating in backend
|
||||
//! - Lifecycle events
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Canonical runtime states
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "data")]
|
||||
pub enum RuntimeState {
|
||||
/// Initial state - nothing initialized
|
||||
Uninitialized,
|
||||
/// Client initialized but no authentication
|
||||
InitializedNoAuth,
|
||||
/// Authenticated but per-user session not activated (transitional)
|
||||
AuthenticatedNoUserSession { user_id: u64 },
|
||||
/// Fully ready - all systems operational
|
||||
Ready { user_id: u64 },
|
||||
/// Degraded state - something is broken
|
||||
Degraded { reason: DegradedReason },
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self::Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons for degraded state
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "message")]
|
||||
pub enum DegradedReason {
|
||||
/// Bundle token extraction failed
|
||||
BundleExtractionFailed(String),
|
||||
/// CoreBridge initialization failed
|
||||
CoreBridgeInitFailed(String),
|
||||
/// Network connectivity issues
|
||||
NetworkError(String),
|
||||
/// Database/storage issues
|
||||
StorageError(String),
|
||||
/// Session activation failed (per-user stores not initialized)
|
||||
SessionActivationFailed(String),
|
||||
}
|
||||
|
||||
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeStatus {
|
||||
/// Current state
|
||||
pub state: RuntimeState,
|
||||
/// User ID if authenticated (None if not logged in)
|
||||
pub user_id: Option<u64>,
|
||||
/// Whether the API client is initialized (bundle tokens extracted)
|
||||
pub client_initialized: bool,
|
||||
/// Whether legacy auth is active
|
||||
pub legacy_auth: bool,
|
||||
/// Whether CoreBridge/V2 auth is active
|
||||
pub corebridge_auth: bool,
|
||||
/// Whether per-user session is activated
|
||||
pub session_activated: bool,
|
||||
/// Degraded reason if state is Degraded
|
||||
pub degraded_reason: Option<DegradedReason>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RuntimeState::Uninitialized,
|
||||
user_id: None,
|
||||
client_initialized: false,
|
||||
legacy_auth: false,
|
||||
corebridge_auth: false,
|
||||
session_activated: false,
|
||||
degraded_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed runtime errors - no string matching in clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "details")]
|
||||
pub enum RuntimeError {
|
||||
/// Runtime not initialized - call runtime_bootstrap first
|
||||
RuntimeNotInitialized,
|
||||
/// Authentication required for this operation
|
||||
AuthRequired,
|
||||
/// Per-user session not activated - call activate_user_session
|
||||
UserSessionNotActivated,
|
||||
/// CoreBridge/V2 auth missing - V2 commands won't work
|
||||
CoreBridgeAuthMissing,
|
||||
/// Runtime is in degraded state
|
||||
RuntimeDegraded(DegradedReason),
|
||||
/// Invalid user ID (e.g., 0)
|
||||
InvalidUserId,
|
||||
/// Bootstrap already in progress
|
||||
BootstrapInProgress,
|
||||
/// V2 CoreBridge authentication failed
|
||||
V2AuthFailed(String),
|
||||
/// V2 CoreBridge not initialized
|
||||
V2NotInitialized,
|
||||
/// Manual offline mode is on and the requested track is not available
|
||||
/// in any local cache. Emitted instead of silently streaming from the
|
||||
/// network (see issue #279).
|
||||
TrackNotAvailableOffline,
|
||||
/// Internal error
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
|
||||
Self::AuthRequired => write!(f, "Authentication required"),
|
||||
Self::UserSessionNotActivated => write!(f, "User session not activated"),
|
||||
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
|
||||
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
|
||||
Self::InvalidUserId => write!(f, "Invalid user ID"),
|
||||
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
|
||||
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
|
||||
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
|
||||
Self::TrackNotAvailableOffline => {
|
||||
write!(f, "Track not available in offline cache while in offline mode")
|
||||
}
|
||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
/// Command prerequisites - what each command requires
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandRequirement {
|
||||
/// No requirements (public endpoints)
|
||||
None,
|
||||
/// Requires client to be initialized (bundle tokens)
|
||||
RequiresClientInit,
|
||||
/// Requires authentication (logged in)
|
||||
RequiresAuth,
|
||||
/// Requires per-user session to be activated
|
||||
RequiresUserSession,
|
||||
/// Requires CoreBridge/V2 auth (for V2 commands)
|
||||
RequiresCoreBridgeAuth,
|
||||
}
|
||||
|
||||
/// Runtime state manager - thread-safe, holds canonical state
|
||||
pub struct RuntimeManager {
|
||||
state: Arc<RwLock<RuntimeStatus>>,
|
||||
bootstrap_in_progress: Arc<RwLock<bool>>,
|
||||
/// Tracks which Mixtape/Collection the current queue was built from.
|
||||
/// Set by v2_enqueue_collection (replace mode), cleared by any
|
||||
/// non-Mixtape queue replacement (set_queue / clear_queue).
|
||||
/// Append-style ops (add/add_next/bulk) preserve this value.
|
||||
/// Persistence is in-memory only; session_queue_state table will
|
||||
/// add the source_collection_id column when it lands.
|
||||
queue_source_collection_id: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl RuntimeManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(RuntimeStatus::default())),
|
||||
bootstrap_in_progress: Arc::new(RwLock::new(false)),
|
||||
queue_source_collection_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) which collection the current queue was built from.
|
||||
pub async fn set_queue_source_collection(&self, id: Option<String>) {
|
||||
*self.queue_source_collection_id.write().await = id;
|
||||
}
|
||||
|
||||
/// Return the collection ID that seeded the current queue, if any.
|
||||
pub async fn get_queue_source_collection(&self) -> Option<String> {
|
||||
self.queue_source_collection_id.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get current runtime status
|
||||
pub async fn get_status(&self) -> RuntimeStatus {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update runtime state
|
||||
pub async fn set_state(&self, new_state: RuntimeState) {
|
||||
let mut status = self.state.write().await;
|
||||
status.state = new_state.clone();
|
||||
|
||||
// Update derived fields based on state
|
||||
match &new_state {
|
||||
RuntimeState::Uninitialized => {
|
||||
status.client_initialized = false;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::InitializedNoAuth => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::AuthenticatedNoUserSession { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.session_activated = false;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Ready { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.corebridge_auth = true;
|
||||
status.session_activated = true;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Degraded { reason } => {
|
||||
status.degraded_reason = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Runtime] State changed to: {:?}", new_state);
|
||||
}
|
||||
|
||||
/// Mark client as initialized
|
||||
pub async fn set_client_initialized(&self, initialized: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.client_initialized = initialized;
|
||||
if initialized && status.state == RuntimeState::Uninitialized {
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark legacy auth status
|
||||
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
|
||||
let mut status = self.state.write().await;
|
||||
status.legacy_auth = auth;
|
||||
if auth {
|
||||
if let Some(uid) = user_id {
|
||||
status.user_id = Some(uid);
|
||||
if !status.session_activated {
|
||||
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.user_id = None;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark CoreBridge auth status
|
||||
pub async fn set_corebridge_auth(&self, auth: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.corebridge_auth = auth;
|
||||
}
|
||||
|
||||
/// Mark session as activated
|
||||
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
|
||||
let mut status = self.state.write().await;
|
||||
status.session_activated = activated;
|
||||
if activated && status.legacy_auth && status.corebridge_auth {
|
||||
status.state = RuntimeState::Ready { user_id };
|
||||
status.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if bootstrap is in progress
|
||||
pub async fn is_bootstrap_in_progress(&self) -> bool {
|
||||
*self.bootstrap_in_progress.read().await
|
||||
}
|
||||
|
||||
/// Set bootstrap in progress flag
|
||||
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
|
||||
*self.bootstrap_in_progress.write().await = in_progress;
|
||||
}
|
||||
|
||||
/// Validate command requirements against current state
|
||||
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
|
||||
let status = self.state.read().await;
|
||||
|
||||
match req {
|
||||
CommandRequirement::None => Ok(()),
|
||||
CommandRequirement::RequiresClientInit => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresUserSession => {
|
||||
// RequiresUserSession only checks session_activated, not legacy_auth.
|
||||
// This allows both:
|
||||
// - Online sessions: session_activated=true via activate_session()
|
||||
// - Offline sessions: session_activated=true via activate_offline_session()
|
||||
//
|
||||
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
|
||||
if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresCoreBridgeAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else if !status.corebridge_auth {
|
||||
Err(RuntimeError::CoreBridgeAuthMissing)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if in degraded state
|
||||
pub async fn is_degraded(&self) -> bool {
|
||||
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
|
||||
}
|
||||
|
||||
/// Set degraded state
|
||||
pub async fn set_degraded(&self, reason: DegradedReason) {
|
||||
self.set_state(RuntimeState::Degraded { reason }).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted during runtime lifecycle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum RuntimeEvent {
|
||||
/// Runtime initialized (client ready)
|
||||
RuntimeInitialized,
|
||||
/// Authentication state changed
|
||||
AuthChanged {
|
||||
logged_in: bool,
|
||||
user_id: Option<u64>,
|
||||
},
|
||||
/// Per-user session activated
|
||||
UserSessionActivated { user_id: u64 },
|
||||
/// Per-user session deactivated
|
||||
UserSessionDeactivated,
|
||||
/// CoreBridge auth failed
|
||||
CoreBridgeAuthFailed { error: String },
|
||||
/// Runtime entered degraded state
|
||||
RuntimeDegraded { reason: DegradedReason },
|
||||
/// Runtime fully ready
|
||||
RuntimeReady { user_id: u64 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mixtape_context_set_clear() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
// Initially None
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
|
||||
// Set Some
|
||||
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
|
||||
assert_eq!(
|
||||
mgr.get_queue_source_collection().await,
|
||||
Some("col-abc".to_string())
|
||||
);
|
||||
|
||||
// Clear to None
|
||||
mgr.set_queue_source_collection(None).await;
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_state_is_uninitialized() {
|
||||
let mgr = RuntimeManager::new();
|
||||
let status = mgr.get_status().await;
|
||||
|
||||
assert_eq!(status.state, RuntimeState::Uninitialized);
|
||||
assert!(!status.client_initialized);
|
||||
assert!(!status.legacy_auth);
|
||||
assert!(!status.corebridge_auth);
|
||||
assert!(!status.session_activated);
|
||||
assert_eq!(status.user_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_initialized_transitions_to_initialized_no_auth() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
mgr.set_client_initialized(true).await;
|
||||
let status = mgr.get_status().await;
|
||||
|
||||
assert_eq!(status.state, RuntimeState::InitializedNoAuth);
|
||||
assert!(status.client_initialized);
|
||||
assert!(!status.legacy_auth);
|
||||
assert_eq!(status.user_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_auth_transitions_to_authenticated_no_user_session() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
mgr.set_client_initialized(true).await;
|
||||
mgr.set_legacy_auth(true, Some(42)).await;
|
||||
let status = mgr.get_status().await;
|
||||
|
||||
assert_eq!(
|
||||
status.state,
|
||||
RuntimeState::AuthenticatedNoUserSession { user_id: 42 }
|
||||
);
|
||||
assert!(status.client_initialized);
|
||||
assert!(status.legacy_auth);
|
||||
assert!(!status.session_activated);
|
||||
assert_eq!(status.user_id, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corebridge_auth_and_session_activation_reaches_ready() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
mgr.set_client_initialized(true).await;
|
||||
mgr.set_legacy_auth(true, Some(42)).await;
|
||||
mgr.set_corebridge_auth(true).await;
|
||||
mgr.set_session_activated(true, 42).await;
|
||||
let status = mgr.get_status().await;
|
||||
|
||||
assert_eq!(status.state, RuntimeState::Ready { user_id: 42 });
|
||||
assert!(status.client_initialized);
|
||||
assert!(status.legacy_auth);
|
||||
assert!(status.corebridge_auth);
|
||||
assert!(status.session_activated);
|
||||
assert_eq!(status.user_id, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requires_user_session_accepts_offline_session_without_legacy_auth() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
mgr.set_session_activated(true, 0).await;
|
||||
|
||||
assert!(mgr
|
||||
.check_requirements(CommandRequirement::RequiresUserSession)
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(matches!(
|
||||
mgr.check_requirements(CommandRequirement::RequiresAuth).await,
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Developer settings persistence.
|
||||
//!
|
||||
//! This module stores portable developer-mode toggles only. Tauri command
|
||||
//! wrappers and restart messaging stay outside `qbz-app`.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeveloperSettings {
|
||||
pub force_dmabuf: bool,
|
||||
}
|
||||
|
||||
impl Default for DeveloperSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
force_dmabuf: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeveloperSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DeveloperSettingsStore {
|
||||
/// Lightweight read-only open for startup before host-managed state exists.
|
||||
/// Opens existing DB without creating tables or running migrations.
|
||||
pub fn new_readonly() -> Result<Self, String> {
|
||||
let db_path = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("developer_settings.db");
|
||||
Self::new_readonly_at_path(&db_path)
|
||||
}
|
||||
|
||||
pub fn new_readonly_at_path(db_path: &Path) -> Result<Self, String> {
|
||||
if !db_path.exists() {
|
||||
return Err("Developer settings DB does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to open developer settings database (readonly): {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for developer settings database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS developer_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
force_dmabuf INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO developer_settings (id, force_dmabuf) VALUES (1, 0);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create developer settings table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "developer_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "developer_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<DeveloperSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT force_dmabuf FROM developer_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(DeveloperSettings {
|
||||
force_dmabuf: row.get::<_, i64>(0)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get developer settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_force_dmabuf(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE developer_settings SET force_dmabuf = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set force_dmabuf: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for host state management.
|
||||
pub struct DeveloperSettingsState {
|
||||
pub store: Arc<Mutex<Option<DeveloperSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl DeveloperSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(DeveloperSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeveloperSettingsState {
|
||||
fn default() -> Self {
|
||||
Self::new_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, DeveloperSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = DeveloperSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_settings_default_values_are_stable() {
|
||||
let settings = DeveloperSettings::default();
|
||||
|
||||
assert!(!settings.force_dmabuf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_settings_store_returns_defaults() {
|
||||
let (dir, store) = fresh_store("developer-default");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.force_dmabuf);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_settings_persist_force_dmabuf() {
|
||||
let dir = unique_test_dir("developer-force-dmabuf");
|
||||
{
|
||||
let store = DeveloperSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_force_dmabuf(true).expect("set force dmabuf");
|
||||
}
|
||||
|
||||
let reopened = DeveloperSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.force_dmabuf);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_settings_readonly_opens_existing_db() {
|
||||
let dir = unique_test_dir("developer-readonly");
|
||||
let db_path = dir.join("developer_settings.db");
|
||||
{
|
||||
let store = DeveloperSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_force_dmabuf(true).expect("set force dmabuf");
|
||||
}
|
||||
|
||||
let readonly = DeveloperSettingsStore::new_readonly_at_path(&db_path)
|
||||
.expect("open existing store read-only");
|
||||
let settings = readonly.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.force_dmabuf);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Download settings persistence.
|
||||
//!
|
||||
//! This module stores portable download preferences only. Host filesystem
|
||||
//! validation, permission probes, and download/cache behavior stay outside
|
||||
//! `qbz-app`.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadSettings {
|
||||
pub download_root: String,
|
||||
pub show_in_library: bool,
|
||||
}
|
||||
|
||||
impl Default for DownloadSettings {
|
||||
fn default() -> Self {
|
||||
let default_root = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("qbz")
|
||||
.join("audio")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
download_root: default_root,
|
||||
// New installs surface offline-cached Qobuz tracks in the
|
||||
// Local Library by default. Existing users with a persisted value
|
||||
// are not affected because INSERT OR IGNORE only seeds first run.
|
||||
show_in_library: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DownloadSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DownloadSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open download settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for download settings database: {}", e))?;
|
||||
|
||||
let default_settings = DownloadSettings::default();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS download_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
download_root TEXT NOT NULL,
|
||||
show_in_library INTEGER NOT NULL DEFAULT 1
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create download settings table: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO download_settings (id, download_root, show_in_library)
|
||||
VALUES (1, ?1, ?2)",
|
||||
params![
|
||||
default_settings.download_root,
|
||||
default_settings.show_in_library as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize download settings: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "download_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "download_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<DownloadSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT download_root, show_in_library FROM download_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(DownloadSettings {
|
||||
download_root: row.get(0)?,
|
||||
show_in_library: row.get::<_, i64>(1)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get download settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_download_root(&self, path: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE download_settings SET download_root = ?1 WHERE id = 1",
|
||||
params![path],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set download root: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_show_in_library(&self, show: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE download_settings SET show_in_library = ?1 WHERE id = 1",
|
||||
params![show as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set show_in_library: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type DownloadSettingsState = Arc<Mutex<Option<DownloadSettingsStore>>>;
|
||||
|
||||
pub fn create_download_settings_state() -> Result<DownloadSettingsState, String> {
|
||||
let store = DownloadSettingsStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_download_settings_state() -> DownloadSettingsState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, DownloadSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = DownloadSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_settings_default_values_are_stable() {
|
||||
let settings = DownloadSettings::default();
|
||||
|
||||
assert!(settings.download_root.ends_with("qbz/audio"));
|
||||
assert!(settings.show_in_library);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_settings_store_returns_defaults() {
|
||||
let (dir, store) = fresh_store("download-default");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.download_root.ends_with("qbz/audio"));
|
||||
assert!(settings.show_in_library);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_settings_persist_download_root() {
|
||||
let dir = unique_test_dir("download-root");
|
||||
{
|
||||
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_download_root("/tmp/qbz-downloads")
|
||||
.expect("set download root");
|
||||
}
|
||||
|
||||
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.download_root, "/tmp/qbz-downloads");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_settings_persist_show_in_library() {
|
||||
let dir = unique_test_dir("download-library");
|
||||
{
|
||||
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_show_in_library(false)
|
||||
.expect("set show in library");
|
||||
}
|
||||
|
||||
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.show_in_library);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_settings_reopen_does_not_overwrite_existing_row() {
|
||||
let dir = unique_test_dir("download-no-overwrite");
|
||||
{
|
||||
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_download_root("/tmp/custom-downloads")
|
||||
.expect("set download root");
|
||||
store
|
||||
.set_show_in_library(false)
|
||||
.expect("set show in library");
|
||||
}
|
||||
|
||||
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.download_root, "/tmp/custom-downloads");
|
||||
assert!(!settings.show_in_library);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use chrono::Utc;
|
||||
use md5::{Digest, Md5};
|
||||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FavoritesPreferences {
|
||||
pub custom_icon_path: Option<String>,
|
||||
pub custom_icon_preset: Option<String>,
|
||||
pub icon_background: Option<String>,
|
||||
pub tab_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for FavoritesPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_icon_path: None,
|
||||
custom_icon_preset: Some("heart".to_string()),
|
||||
icon_background: None,
|
||||
tab_order: vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FavoritesPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl FavoritesPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS favorites_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
custom_icon_path TEXT,
|
||||
custom_icon_preset TEXT,
|
||||
tab_order TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create favorites preferences table: {}", e))?;
|
||||
|
||||
// Migration: Add icon_background column if it doesn't exist
|
||||
let has_icon_background = conn
|
||||
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
|
||||
.is_ok();
|
||||
|
||||
if !has_icon_background {
|
||||
conn.execute(
|
||||
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add icon_background column: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "favorites_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "favorites_preferences.db")
|
||||
}
|
||||
|
||||
fn favorites_icon_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or("Could not determine cache directory")?
|
||||
.join("qbz")
|
||||
.join("favorites");
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("Failed to create favorites icon directory: {}", e))?;
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
fn normalize_custom_icon_path(&self, path: String) -> Result<String, String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let source = Path::new(trimmed);
|
||||
if !source.exists() {
|
||||
return Err(format!("Source icon does not exist: {}", trimmed));
|
||||
}
|
||||
|
||||
let icon_dir = Self::favorites_icon_dir()?;
|
||||
if source.starts_with(&icon_dir) {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
|
||||
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("png");
|
||||
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(trimmed.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let filename = format!(
|
||||
"favorites_icon_{}_{}.{}",
|
||||
hash,
|
||||
Utc::now().timestamp(),
|
||||
extension
|
||||
);
|
||||
let dest_path = icon_dir.join(filename);
|
||||
|
||||
fs::copy(source, &dest_path)
|
||||
.map_err(|e| format!("Failed to copy favorites icon: {}", e))?;
|
||||
|
||||
Ok(dest_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<FavoritesPreferences, String> {
|
||||
let mut stmt = self.conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")
|
||||
.map_err(|e| format!("Failed to prepare select: {}", e))?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let custom_icon_path: Option<String> = row.get(0)?;
|
||||
let custom_icon_preset: Option<String> = row.get(1)?;
|
||||
let icon_background: Option<String> = row.get(2)?;
|
||||
let tab_order_str: String = row.get(3)?;
|
||||
|
||||
let custom_icon_path = custom_icon_path.and_then(|value| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
});
|
||||
|
||||
let tab_order: Vec<String> =
|
||||
serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
|
||||
vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(FavoritesPreferences {
|
||||
custom_icon_path,
|
||||
custom_icon_preset,
|
||||
icon_background,
|
||||
tab_order,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(mut prefs) => {
|
||||
if let Some(path) = prefs.custom_icon_path.clone() {
|
||||
match self.normalize_custom_icon_path(path) {
|
||||
Ok(resolved) => {
|
||||
let normalized = if resolved.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(resolved)
|
||||
};
|
||||
if normalized != prefs.custom_icon_path {
|
||||
prefs.custom_icon_path = normalized;
|
||||
let _ = self.save_preferences(prefs.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
prefs.custom_icon_path = None;
|
||||
let _ = self.save_preferences(prefs.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(prefs)
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
|
||||
Err(e) => Err(format!("Failed to query preferences: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(
|
||||
&self,
|
||||
mut prefs: FavoritesPreferences,
|
||||
) -> Result<FavoritesPreferences, String> {
|
||||
if let Some(path) = prefs.custom_icon_path.clone() {
|
||||
let resolved = self.normalize_custom_icon_path(path)?;
|
||||
if resolved.is_empty() {
|
||||
prefs.custom_icon_path = None;
|
||||
} else {
|
||||
prefs.custom_icon_path = Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order)
|
||||
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save preferences: {}", e))?;
|
||||
Ok(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FavoritesPreferencesState {
|
||||
pub store: Arc<Mutex<Option<FavoritesPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl FavoritesPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = FavoritesPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = FavoritesPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_table(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS favorites_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
custom_icon_path TEXT,
|
||||
custom_icon_preset TEXT,
|
||||
tab_order TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migration: Add icon_background column if it doesn't exist
|
||||
let has_icon_background = conn
|
||||
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
|
||||
.is_ok();
|
||||
|
||||
if !has_icon_background {
|
||||
conn.execute(
|
||||
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_preferences(conn: &Connection) -> Result<FavoritesPreferences> {
|
||||
let mut stmt = conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let custom_icon_path: Option<String> = row.get(0)?;
|
||||
let custom_icon_preset: Option<String> = row.get(1)?;
|
||||
let icon_background: Option<String> = row.get(2)?;
|
||||
let tab_order_str: String = row.get(3)?;
|
||||
|
||||
let custom_icon_path = custom_icon_path.and_then(|value| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
});
|
||||
|
||||
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
|
||||
vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(FavoritesPreferences {
|
||||
custom_icon_path,
|
||||
custom_icon_preset,
|
||||
icon_background,
|
||||
tab_order,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(prefs) => Ok(prefs),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(conn: &Connection, prefs: &FavoritesPreferences) -> Result<()> {
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_preferences_default_tab_order_is_stable() {
|
||||
let prefs = FavoritesPreferences::default();
|
||||
|
||||
assert_eq!(
|
||||
prefs.tab_order,
|
||||
vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(prefs.custom_icon_preset, Some("heart".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_preferences_store_returns_default_when_empty() {
|
||||
let dir = unique_test_dir("favorites-default");
|
||||
let store = FavoritesPreferencesStore::new_at(&dir).unwrap();
|
||||
|
||||
let prefs = store.get_preferences().unwrap();
|
||||
|
||||
assert_eq!(prefs.tab_order, FavoritesPreferences::default().tab_order);
|
||||
assert_eq!(
|
||||
prefs.custom_icon_preset,
|
||||
FavoritesPreferences::default().custom_icon_preset
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_preferences_persist_without_custom_icon() {
|
||||
let dir = unique_test_dir("favorites-persist");
|
||||
let store = FavoritesPreferencesStore::new_at(&dir).unwrap();
|
||||
let prefs = FavoritesPreferences {
|
||||
custom_icon_path: None,
|
||||
custom_icon_preset: Some("star".to_string()),
|
||||
icon_background: Some("#112233".to_string()),
|
||||
tab_order: vec!["artists".to_string(), "tracks".to_string()],
|
||||
};
|
||||
|
||||
let saved = store.save_preferences(prefs.clone()).unwrap();
|
||||
let loaded = store.get_preferences().unwrap();
|
||||
|
||||
assert_eq!(saved.custom_icon_preset, prefs.custom_icon_preset);
|
||||
assert_eq!(loaded.custom_icon_preset, prefs.custom_icon_preset);
|
||||
assert_eq!(loaded.icon_background, prefs.icon_background);
|
||||
assert_eq!(loaded.tab_order, prefs.tab_order);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Graphics settings persistence.
|
||||
//!
|
||||
//! This module stores portable host rendering preferences only. Startup
|
||||
//! detection, environment variable application, crash recovery, and command
|
||||
//! transport stay outside `qbz-app`.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphicsSettings {
|
||||
/// GPU rendering toggle. Read at startup as default; env var QBZ_HARDWARE_ACCEL overrides.
|
||||
pub hardware_acceleration: bool,
|
||||
/// Force X11/XWayland backend on Wayland sessions (requires restart).
|
||||
pub force_x11: bool,
|
||||
/// GDK_SCALE override for XWayland (None = auto). Integer values: "1", "2".
|
||||
pub gdk_scale: Option<String>,
|
||||
/// GDK_DPI_SCALE override for XWayland (None = auto). Float values: "0.5", "1", "1.5".
|
||||
pub gdk_dpi_scale: Option<String>,
|
||||
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo".
|
||||
pub gsk_renderer: Option<String>,
|
||||
/// Rendering GPU selection.
|
||||
///
|
||||
/// Valid values are "auto", "integrated", "discrete", "software", or a
|
||||
/// host-specific GPU id such as a PCI slot.
|
||||
pub preferred_gpu: String,
|
||||
/// Opt-in NVIDIA Wayland compatibility mode. The host applies the runtime
|
||||
/// environment changes before graphics initialization.
|
||||
pub nvidia_compat_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for GraphicsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gdk_scale: None,
|
||||
gdk_dpi_scale: None,
|
||||
gsk_renderer: None,
|
||||
preferred_gpu: "auto".to_string(),
|
||||
nvidia_compat_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GraphicsSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl GraphicsSettingsStore {
|
||||
/// Lightweight read-only open for startup before host-managed state exists.
|
||||
/// Opens existing DB without creating tables or running migrations.
|
||||
pub fn new_readonly() -> Result<Self, String> {
|
||||
let db_path = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("graphics_settings.db");
|
||||
Self::new_readonly_at_path(&db_path)
|
||||
}
|
||||
|
||||
pub fn new_readonly_at_path(db_path: &Path) -> Result<Self, String> {
|
||||
if !db_path.exists() {
|
||||
return Err("Graphics settings DB does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to open graphics settings database (readonly): {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS graphics_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
hardware_acceleration INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
INSERT OR IGNORE INTO graphics_settings (id, hardware_acceleration) VALUES (1, 1);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create graphics settings table: {}", e))?;
|
||||
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE graphics_settings ADD COLUMN force_x11 INTEGER NOT NULL DEFAULT 0;",
|
||||
);
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE graphics_settings ADD COLUMN preferred_gpu TEXT NOT NULL DEFAULT 'auto';",
|
||||
);
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE graphics_settings ADD COLUMN nvidia_compat_mode INTEGER NOT NULL DEFAULT 0;",
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "graphics_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "graphics_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer, preferred_gpu, nvidia_compat_mode FROM graphics_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(GraphicsSettings {
|
||||
hardware_acceleration: row.get::<_, i64>(0)? != 0,
|
||||
force_x11: row.get::<_, i64>(1)? != 0,
|
||||
gdk_scale: row.get::<_, Option<String>>(2)?,
|
||||
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
|
||||
gsk_renderer: row.get::<_, Option<String>>(4)?,
|
||||
preferred_gpu: row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.unwrap_or_else(|| "auto".to_string()),
|
||||
nvidia_compat_mode: row.get::<_, i64>(6).unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get graphics settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_hardware_acceleration(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET hardware_acceleration = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set hardware_acceleration: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_force_x11(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET force_x11 = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set force_x11: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gdk_scale(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gdk_scale = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gdk_scale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gdk_dpi_scale(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gdk_dpi_scale = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gdk_dpi_scale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gsk_renderer(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gsk_renderer = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_preferred_gpu(&self, value: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET preferred_gpu = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set preferred_gpu: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_nvidia_compat_mode(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET nvidia_compat_mode = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set nvidia_compat_mode: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_settings_default_values_are_stable() {
|
||||
let settings = GraphicsSettings::default();
|
||||
|
||||
assert!(settings.hardware_acceleration);
|
||||
assert!(!settings.force_x11);
|
||||
assert_eq!(settings.gdk_scale, None);
|
||||
assert_eq!(settings.gdk_dpi_scale, None);
|
||||
assert_eq!(settings.gsk_renderer, None);
|
||||
assert_eq!(settings.preferred_gpu, "auto");
|
||||
assert!(!settings.nvidia_compat_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_settings_store_returns_defaults() {
|
||||
let dir = unique_test_dir("graphics-default");
|
||||
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.hardware_acceleration);
|
||||
assert!(!settings.force_x11);
|
||||
assert_eq!(settings.preferred_gpu, "auto");
|
||||
assert!(!settings.nvidia_compat_mode);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_settings_persist_all_fields() {
|
||||
let dir = unique_test_dir("graphics-persist");
|
||||
{
|
||||
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_hardware_acceleration(false)
|
||||
.expect("set hardware acceleration");
|
||||
store.set_force_x11(true).expect("set force x11");
|
||||
store
|
||||
.set_gdk_scale(Some("2".to_string()))
|
||||
.expect("set gdk scale");
|
||||
store
|
||||
.set_gdk_dpi_scale(Some("0.5".to_string()))
|
||||
.expect("set gdk dpi scale");
|
||||
store
|
||||
.set_gsk_renderer(Some("ngl".to_string()))
|
||||
.expect("set gsk renderer");
|
||||
store
|
||||
.set_preferred_gpu("discrete")
|
||||
.expect("set preferred gpu");
|
||||
store
|
||||
.set_nvidia_compat_mode(true)
|
||||
.expect("set nvidia compat mode");
|
||||
}
|
||||
|
||||
let reopened = GraphicsSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.hardware_acceleration);
|
||||
assert!(settings.force_x11);
|
||||
assert_eq!(settings.gdk_scale.as_deref(), Some("2"));
|
||||
assert_eq!(settings.gdk_dpi_scale.as_deref(), Some("0.5"));
|
||||
assert_eq!(settings.gsk_renderer.as_deref(), Some("ngl"));
|
||||
assert_eq!(settings.preferred_gpu, "discrete");
|
||||
assert!(settings.nvidia_compat_mode);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_settings_reopen_does_not_overwrite_existing_row() {
|
||||
let dir = unique_test_dir("graphics-no-overwrite");
|
||||
{
|
||||
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_hardware_acceleration(false)
|
||||
.expect("set hardware acceleration");
|
||||
store
|
||||
.set_preferred_gpu("software")
|
||||
.expect("set preferred gpu");
|
||||
}
|
||||
|
||||
let reopened = GraphicsSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.hardware_acceleration);
|
||||
assert_eq!(settings.preferred_gpu, "software");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_settings_readonly_opens_existing_db() {
|
||||
let dir = unique_test_dir("graphics-readonly");
|
||||
let db_path = dir.join("graphics_settings.db");
|
||||
{
|
||||
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_preferred_gpu("integrated")
|
||||
.expect("set preferred gpu");
|
||||
}
|
||||
|
||||
let readonly =
|
||||
GraphicsSettingsStore::new_readonly_at_path(&db_path).expect("open readonly store");
|
||||
let settings = readonly.get_settings().expect("get readonly settings");
|
||||
|
||||
assert_eq!(settings.preferred_gpu, "integrated");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Image cache settings persistence.
|
||||
//!
|
||||
//! This module stores portable image cache preferences only. Cache runtime,
|
||||
//! file management, stats, and image resolution remain host-owned behavior.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageCacheSettings {
|
||||
pub enabled: bool,
|
||||
pub max_size_mb: u32,
|
||||
}
|
||||
|
||||
impl Default for ImageCacheSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_size_mb: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS image_cache_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 200
|
||||
);
|
||||
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
|
||||
VALUES (1, 1, 200);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "image_cache_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "image_cache_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ImageCacheSettings {
|
||||
enabled: row.get::<_, i32>(0)? != 0,
|
||||
max_size_mb: row.get::<_, u32>(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read image cache settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
|
||||
params![max_size_mb],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsState {
|
||||
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, ImageCacheSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_cache_settings_default_values_are_stable() {
|
||||
let settings = ImageCacheSettings::default();
|
||||
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.max_size_mb, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_cache_settings_store_returns_defaults() {
|
||||
let (dir, store) = fresh_store("image-cache-default");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.max_size_mb, 200);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_cache_settings_persist_enabled() {
|
||||
let dir = unique_test_dir("image-cache-enabled");
|
||||
{
|
||||
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_enabled(false).expect("set enabled");
|
||||
}
|
||||
|
||||
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.enabled);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_cache_settings_persist_max_size_mb() {
|
||||
let dir = unique_test_dir("image-cache-size");
|
||||
{
|
||||
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_max_size_mb(512).expect("set max size");
|
||||
}
|
||||
|
||||
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.max_size_mb, 512);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_cache_settings_reopen_does_not_overwrite_existing_row() {
|
||||
let dir = unique_test_dir("image-cache-no-overwrite");
|
||||
{
|
||||
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_enabled(false).expect("set enabled");
|
||||
store.set_max_size_mb(512).expect("set max size");
|
||||
}
|
||||
|
||||
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.enabled);
|
||||
assert_eq!(settings.max_size_mb, 512);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Legal settings storage.
|
||||
//!
|
||||
//! Persists user acceptance of legal terms.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegalSettings {
|
||||
/// User has accepted Qobuz Terms of Service.
|
||||
pub qobuz_tos_accepted: bool,
|
||||
}
|
||||
|
||||
impl Default for LegalSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
qobuz_tos_accepted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LegalSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LegalSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open legal settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for legal settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS legal_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
qobuz_tos_accepted INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create legal settings table: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO legal_settings (id, qobuz_tos_accepted) VALUES (1, 0)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default legal settings: {}", e))?;
|
||||
|
||||
info!("[LegalSettings] Database initialized");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "legal_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "legal_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<LegalSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT qobuz_tos_accepted FROM legal_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let qobuz_tos_accepted: i32 = row.get(0)?;
|
||||
Ok(LegalSettings {
|
||||
qobuz_tos_accepted: qobuz_tos_accepted != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get legal settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_qobuz_tos_accepted(&self, accepted: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE legal_settings SET qobuz_tos_accepted = ? WHERE id = 1",
|
||||
params![accepted as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update ToS acceptance: {}", e))?;
|
||||
|
||||
info!("[LegalSettings] Qobuz ToS accepted: {}", accepted);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type LegalSettingsState = Arc<Mutex<Option<LegalSettingsStore>>>;
|
||||
|
||||
pub fn create_legal_settings_state() -> Result<LegalSettingsState, String> {
|
||||
let store = LegalSettingsStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_legal_settings_state() -> LegalSettingsState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_settings_default_to_unaccepted() {
|
||||
let dir = unique_test_dir("legal-default");
|
||||
let store = LegalSettingsStore::new_at(&dir).unwrap();
|
||||
|
||||
let settings = store.get_settings().unwrap();
|
||||
|
||||
assert!(!settings.qobuz_tos_accepted);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_settings_persist_tos_acceptance() {
|
||||
let dir = unique_test_dir("legal-persist");
|
||||
{
|
||||
let store = LegalSettingsStore::new_at(&dir).unwrap();
|
||||
store.set_qobuz_tos_accepted(true).unwrap();
|
||||
}
|
||||
|
||||
let reopened = LegalSettingsStore::new_at(&dir).unwrap();
|
||||
let settings = reopened.get_settings().unwrap();
|
||||
|
||||
assert!(settings.qobuz_tos_accepted);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FoldersViewMode {
|
||||
Flat,
|
||||
Tree,
|
||||
}
|
||||
|
||||
impl Default for FoldersViewMode {
|
||||
fn default() -> Self {
|
||||
FoldersViewMode::Flat
|
||||
}
|
||||
}
|
||||
|
||||
impl FoldersViewMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FoldersViewMode::Flat => "flat",
|
||||
FoldersViewMode::Tree => "tree",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"tree" => FoldersViewMode::Tree,
|
||||
_ => FoldersViewMode::Flat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibraryPreferences {
|
||||
pub tab_order: Vec<String>,
|
||||
pub hidden_tabs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub folders_view_mode: FoldersViewMode,
|
||||
/// Width (in CSS pixels) of the tree-mode left sidebar in the Folders
|
||||
/// tab. `None` means "use the frontend default" (currently 302px).
|
||||
/// Persisted on drag-end so the choice survives restarts. Bounds are
|
||||
/// enforced on the frontend (min 200px, max 40% of content area).
|
||||
#[serde(default)]
|
||||
pub folders_tree_sidebar_width: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for LibraryPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Default order matches the current LocalLibraryView tab list,
|
||||
// with the renamed 'folders' tab in place of the old 'albums'
|
||||
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
|
||||
// to this default.
|
||||
tab_order: vec![
|
||||
"tracks".to_string(),
|
||||
"folders".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
],
|
||||
hidden_tabs: vec![],
|
||||
folders_view_mode: FoldersViewMode::Flat,
|
||||
folders_tree_sidebar_width: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS library_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
tab_order TEXT NOT NULL,
|
||||
hidden_tabs TEXT NOT NULL DEFAULT '[]'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
|
||||
|
||||
// Idempotent migrations for columns added after the initial schema.
|
||||
// These ALTERs fail when the column already exists; that failure is
|
||||
// expected and ignored.
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE library_preferences ADD COLUMN folders_view_mode TEXT DEFAULT 'flat'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE library_preferences ADD COLUMN folders_tree_sidebar_width INTEGER DEFAULT NULL",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width \
|
||||
FROM library_preferences WHERE id = 1",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare select: {}", e))?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let tab_order_str: String = row.get(0)?;
|
||||
let hidden_tabs_str: String = row.get(1)?;
|
||||
let folders_view_mode_str: Option<String> = row.get(2)?;
|
||||
let folders_tree_sidebar_width: Option<i64> = row.get(3)?;
|
||||
|
||||
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
|
||||
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
|
||||
let hidden_tabs: Vec<String> =
|
||||
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
|
||||
let folders_view_mode = folders_view_mode_str
|
||||
.map(|s| FoldersViewMode::from_str(&s))
|
||||
.unwrap_or_default();
|
||||
// Stored as INTEGER in SQLite; clamp to non-negative on read.
|
||||
// Frontend re-clamps against the live max anyway.
|
||||
let folders_tree_sidebar_width = folders_tree_sidebar_width
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| v as u32);
|
||||
|
||||
Ok(LibraryPreferences {
|
||||
tab_order,
|
||||
hidden_tabs,
|
||||
folders_view_mode,
|
||||
folders_tree_sidebar_width,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(prefs) => Ok(prefs),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
|
||||
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(
|
||||
&self,
|
||||
prefs: LibraryPreferences,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order)
|
||||
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
|
||||
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
|
||||
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO library_preferences \
|
||||
(id, tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width) \
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
tab_order_str,
|
||||
hidden_tabs_str,
|
||||
prefs.folders_view_mode.as_str(),
|
||||
prefs.folders_tree_sidebar_width.map(|v| v as i64),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
pub fn set_folders_tree_sidebar_width(&self, width: u32) -> Result<(), String> {
|
||||
// Same upsert pattern as `set_folders_view_mode`: try the cheap
|
||||
// UPDATE first; if no row exists yet, fall back to inserting a
|
||||
// default row carrying just the new width.
|
||||
let updated = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE library_preferences SET folders_tree_sidebar_width = ?1 WHERE id = 1",
|
||||
params![width as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set folders_tree_sidebar_width: {}", e))?;
|
||||
|
||||
if updated == 0 {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_tree_sidebar_width = Some(width);
|
||||
self.save_preferences(prefs)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folders_view_mode(&self, mode: FoldersViewMode) -> Result<(), String> {
|
||||
// Ensure the row exists before updating; if no preferences have been
|
||||
// saved yet, fall back to inserting a default row carrying the new
|
||||
// folders_view_mode value.
|
||||
let updated = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE library_preferences SET folders_view_mode = ?1 WHERE id = 1",
|
||||
params![mode.as_str()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set folders_view_mode: {}", e))?;
|
||||
|
||||
if updated == 0 {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = mode;
|
||||
self.save_preferences(prefs)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesState {
|
||||
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = LibraryPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = LibraryPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, LibraryPreferencesStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = LibraryPreferencesStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_flat() {
|
||||
let prefs = LibraryPreferences::default();
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_folders_view_mode_defaults_to_flat() {
|
||||
let legacy = r#"{
|
||||
"tab_order": ["tracks", "folders", "albums", "artists"],
|
||||
"hidden_tabs": []
|
||||
}"#;
|
||||
let prefs: LibraryPreferences = serde_json::from_str(legacy).expect("parse legacy json");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_tree_set() {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = FoldersViewMode::Tree;
|
||||
let json = serde_json::to_string(&prefs).expect("serialize");
|
||||
let parsed: LibraryPreferences = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(parsed.folders_view_mode, FoldersViewMode::Tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_view_mode_str_round_trip() {
|
||||
assert_eq!(FoldersViewMode::Flat.as_str(), "flat");
|
||||
assert_eq!(FoldersViewMode::Tree.as_str(), "tree");
|
||||
assert_eq!(FoldersViewMode::from_str("flat"), FoldersViewMode::Flat);
|
||||
assert_eq!(FoldersViewMode::from_str("tree"), FoldersViewMode::Tree);
|
||||
assert_eq!(FoldersViewMode::from_str("garbage"), FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_returns_default_folders_view_mode() {
|
||||
let (dir, store) = fresh_store("library-default-mode");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_view_mode_persists_on_empty_table() {
|
||||
let (dir, store) = fresh_store("library-mode-empty");
|
||||
store
|
||||
.set_folders_view_mode(FoldersViewMode::Tree)
|
||||
.expect("set mode");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Tree);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_view_mode_updates_existing_row() {
|
||||
let (dir, store) = fresh_store("library-mode-update");
|
||||
|
||||
// Seed a row with the default save path so the UPDATE branch runs.
|
||||
let mut seeded = LibraryPreferences::default();
|
||||
seeded.tab_order = vec!["tracks".into(), "albums".into()];
|
||||
store.save_preferences(seeded).expect("save seed");
|
||||
|
||||
store
|
||||
.set_folders_view_mode(FoldersViewMode::Tree)
|
||||
.expect("set mode");
|
||||
let after = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(after.folders_view_mode, FoldersViewMode::Tree);
|
||||
// Sibling fields preserved.
|
||||
assert_eq!(
|
||||
after.tab_order,
|
||||
vec!["tracks".to_string(), "albums".to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_preferences_persists_folders_view_mode() {
|
||||
let (dir, store) = fresh_store("library-save-mode");
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = FoldersViewMode::Tree;
|
||||
store.save_preferences(prefs).expect("save");
|
||||
|
||||
let loaded = store.get_preferences().expect("load");
|
||||
assert_eq!(loaded.folders_view_mode, FoldersViewMode::Tree);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_returns_none_folders_tree_sidebar_width() {
|
||||
let (dir, store) = fresh_store("library-default-width");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_tree_sidebar_width_persists_on_empty_table() {
|
||||
let (dir, store) = fresh_store("library-width-empty");
|
||||
store
|
||||
.set_folders_tree_sidebar_width(512)
|
||||
.expect("set width");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, Some(512));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_tree_sidebar_width_updates_existing_row() {
|
||||
let (dir, store) = fresh_store("library-width-update");
|
||||
|
||||
let mut seeded = LibraryPreferences::default();
|
||||
seeded.tab_order = vec!["tracks".into(), "folders".into()];
|
||||
store.save_preferences(seeded).expect("save seed");
|
||||
|
||||
store
|
||||
.set_folders_tree_sidebar_width(640)
|
||||
.expect("set width");
|
||||
let after = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(after.folders_tree_sidebar_width, Some(640));
|
||||
assert_eq!(
|
||||
after.tab_order,
|
||||
vec!["tracks".to_string(), "folders".to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_sidebar_width_defaults_to_none() {
|
||||
let legacy = r#"{
|
||||
"tab_order": ["tracks", "folders", "albums", "artists"],
|
||||
"hidden_tabs": [],
|
||||
"folders_view_mode": "tree"
|
||||
}"#;
|
||||
let prefs: LibraryPreferences = serde_json::from_str(legacy).expect("parse legacy json");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod developer;
|
||||
pub mod download;
|
||||
pub mod favorites;
|
||||
pub mod graphics;
|
||||
pub mod image_cache;
|
||||
pub mod legal;
|
||||
pub mod library;
|
||||
pub mod playback;
|
||||
pub mod remote_control;
|
||||
pub mod subscription;
|
||||
pub mod tray;
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Playback and small playback-adjacent UI preferences.
|
||||
//!
|
||||
//! `show_context_icon` is persisted here to preserve the existing settings
|
||||
//! contract, but it is a portable UI preference, not playback domain logic.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AutoplayMode {
|
||||
/// Continue playing within the source (album, playlist, etc.)
|
||||
#[serde(rename = "continue")]
|
||||
ContinueWithinSource,
|
||||
/// Play only the selected track, then stop.
|
||||
#[serde(rename = "track_only")]
|
||||
PlayTrackOnly,
|
||||
/// Create infinite radio when queue ends (based on recent tracks).
|
||||
#[serde(rename = "infinite")]
|
||||
InfiniteRadio,
|
||||
}
|
||||
|
||||
impl Default for AutoplayMode {
|
||||
fn default() -> Self {
|
||||
Self::ContinueWithinSource
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoplayMode {
|
||||
fn to_db_value(self) -> &'static str {
|
||||
match self {
|
||||
AutoplayMode::ContinueWithinSource => "continue",
|
||||
AutoplayMode::PlayTrackOnly => "track_only",
|
||||
AutoplayMode::InfiniteRadio => "infinite",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_db_value(value: &str) -> Self {
|
||||
match value {
|
||||
"track_only" => AutoplayMode::PlayTrackOnly,
|
||||
"infinite" => AutoplayMode::InfiniteRadio,
|
||||
_ => AutoplayMode::ContinueWithinSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybackPreferences {
|
||||
pub autoplay_mode: AutoplayMode,
|
||||
/// Portable UI preference for showing the context-stack icon.
|
||||
/// This is not domain playback behavior.
|
||||
pub show_context_icon: bool,
|
||||
pub persist_session: bool,
|
||||
/// Sub-preference of `persist_session`. When true, restoring a
|
||||
/// session also seeks to `current_position_secs` of the saved
|
||||
/// track. When false (default), the saved track is shown paused at
|
||||
/// 0:00 and the user starts the next listen fresh.
|
||||
pub resume_playback_position: bool,
|
||||
}
|
||||
|
||||
impl Default for PlaybackPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
autoplay_mode: AutoplayMode::ContinueWithinSource,
|
||||
show_context_icon: false,
|
||||
persist_session: false,
|
||||
resume_playback_position: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlaybackPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl PlaybackPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for playback preferences database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS playback_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
|
||||
|
||||
let show_context_icon_exists =
|
||||
column_exists(&conn, "playback_preferences", "show_context_icon");
|
||||
info!(
|
||||
"[PlaybackPrefs] Column show_context_icon exists: {}",
|
||||
show_context_icon_exists
|
||||
);
|
||||
if !show_context_icon_exists {
|
||||
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
|
||||
info!("[PlaybackPrefs] Migration successful");
|
||||
}
|
||||
|
||||
if !column_exists(&conn, "playback_preferences", "persist_session") {
|
||||
info!("[PlaybackPrefs] Migrating: adding persist_session column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add persist_session column: {}", e))?;
|
||||
info!("[PlaybackPrefs] persist_session migration successful");
|
||||
}
|
||||
|
||||
if !column_exists(&conn, "playback_preferences", "resume_playback_position") {
|
||||
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
|
||||
info!("[PlaybackPrefs] resume_playback_position migration successful");
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
|
||||
VALUES (1, 'continue', 0, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "playback_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "playback_preferences.db")
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let autoplay_str: String = row.get(0)?;
|
||||
let show_icon: i32 = row.get(1)?;
|
||||
let persist: i32 = row.get(2)?;
|
||||
let resume_pos: i32 = row.get(3)?;
|
||||
Ok(PlaybackPreferences {
|
||||
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
|
||||
show_context_icon: show_icon != 0,
|
||||
persist_session: persist != 0,
|
||||
resume_playback_position: resume_pos != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get playback preferences: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
|
||||
params![mode.to_db_value()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
|
||||
params![if show { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
|
||||
params![if persist { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set persist session: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
|
||||
params![if resume { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all playback preferences to their default values.
|
||||
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
|
||||
let defaults = PlaybackPreferences::default();
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
|
||||
params![
|
||||
defaults.autoplay_mode.to_db_value(),
|
||||
if defaults.show_context_icon { 1 } else { 0 },
|
||||
if defaults.persist_session { 1 } else { 0 },
|
||||
if defaults.resume_playback_position { 1 } else { 0 },
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
|
||||
Ok(defaults)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlaybackPreferencesState {
|
||||
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl PlaybackPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = PlaybackPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_preferences()
|
||||
}
|
||||
|
||||
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_autoplay_mode(mode)
|
||||
}
|
||||
|
||||
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_show_context_icon(show)
|
||||
}
|
||||
|
||||
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_persist_session(persist)
|
||||
}
|
||||
|
||||
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_resume_playback_position(resume)
|
||||
}
|
||||
}
|
||||
|
||||
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||
conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?1"),
|
||||
[column],
|
||||
|row| {
|
||||
let count: i32 = row.get(0)?;
|
||||
Ok(count > 0)
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, PlaybackPreferencesStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_preferences_default_values_are_stable() {
|
||||
let prefs = PlaybackPreferences::default();
|
||||
|
||||
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||
assert!(!prefs.show_context_icon);
|
||||
assert!(!prefs.persist_session);
|
||||
assert!(!prefs.resume_playback_position);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_preferences_store_returns_defaults() {
|
||||
let (dir, store) = fresh_store("playback-default");
|
||||
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
|
||||
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||
assert!(!prefs.show_context_icon);
|
||||
assert!(!prefs.persist_session);
|
||||
assert!(!prefs.resume_playback_position);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_preferences_persist_all_fields() {
|
||||
let dir = unique_test_dir("playback-persist");
|
||||
{
|
||||
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
|
||||
.expect("set autoplay");
|
||||
store.set_show_context_icon(true).expect("set context icon");
|
||||
store
|
||||
.set_persist_session(true)
|
||||
.expect("set persist session");
|
||||
store
|
||||
.set_resume_playback_position(true)
|
||||
.expect("set resume position");
|
||||
}
|
||||
|
||||
let reopened = PlaybackPreferencesStore::new_at(&dir).expect("reopen store");
|
||||
let prefs = reopened.get_preferences().expect("get prefs");
|
||||
|
||||
assert_eq!(prefs.autoplay_mode, AutoplayMode::InfiniteRadio);
|
||||
assert!(prefs.show_context_icon);
|
||||
assert!(prefs.persist_session);
|
||||
assert!(prefs.resume_playback_position);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_preferences_migrates_legacy_schema() {
|
||||
let dir = unique_test_dir("playback-migrate");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let db_path = dir.join("playback_preferences.db");
|
||||
{
|
||||
let conn = Connection::open(&db_path).expect("open legacy db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE playback_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
|
||||
);
|
||||
INSERT INTO playback_preferences (id, autoplay_mode) VALUES (1, 'track_only');",
|
||||
)
|
||||
.expect("create legacy schema");
|
||||
}
|
||||
|
||||
let store = PlaybackPreferencesStore::new_at(&dir).expect("migrate store");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
|
||||
assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly);
|
||||
assert!(!prefs.show_context_icon);
|
||||
assert!(!prefs.persist_session);
|
||||
assert!(!prefs.resume_playback_position);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_preferences_reset_all_preserves_existing_behavior() {
|
||||
let (dir, store) = fresh_store("playback-reset");
|
||||
store
|
||||
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
|
||||
.expect("set autoplay");
|
||||
store.set_show_context_icon(true).expect("set context icon");
|
||||
store.set_persist_session(true).expect("set persist");
|
||||
store
|
||||
.set_resume_playback_position(true)
|
||||
.expect("set resume position");
|
||||
|
||||
let defaults = store.reset_all().expect("reset prefs");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
|
||||
assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
|
||||
assert!(!prefs.show_context_icon);
|
||||
assert!(!prefs.persist_session);
|
||||
assert!(!prefs.resume_playback_position);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
//! Remote control settings and allowed-origin persistence.
|
||||
//!
|
||||
//! This module owns portable storage only. Server startup, TLS certificate
|
||||
//! management, QR generation, router/CORS application, and live restarts remain
|
||||
//! host-owned behavior.
|
||||
|
||||
use base64::Engine;
|
||||
use rand::RngExt;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const DEFAULT_ALLOWED_ORIGINS: &[&str] = &[
|
||||
"vicrodh.github.io",
|
||||
"control.qbz.lol",
|
||||
"www.control.qbz.lol",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RemoteControlSettings {
|
||||
pub enabled: bool,
|
||||
pub port: u16,
|
||||
pub secure: bool,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Default for RemoteControlSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: 8182,
|
||||
secure: true,
|
||||
token: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RemoteControlSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl RemoteControlSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for remote control settings database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS remote_control_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
port INTEGER NOT NULL DEFAULT 8182,
|
||||
secure INTEGER NOT NULL DEFAULT 1,
|
||||
token TEXT NOT NULL DEFAULT ''
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create remote control settings table: {}", e))?;
|
||||
|
||||
ensure_secure_column(&conn)?;
|
||||
|
||||
let token = generate_token();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO remote_control_settings (id, enabled, port, secure, token)
|
||||
VALUES (1, 0, 8182, 0, ?1)",
|
||||
params![token],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default remote control settings: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
|
||||
let mut settings = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT enabled, port, secure, token FROM remote_control_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let enabled: i32 = row.get(0)?;
|
||||
let port: i64 = row.get(1)?;
|
||||
let secure: i32 = row.get(2)?;
|
||||
let token: String = row.get(3)?;
|
||||
Ok(RemoteControlSettings {
|
||||
enabled: enabled != 0,
|
||||
port: port as u16,
|
||||
secure: secure != 0,
|
||||
token,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get remote control settings: {}", e))?;
|
||||
|
||||
if settings.token.is_empty() {
|
||||
settings.token = generate_token();
|
||||
self.set_token(&settings.token)?;
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![if enabled { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_port(&self, port: u16) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET port = ?1 WHERE id = 1",
|
||||
params![port as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control port: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET secure = ?1 WHERE id = 1",
|
||||
params![if secure { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control secure: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_token(&self, token: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET token = ?1 WHERE id = 1",
|
||||
params![token],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control token: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn regenerate_token(&self) -> Result<String, String> {
|
||||
let token = generate_token();
|
||||
self.set_token(&token)?;
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RemoteControlSettingsState {
|
||||
pub store: Arc<Mutex<Option<RemoteControlSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl RemoteControlSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = RemoteControlSettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_enabled(enabled)
|
||||
}
|
||||
|
||||
pub fn set_port(&self, port: u16) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_port(port)
|
||||
}
|
||||
|
||||
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_secure(secure)
|
||||
}
|
||||
|
||||
pub fn regenerate_token(&self) -> Result<String, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.regenerate_token()
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn ensure_secure_column(conn: &Connection) -> Result<(), String> {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(remote_control_settings)")
|
||||
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| format!("Schema read error: {}", e))?
|
||||
{
|
||||
let name: String = row
|
||||
.get(1)
|
||||
.map_err(|e| format!("Schema read error: {}", e))?;
|
||||
if name == "secure" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"ALTER TABLE remote_control_settings ADD COLUMN secure INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to migrate remote control settings: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AllowedOrigin {
|
||||
pub id: i64,
|
||||
pub origin: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
pub struct AllowedOriginsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AllowedOriginsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS allowed_origins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
origin TEXT NOT NULL UNIQUE,
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create allowed_origins table: {}", e))?;
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM allowed_origins", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if count == 0 {
|
||||
for origin in DEFAULT_ALLOWED_ORIGINS {
|
||||
let _ = conn.execute(
|
||||
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
|
||||
params![origin],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
|
||||
let mut stmt = self.conn
|
||||
.prepare("SELECT id, origin, is_default FROM allowed_origins ORDER BY is_default DESC, origin ASC")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let origins = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AllowedOrigin {
|
||||
id: row.get(0)?,
|
||||
origin: row.get(1)?,
|
||||
is_default: row.get::<_, i32>(2)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query origins: {}", e))?;
|
||||
|
||||
origins
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to collect origins: {}", e))
|
||||
}
|
||||
|
||||
pub fn is_origin_allowed(&self, origin: &str) -> bool {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM allowed_origins WHERE origin = ?1",
|
||||
params![origin],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
|
||||
let normalized = origin.trim().to_lowercase();
|
||||
|
||||
if normalized.is_empty() {
|
||||
return Err("Origin cannot be empty".to_string());
|
||||
}
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO allowed_origins (origin, is_default) VALUES (?1, 0)",
|
||||
params![normalized],
|
||||
)
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("UNIQUE constraint") {
|
||||
"Origin already exists".to_string()
|
||||
} else {
|
||||
format!("Failed to add origin: {}", e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let id = self.conn.last_insert_rowid();
|
||||
Ok(AllowedOrigin {
|
||||
id,
|
||||
origin: normalized,
|
||||
is_default: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
|
||||
let affected = self
|
||||
.conn
|
||||
.execute("DELETE FROM allowed_origins WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to remove origin: {}", e))?;
|
||||
|
||||
if affected == 0 {
|
||||
return Err("Origin not found".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_defaults(&self) -> Result<(), String> {
|
||||
for origin in DEFAULT_ALLOWED_ORIGINS {
|
||||
let _ = self.conn.execute(
|
||||
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
|
||||
params![origin],
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AllowedOriginsState {
|
||||
pub store: Arc<Mutex<Option<AllowedOriginsStore>>>,
|
||||
}
|
||||
|
||||
impl AllowedOriginsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = AllowedOriginsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_origins()
|
||||
}
|
||||
|
||||
pub fn is_origin_allowed(&self, origin: &str) -> bool {
|
||||
self.store
|
||||
.lock()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|store| store.is_origin_allowed(origin))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.add_origin(origin)
|
||||
}
|
||||
|
||||
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.remove_origin(id)
|
||||
}
|
||||
|
||||
pub fn restore_defaults(&self) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.restore_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_remote_store(name: &str) -> (std::path::PathBuf, RemoteControlSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn fresh_origins_store(name: &str) -> (std::path::PathBuf, AllowedOriginsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = AllowedOriginsStore::new_at(&dir).expect("open origins store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_default_struct_values_are_stable() {
|
||||
let defaults = RemoteControlSettings::default();
|
||||
|
||||
assert!(!defaults.enabled);
|
||||
assert_eq!(defaults.port, 8182);
|
||||
assert!(defaults.secure);
|
||||
assert!(defaults.token.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_store_creates_token_and_defaults_internal_remote_to_http() {
|
||||
let (dir, store) = fresh_remote_store("remote-default");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.enabled);
|
||||
assert_eq!(settings.port, 8182);
|
||||
assert!(!settings.secure);
|
||||
assert!(!settings.token.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_settings_persist_across_reopen() {
|
||||
let dir = unique_test_dir("remote-persist");
|
||||
let token;
|
||||
{
|
||||
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
|
||||
token = store.get_settings().expect("get settings").token;
|
||||
store.set_enabled(true).expect("set enabled");
|
||||
store.set_port(9310).expect("set port");
|
||||
store.set_secure(true).expect("set secure");
|
||||
}
|
||||
|
||||
let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.port, 9310);
|
||||
assert!(settings.secure);
|
||||
assert_eq!(settings.token, token);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_regenerate_token_changes_and_persists_token() {
|
||||
let dir = unique_test_dir("remote-token");
|
||||
let original;
|
||||
let regenerated;
|
||||
{
|
||||
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
|
||||
original = store.get_settings().expect("get settings").token;
|
||||
regenerated = store.regenerate_token().expect("regenerate token");
|
||||
}
|
||||
|
||||
let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_ne!(original, regenerated);
|
||||
assert_eq!(settings.token, regenerated);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_empty_token_is_repaired_on_read() {
|
||||
let dir = unique_test_dir("remote-empty-token");
|
||||
let db_path = dir.join("remote_control_settings.db");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
{
|
||||
let conn = Connection::open(&db_path).expect("open db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE remote_control_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
port INTEGER NOT NULL DEFAULT 8182,
|
||||
secure INTEGER NOT NULL DEFAULT 1,
|
||||
token TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO remote_control_settings (id, enabled, port, secure, token)
|
||||
VALUES (1, 0, 8182, 0, '');",
|
||||
)
|
||||
.expect("create schema");
|
||||
}
|
||||
|
||||
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.token.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_legacy_schema_adds_secure_default_false() {
|
||||
let dir = unique_test_dir("remote-legacy");
|
||||
let db_path = dir.join("remote_control_settings.db");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
{
|
||||
let conn = Connection::open(&db_path).expect("open legacy db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE remote_control_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
port INTEGER NOT NULL DEFAULT 8182,
|
||||
token TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO remote_control_settings (id, enabled, port, token)
|
||||
VALUES (1, 1, 8183, 'legacy-token');",
|
||||
)
|
||||
.expect("create legacy schema");
|
||||
}
|
||||
|
||||
let store = RemoteControlSettingsStore::new_at(&dir).expect("migrate store");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.port, 8183);
|
||||
assert!(!settings.secure);
|
||||
assert_eq!(settings.token, "legacy-token");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_origins_store_creates_defaults() {
|
||||
let (dir, store) = fresh_origins_store("origins-default");
|
||||
|
||||
let origins = store.get_origins().expect("get origins");
|
||||
let names = origins
|
||||
.iter()
|
||||
.map(|origin| origin.origin.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"control.qbz.lol",
|
||||
"vicrodh.github.io",
|
||||
"www.control.qbz.lol"
|
||||
]
|
||||
);
|
||||
assert!(origins.iter().all(|origin| origin.is_default));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_origins_add_normalizes_and_remove_works() {
|
||||
let (dir, store) = fresh_origins_store("origins-add-remove");
|
||||
|
||||
let origin = store
|
||||
.add_origin(" EXAMPLE.com ")
|
||||
.expect("add custom origin");
|
||||
assert_eq!(origin.origin, "example.com");
|
||||
assert!(!origin.is_default);
|
||||
assert!(store.is_origin_allowed("example.com"));
|
||||
|
||||
store.remove_origin(origin.id).expect("remove origin");
|
||||
assert!(!store.is_origin_allowed("example.com"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_origins_rejects_empty_and_duplicate_origins() {
|
||||
let (dir, store) = fresh_origins_store("origins-invalid");
|
||||
|
||||
assert!(store.add_origin(" ").is_err());
|
||||
store.add_origin("example.com").expect("add origin");
|
||||
let duplicate = store.add_origin("example.com").expect_err("duplicate");
|
||||
|
||||
assert_eq!(duplicate, "Origin already exists");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_origins_restore_defaults_adds_missing_defaults() {
|
||||
let (dir, store) = fresh_origins_store("origins-restore");
|
||||
let origin = store.add_origin("example.com").expect("add origin");
|
||||
let default_id = store
|
||||
.get_origins()
|
||||
.expect("get origins")
|
||||
.into_iter()
|
||||
.find(|entry| entry.origin == "control.qbz.lol")
|
||||
.expect("find default")
|
||||
.id;
|
||||
store.remove_origin(default_id).expect("remove default");
|
||||
|
||||
store.restore_defaults().expect("restore defaults");
|
||||
let origins = store.get_origins().expect("get origins");
|
||||
|
||||
assert!(origins.iter().any(|entry| entry.origin == "control.qbz.lol"));
|
||||
assert!(origins.iter().any(|entry| entry.id == origin.id));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Subscription validity tracking for offline download compliance.
|
||||
//!
|
||||
//! Tracks when a user was first observed without a valid subscription. If the
|
||||
//! invalid state persists for more than the grace period, offline downloads are
|
||||
//! purged by the host-side session lifecycle.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// How long we keep honoring offline access after the server first reports an
|
||||
/// invalid subscription.
|
||||
///
|
||||
/// Qobuz's own mobile app gives a 30-day offline grace window, so QBZ matches
|
||||
/// that posture for compliance. Shorter windows would punish users on flaky
|
||||
/// networks; longer windows would be more lenient than the official client.
|
||||
///
|
||||
/// The primary protection for offline files is the CMAF-at-rest cache format.
|
||||
/// This grace period is an additional compliance guard, not the main defense.
|
||||
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionState {
|
||||
pub invalid_since: Option<i64>,
|
||||
pub last_invalid_at: Option<i64>,
|
||||
pub last_valid_at: Option<i64>,
|
||||
pub last_checked_at: Option<i64>,
|
||||
pub downloads_purged_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for SubscriptionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
invalid_since: None,
|
||||
last_invalid_at: None,
|
||||
last_valid_at: None,
|
||||
last_checked_at: None,
|
||||
downloads_purged_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SubscriptionStateStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl SubscriptionStateStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for subscription state database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS subscription_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
invalid_since INTEGER,
|
||||
last_invalid_at INTEGER,
|
||||
last_valid_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
downloads_purged_at INTEGER
|
||||
);
|
||||
INSERT OR IGNORE INTO subscription_state (id) VALUES (1);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create subscription state table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "subscription_state.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "subscription_state.db")
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> Result<SubscriptionState, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at
|
||||
FROM subscription_state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(SubscriptionState {
|
||||
invalid_since: row.get(0)?,
|
||||
last_invalid_at: row.get(1)?,
|
||||
last_valid_at: row.get(2)?,
|
||||
last_checked_at: row.get(3)?,
|
||||
downloads_purged_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read subscription state: {}", e))
|
||||
}
|
||||
|
||||
pub fn mark_valid(&self, now: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state
|
||||
SET invalid_since = NULL,
|
||||
last_valid_at = ?1,
|
||||
last_checked_at = ?1
|
||||
WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_invalid(&self, now: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state
|
||||
SET invalid_since = COALESCE(invalid_since, ?1),
|
||||
last_invalid_at = ?1,
|
||||
last_checked_at = ?1
|
||||
WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_offline_cache_purged(&self, now: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update purge timestamp: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_purge_offline_cache(&self, now: i64) -> Result<bool, String> {
|
||||
let state = self.get_state()?;
|
||||
let Some(invalid_since) = state.invalid_since else {
|
||||
return Ok(false);
|
||||
};
|
||||
if now - invalid_since < GRACE_PERIOD_SECS {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(purged_at) = state.downloads_purged_at {
|
||||
if purged_at >= invalid_since {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SubscriptionStateState = Arc<Mutex<Option<SubscriptionStateStore>>>;
|
||||
|
||||
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
|
||||
let store = SubscriptionStateStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_subscription_state() -> SubscriptionStateState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_state_defaults_to_valid_access() {
|
||||
let dir = unique_test_dir("subscription-default");
|
||||
let store = SubscriptionStateStore::new_at(&dir).unwrap();
|
||||
|
||||
let state = store.get_state().unwrap();
|
||||
|
||||
assert!(state.invalid_since.is_none());
|
||||
assert!(!store.should_purge_offline_cache(0).unwrap());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_invalid_since_preserves_first_invalid_observation() {
|
||||
let dir = unique_test_dir("subscription-invalid");
|
||||
let store = SubscriptionStateStore::new_at(&dir).unwrap();
|
||||
|
||||
store.mark_invalid(100).unwrap();
|
||||
store.mark_invalid(200).unwrap();
|
||||
let state = store.get_state().unwrap();
|
||||
|
||||
assert_eq!(state.invalid_since, Some(100));
|
||||
assert_eq!(state.last_invalid_at, Some(200));
|
||||
assert_eq!(state.last_checked_at, Some(200));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_purge_waits_for_grace_period_and_only_runs_once() {
|
||||
let dir = unique_test_dir("subscription-purge");
|
||||
let store = SubscriptionStateStore::new_at(&dir).unwrap();
|
||||
|
||||
store.mark_invalid(100).unwrap();
|
||||
|
||||
assert!(!store
|
||||
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS - 1)
|
||||
.unwrap());
|
||||
assert!(store
|
||||
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS)
|
||||
.unwrap());
|
||||
|
||||
store
|
||||
.mark_offline_cache_purged(100 + GRACE_PERIOD_SECS)
|
||||
.unwrap();
|
||||
|
||||
assert!(!store
|
||||
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS + 1)
|
||||
.unwrap());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_mark_valid_clears_invalid_since() {
|
||||
let dir = unique_test_dir("subscription-valid");
|
||||
let store = SubscriptionStateStore::new_at(&dir).unwrap();
|
||||
|
||||
store.mark_invalid(100).unwrap();
|
||||
store.mark_valid(200).unwrap();
|
||||
let state = store.get_state().unwrap();
|
||||
|
||||
assert_eq!(state.invalid_since, None);
|
||||
assert_eq!(state.last_valid_at, Some(200));
|
||||
assert_eq!(state.last_checked_at, Some(200));
|
||||
assert!(!store.should_purge_offline_cache(1000).unwrap());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Portable tray preference storage.
|
||||
//!
|
||||
//! This module owns persisted tray preferences only. Runtime tray creation,
|
||||
//! icon updates, window hiding/showing, and emitted events remain in the
|
||||
//! host application layer.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TraySettings {
|
||||
/// Show system tray icon (requires restart to take effect).
|
||||
pub enable_tray: bool,
|
||||
/// Hide window to tray when clicking minimize.
|
||||
pub minimize_to_tray: bool,
|
||||
/// Hide window to tray instead of quitting when clicking close.
|
||||
pub close_to_tray: bool,
|
||||
/// Tray icon variant override:
|
||||
/// - "auto" follows system color scheme,
|
||||
/// - "mono-light" uses a light glyph for dark panels,
|
||||
/// - "mono-dark" uses a dark glyph for light panels,
|
||||
/// - "color" uses the full-color vinyl logo.
|
||||
#[serde(default = "default_tray_icon_theme")]
|
||||
pub tray_icon_theme: String,
|
||||
}
|
||||
|
||||
fn default_tray_icon_theme() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
/// Coerce free-form values to the supported set. Anything outside the
|
||||
/// supported list falls back to "auto".
|
||||
///
|
||||
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
|
||||
/// relative to the labels users saw. They are remapped here to the value
|
||||
/// that matches the user's original intent.
|
||||
pub fn normalize_tray_icon_theme(input: &str) -> String {
|
||||
match input {
|
||||
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
|
||||
"light" => "mono-light".to_string(),
|
||||
"dark" => "mono-dark".to_string(),
|
||||
_ => "auto".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TraySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_tray: true,
|
||||
minimize_to_tray: false,
|
||||
close_to_tray: false,
|
||||
tray_icon_theme: default_tray_icon_theme(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TraySettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl TraySettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tray_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enable_tray INTEGER NOT NULL DEFAULT 1,
|
||||
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
|
||||
close_to_tray INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
|
||||
|
||||
let has_theme_column: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
|
||||
if has_theme_column == 0 {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
|
||||
)
|
||||
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
|
||||
VALUES (1, 1, 0, 0, 'auto')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
|
||||
|
||||
info!("[TraySettings] Database initialized");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "tray_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "tray_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<TraySettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let enable_tray: i32 = row.get(0)?;
|
||||
let minimize_to_tray: i32 = row.get(1)?;
|
||||
let close_to_tray: i32 = row.get(2)?;
|
||||
let tray_icon_theme: String = row.get(3)?;
|
||||
Ok(TraySettings {
|
||||
enable_tray: enable_tray != 0,
|
||||
minimize_to_tray: minimize_to_tray != 0,
|
||||
close_to_tray: close_to_tray != 0,
|
||||
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get tray settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
|
||||
let normalized = normalize_tray_icon_theme(value);
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
|
||||
params![normalized],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TraySettingsState {
|
||||
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
|
||||
}
|
||||
|
||||
impl TraySettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = TraySettingsStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = TraySettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<TraySettings, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_enable_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_minimize_to_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_close_to_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_tray_icon_theme(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, TraySettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = TraySettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_settings_default_values_are_stable() {
|
||||
let settings = TraySettings::default();
|
||||
|
||||
assert!(settings.enable_tray);
|
||||
assert!(!settings.minimize_to_tray);
|
||||
assert!(!settings.close_to_tray);
|
||||
assert_eq!(settings.tray_icon_theme, "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_settings_store_returns_defaults() {
|
||||
let (dir, store) = fresh_store("tray-default");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.enable_tray);
|
||||
assert!(!settings.minimize_to_tray);
|
||||
assert!(!settings.close_to_tray);
|
||||
assert_eq!(settings.tray_icon_theme, "auto");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_settings_persist_all_fields() {
|
||||
let dir = unique_test_dir("tray-persist");
|
||||
{
|
||||
let store = TraySettingsStore::new_at(&dir).expect("open store");
|
||||
store.set_enable_tray(false).expect("set enable");
|
||||
store.set_minimize_to_tray(true).expect("set minimize");
|
||||
store.set_close_to_tray(true).expect("set close");
|
||||
store
|
||||
.set_tray_icon_theme("mono-light")
|
||||
.expect("set icon theme");
|
||||
}
|
||||
|
||||
let reopened = TraySettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.enable_tray);
|
||||
assert!(settings.minimize_to_tray);
|
||||
assert!(settings.close_to_tray);
|
||||
assert_eq!(settings.tray_icon_theme, "mono-light");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_icon_theme_normalizes_supported_legacy_and_unknown_values() {
|
||||
assert_eq!(normalize_tray_icon_theme("auto"), "auto");
|
||||
assert_eq!(normalize_tray_icon_theme("mono-light"), "mono-light");
|
||||
assert_eq!(normalize_tray_icon_theme("mono-dark"), "mono-dark");
|
||||
assert_eq!(normalize_tray_icon_theme("color"), "color");
|
||||
assert_eq!(normalize_tray_icon_theme("light"), "mono-light");
|
||||
assert_eq!(normalize_tray_icon_theme("dark"), "mono-dark");
|
||||
assert_eq!(normalize_tray_icon_theme("invalid"), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_settings_migrates_legacy_schema() {
|
||||
let dir = unique_test_dir("tray-migrate");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let db_path = dir.join("tray_settings.db");
|
||||
{
|
||||
let conn = Connection::open(&db_path).expect("open legacy db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE tray_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enable_tray INTEGER NOT NULL DEFAULT 1,
|
||||
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
|
||||
close_to_tray INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray)
|
||||
VALUES (1, 0, 1, 1);",
|
||||
)
|
||||
.expect("create legacy schema");
|
||||
}
|
||||
|
||||
let store = TraySettingsStore::new_at(&dir).expect("migrate store");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert!(!settings.enable_tray);
|
||||
assert!(settings.minimize_to_tray);
|
||||
assert!(settings.close_to_tray);
|
||||
assert_eq!(settings.tray_icon_theme, "auto");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Per-user data path management.
|
||||
//!
|
||||
//! Each Qobuz user gets their own subdirectory under the app's data/cache paths.
|
||||
//! This module provides the central path provider that host shells use to
|
||||
//! determine where to store per-user databases and cache files.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Central path provider for per-user data isolation.
|
||||
///
|
||||
/// Holds the current user_id and provides methods to get user-scoped data and
|
||||
/// cache directories.
|
||||
pub struct UserDataPaths {
|
||||
user_id: RwLock<Option<u64>>,
|
||||
}
|
||||
|
||||
impl UserDataPaths {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
user_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current user after login.
|
||||
pub fn set_user(&self, user_id: u64) {
|
||||
*self
|
||||
.user_id
|
||||
.write()
|
||||
.expect("UserDataPaths write lock poisoned") = Some(user_id);
|
||||
log::info!("UserDataPaths: active user set");
|
||||
}
|
||||
|
||||
/// Clear the current user on logout.
|
||||
pub fn clear_user(&self) {
|
||||
*self
|
||||
.user_id
|
||||
.write()
|
||||
.expect("UserDataPaths write lock poisoned") = None;
|
||||
log::info!("UserDataPaths: active user cleared");
|
||||
}
|
||||
|
||||
/// Get the current user ID, if set.
|
||||
pub fn current_user_id(&self) -> Option<u64> {
|
||||
*self
|
||||
.user_id
|
||||
.read()
|
||||
.expect("UserDataPaths read lock poisoned")
|
||||
}
|
||||
|
||||
/// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/
|
||||
pub fn user_data_dir(&self) -> Result<PathBuf, String> {
|
||||
let uid = self
|
||||
.user_id
|
||||
.read()
|
||||
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
|
||||
.ok_or("No active user - please log in")?;
|
||||
|
||||
let base = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string());
|
||||
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/
|
||||
pub fn user_cache_dir(&self) -> Result<PathBuf, String> {
|
||||
let uid = self
|
||||
.user_id
|
||||
.read()
|
||||
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
|
||||
.ok_or("No active user - please log in")?;
|
||||
|
||||
let base = dirs::cache_dir()
|
||||
.ok_or("Could not determine cache directory")?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string());
|
||||
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Get the global (non-user-scoped) data directory: ~/.local/share/qbz/
|
||||
pub fn global_data_dir() -> Result<PathBuf, String> {
|
||||
dirs::data_dir()
|
||||
.ok_or_else(|| "Could not determine data directory".to_string())
|
||||
.map(|d| d.join("qbz"))
|
||||
}
|
||||
|
||||
/// Get the global (non-user-scoped) cache directory: ~/.cache/qbz/
|
||||
pub fn global_cache_dir() -> Result<PathBuf, String> {
|
||||
dirs::cache_dir()
|
||||
.ok_or_else(|| "Could not determine cache directory".to_string())
|
||||
.map(|d| d.join("qbz"))
|
||||
}
|
||||
|
||||
/// Save the last active user_id to a flat-path file so the session can be
|
||||
/// restored on next app launch when remember-me is active.
|
||||
pub fn save_last_user_id(user_id: u64) -> Result<(), String> {
|
||||
let path = Self::last_user_id_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create global data directory: {}", e))?;
|
||||
}
|
||||
std::fs::write(&path, user_id.to_string())
|
||||
.map_err(|e| format!("Failed to save last user id: {}", e))?;
|
||||
log::info!("Saved last_user_id marker");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the last active user_id. Returns None if the file is missing or
|
||||
/// invalid.
|
||||
pub fn load_last_user_id() -> Option<u64> {
|
||||
let path = Self::last_user_id_path().ok()?;
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
contents.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Clear the last user_id file, called on explicit logout.
|
||||
pub fn clear_last_user_id() {
|
||||
if let Ok(path) = Self::last_user_id_path() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
log::info!("Cleared last_user_id file");
|
||||
}
|
||||
}
|
||||
|
||||
fn last_user_id_path() -> Result<PathBuf, String> {
|
||||
let dir = Self::global_data_dir()?;
|
||||
Ok(dir.join("last_user_id"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserDataPaths {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starts_without_active_user() {
|
||||
let paths = UserDataPaths::new();
|
||||
|
||||
assert_eq!(paths.current_user_id(), None);
|
||||
assert!(paths.user_data_dir().is_err());
|
||||
assert!(paths.user_cache_dir().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_clear_user_updates_current_user() {
|
||||
let paths = UserDataPaths::new();
|
||||
|
||||
paths.set_user(10385965);
|
||||
assert_eq!(paths.current_user_id(), Some(10385965));
|
||||
|
||||
paths.clear_user();
|
||||
assert_eq!(paths.current_user_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_dirs_are_scoped_by_user_id() {
|
||||
let paths = UserDataPaths::new();
|
||||
paths.set_user(42);
|
||||
|
||||
let data_dir = paths.user_data_dir().expect("data dir");
|
||||
let cache_dir = paths.user_cache_dir().expect("cache dir");
|
||||
|
||||
assert!(data_dir.ends_with("qbz/users/42"));
|
||||
assert!(cache_dir.ends_with("qbz/users/42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_dirs_are_scoped_to_qbz() {
|
||||
let data_dir = UserDataPaths::global_data_dir().expect("global data dir");
|
||||
let cache_dir = UserDataPaths::global_cache_dir().expect("global cache dir");
|
||||
|
||||
assert!(data_dir.ends_with("qbz"));
|
||||
assert!(cache_dir.ends_with("qbz"));
|
||||
}
|
||||
}
|
||||
+111
-33
@@ -488,6 +488,14 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let effective_device_id = config.device_id.as_ref();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if !config.exclusive_mode {
|
||||
return self.open_macos_shared_stream_with_retry(
|
||||
effective_device_id.map(|name| name.as_str()),
|
||||
MACOS_SHARED_OPEN_MAX_ATTEMPTS,
|
||||
);
|
||||
}
|
||||
|
||||
let device = if let Some(device_id) = effective_device_id {
|
||||
self.host
|
||||
.output_devices()
|
||||
@@ -504,35 +512,9 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.ok_or_else(|| "No default output device found".to_string())?
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let shared_mode_override = if !config.exclusive_mode {
|
||||
Self::shared_mode_nominal_stream_config(
|
||||
&device,
|
||||
effective_device_id.map(|name| name.as_str()),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let builder = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mixer_sink = if let Some(override_config) = shared_mode_override {
|
||||
let buffer_size =
|
||||
rodio::cpal::BufferSize::Fixed((override_config.sample_rate() / 20).max(64));
|
||||
builder
|
||||
.with_supported_config(&override_config)
|
||||
.with_buffer_size(buffer_size)
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?
|
||||
} else {
|
||||
builder
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let mixer_sink = builder
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?;
|
||||
@@ -587,8 +569,110 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_SHARED_OPEN_MAX_ATTEMPTS: usize = 2;
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_SHARED_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl CpalDefaultBackend {
|
||||
/// Open a macOS shared-mode CPAL stream and verify it actually opened at
|
||||
/// CoreAudio's current nominal rate. CPAL caches device configs and can
|
||||
/// return a stale rate when the OS rate has just changed; passing the
|
||||
/// stale rate to CPAL produces a stream that runs at the wrong speed.
|
||||
///
|
||||
/// Retries up to `max_attempts` times because the mismatch is racy — a
|
||||
/// fresh resolve + reopen usually lands on the correct rate.
|
||||
fn open_macos_shared_stream_with_retry(
|
||||
&self,
|
||||
effective_device_name: Option<&str>,
|
||||
max_attempts: usize,
|
||||
) -> BackendResult<MixerDeviceSink> {
|
||||
debug_assert!(max_attempts >= 1, "max_attempts must be at least 1");
|
||||
|
||||
let mut last_err: Option<String> = None;
|
||||
for attempt in 0..max_attempts {
|
||||
let device = if let Some(device_name) = effective_device_name {
|
||||
self.host
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.find(|d| {
|
||||
d.description()
|
||||
.map(|desc| desc.name() == device_name)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| format!("Device '{}' not found", device_name))?
|
||||
} else {
|
||||
self.host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| "No default output device found".to_string())?
|
||||
};
|
||||
|
||||
let override_config =
|
||||
Self::shared_mode_nominal_stream_config(&device, effective_device_name);
|
||||
|
||||
let builder = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
|
||||
|
||||
let open_result = if let Some(ref cfg) = override_config {
|
||||
let buffer_size = rodio::cpal::BufferSize::Fixed((cfg.sample_rate() / 20).max(64));
|
||||
builder
|
||||
.with_supported_config(cfg)
|
||||
.with_buffer_size(buffer_size)
|
||||
.open_stream()
|
||||
} else {
|
||||
builder.open_stream()
|
||||
};
|
||||
|
||||
let mixer_sink = match open_result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
last_err = Some(format!("Failed to create output stream: {}", e));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let nominal_rate = Self::current_macos_nominal_rate(effective_device_name);
|
||||
let output_rate = mixer_sink.config().sample_rate().get();
|
||||
match nominal_rate {
|
||||
Some(nominal) if nominal != output_rate => {
|
||||
drop(mixer_sink);
|
||||
if attempt + 1 < max_attempts {
|
||||
log::warn!(
|
||||
"[CoreAudio] System audio rate changed during stream open (stream {}Hz, device now {}Hz). Retrying open ({}/{})",
|
||||
output_rate,
|
||||
nominal,
|
||||
attempt + 2,
|
||||
max_attempts
|
||||
);
|
||||
std::thread::sleep(MACOS_SHARED_OPEN_RETRY_DELAY);
|
||||
}
|
||||
last_err = Some(format!(
|
||||
"macOS audio device changed its sample rate during stream open (opened at {}Hz, device is now {}Hz). This usually self-corrects — try playing again.",
|
||||
output_rate, nominal
|
||||
));
|
||||
continue;
|
||||
}
|
||||
_ => return Ok(mixer_sink),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
"Could not open the macOS audio output stream after multiple attempts. Try selecting the device again or restarting playback.".to_string()
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_macos_nominal_rate(effective_device_name: Option<&str>) -> Option<u32> {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
let device_id = match effective_device_name {
|
||||
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
|
||||
None => coreaudio_direct::get_default_output_device().ok(),
|
||||
}?;
|
||||
|
||||
coreaudio_direct::get_nominal_sample_rate(device_id).ok()
|
||||
}
|
||||
|
||||
/// In macOS shared mode, CPAL's default config can briefly report a stale
|
||||
/// sample rate after CoreAudio changes the device's nominal rate. If we
|
||||
/// trust the stale rate, playback can run at the wrong speed until the
|
||||
@@ -598,13 +682,7 @@ impl CpalDefaultBackend {
|
||||
device: &rodio::cpal::Device,
|
||||
effective_device_name: Option<&str>,
|
||||
) -> Option<rodio::cpal::SupportedStreamConfig> {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
let device_id = match effective_device_name {
|
||||
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
|
||||
None => coreaudio_direct::get_default_output_device().ok(),
|
||||
}?;
|
||||
let nominal_rate = coreaudio_direct::get_nominal_sample_rate(device_id).ok()?;
|
||||
let nominal_rate = Self::current_macos_nominal_rate(effective_device_name)?;
|
||||
let default_config = device.default_output_config().ok()?;
|
||||
let default_rate = default_config.sample_rate();
|
||||
if nominal_rate == default_rate {
|
||||
|
||||
@@ -44,17 +44,33 @@ pub struct AudioSettings {
|
||||
/// Target loudness in LUFS for normalization.
|
||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||
pub normalization_target_lufs: f32,
|
||||
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
|
||||
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
|
||||
/// When true, consecutive same-format tracks play without gap.
|
||||
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||
pub gapless_enabled: bool,
|
||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
||||
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, reload audio settings from DB into the player on app startup.
|
||||
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
|
||||
/// Default: false (most users don't need this).
|
||||
pub sync_audio_on_startup: bool,
|
||||
/// User preference for what happens when all quality retries fail.
|
||||
/// Values: "ask" (default), "always_fallback", "always_skip"
|
||||
/// Protected by ADR-003: must survive reset_all() and migrations.
|
||||
pub quality_fallback_behavior: String,
|
||||
/// When true, skip `pactl set-default-sink` on stream creation.
|
||||
/// Preserves external routing (JACK, qjackctl, Reaper).
|
||||
/// Mutually exclusive with dac_passthrough.
|
||||
pub skip_sink_switch: bool,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
/// When false (default), playback or download fails if the exact quality is unavailable.
|
||||
pub allow_quality_fallback: bool,
|
||||
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
|
||||
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
|
||||
/// clients won't grab the DAC and break exclusive playback. Off by default.
|
||||
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
|
||||
#[serde(default)]
|
||||
pub reserve_dac_while_running: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -64,7 +80,15 @@ impl Default for AudioSettings {
|
||||
exclusive_mode: false,
|
||||
dac_passthrough: false,
|
||||
preferred_sample_rate: None,
|
||||
backend_type: None, // Auto-detect (PipeWire if available, else ALSA)
|
||||
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
|
||||
// elsewhere) instead of None. None used to mean "auto-detect" but it
|
||||
// actually fell through to the legacy CPAL path in init_device, which
|
||||
// has a known race against rodio's DeviceSink drop on resume — visible
|
||||
// as "audio doesn't come back after pause" (#375). Picking a concrete
|
||||
// backend here puts new installs on the proper backend init path; the
|
||||
// runtime still degrades gracefully if the chosen backend isn't
|
||||
// available on the host.
|
||||
backend_type: Some(AudioBackendType::default()),
|
||||
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
|
||||
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
|
||||
stream_first_track: false, // Disabled by default — user opts in
|
||||
@@ -75,10 +99,13 @@ impl Default for AudioSettings {
|
||||
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: false, // Off by default — user opts in
|
||||
gapless_enabled: true, // On by default — works for same-format tracks on all backends
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
|
||||
quality_fallback_behavior: "ask".to_string(),
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +194,14 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
|
||||
[],
|
||||
@@ -175,6 +210,34 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration (#375): existing installs persisted backend_type = NULL when
|
||||
// the user never explicitly picked a backend. NULL fell through to the
|
||||
// legacy CPAL path in the player, which has a race against rodio's
|
||||
// DeviceSink drop on resume that produces silent streams after pause.
|
||||
// Backfill NULL rows with the per-OS default so those installs move onto
|
||||
// the proper backend init path. set_backend_type is the canonical writer
|
||||
// (it serialises via serde_json), so we go through the same JSON shape
|
||||
// here for the row read path to round-trip cleanly.
|
||||
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback only matters if serde_json::to_string fails for a
|
||||
// unit-variant enum, which it can't — but keep a sane string in
|
||||
// case the enum gains a non-trivial variant in the future.
|
||||
if cfg!(target_os = "linux") {
|
||||
"\"PipeWire\"".to_string()
|
||||
} else {
|
||||
"\"SystemDefault\"".to_string()
|
||||
}
|
||||
});
|
||||
let _ = conn.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
|
||||
params![default_backend_json],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -193,7 +256,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -230,8 +293,16 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
|
||||
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(18)?.unwrap_or(0) != 0,
|
||||
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
quality_fallback_behavior: row
|
||||
.get::<_, Option<String>>(18)?
|
||||
.unwrap_or_else(|| "ask".to_string()),
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
|
||||
reserve_dac_while_running: row
|
||||
.get::<_, Option<i64>>(21)?
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -290,6 +361,25 @@ impl AudioSettingsStore {
|
||||
params![backend_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set backend type: {}", e))?;
|
||||
|
||||
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
|
||||
if backend == Some(AudioBackendType::Alsa) {
|
||||
let current_plugin: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
|
||||
|
||||
if current_plugin.is_none() {
|
||||
log::info!(
|
||||
"[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw"
|
||||
);
|
||||
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -445,6 +535,40 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
|
||||
/// ALSA exclusive-hardening design spec). Toggling this only updates
|
||||
/// the DB row; applying the change to the live `DeviceReservation`
|
||||
/// guard is the caller's responsibility.
|
||||
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -455,6 +579,39 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
|
||||
let settings = self.get_settings()?;
|
||||
let value = &settings.quality_fallback_behavior;
|
||||
match value.as_str() {
|
||||
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
|
||||
_ => Ok("ask".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
|
||||
match behavior {
|
||||
"ask" | "always_fallback" | "always_skip" => {}
|
||||
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![behavior],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -467,6 +624,11 @@ impl AudioSettingsStore {
|
||||
|
||||
/// Reset all audio settings to their default values
|
||||
pub fn reset_all(&self) -> Result<AudioSettings, String> {
|
||||
// ADR-003: quality_fallback_behavior must survive reset_all()
|
||||
let saved_fallback = self
|
||||
.get_quality_fallback_behavior()
|
||||
.unwrap_or_else(|_| "ask".to_string());
|
||||
|
||||
let defaults = AudioSettings::default();
|
||||
let backend_json: Option<String> = defaults
|
||||
.backend_type
|
||||
@@ -502,7 +664,11 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18,
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20,
|
||||
reserve_dac_while_running = ?21
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -522,11 +688,30 @@ impl AudioSettingsStore {
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
defaults.skip_sink_switch as i64,
|
||||
defaults.allow_quality_fallback as i64,
|
||||
defaults.reserve_dac_while_running as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
Ok(defaults)
|
||||
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![saved_fallback],
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore quality_fallback_behavior after reset: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = defaults;
|
||||
result.quality_fallback_behavior = saved_fallback;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,3 +758,211 @@ impl Default for AudioSettingsState {
|
||||
Self::new_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-audio-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, AudioSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_settings_default_values_are_stable() {
|
||||
let settings = AudioSettings::default();
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
|
||||
assert!(settings.gapless_enabled);
|
||||
assert!(!settings.sync_audio_on_startup);
|
||||
assert_eq!(settings.quality_fallback_behavior, "ask");
|
||||
assert!(!settings.skip_sink_switch);
|
||||
assert!(!settings.allow_quality_fallback);
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_settings_store_returns_current_defaults() {
|
||||
let (dir, store) = fresh_store("defaults");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
assert_eq!(settings.alsa_plugin, None);
|
||||
assert!(!settings.gapless_enabled);
|
||||
assert_eq!(settings.quality_fallback_behavior, "ask");
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_null_is_backfilled_to_default_backend() {
|
||||
let dir = unique_test_dir("backend-backfill");
|
||||
{
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET backend_type = NULL WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("force legacy null backend");
|
||||
}
|
||||
|
||||
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_alsa_sets_default_plugin_when_missing() {
|
||||
let (dir, store) = fresh_store("alsa-plugin-default");
|
||||
store.set_alsa_plugin(None).expect("clear alsa plugin");
|
||||
|
||||
store
|
||||
.set_backend_type(Some(AudioBackendType::Alsa))
|
||||
.expect("set backend");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::Alsa));
|
||||
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_buffer_seconds_clamps_to_valid_range() {
|
||||
let (dir, store) = fresh_store("stream-buffer-clamp");
|
||||
|
||||
store
|
||||
.set_stream_buffer_seconds(0)
|
||||
.expect("set low buffer");
|
||||
assert_eq!(
|
||||
store.get_settings().expect("get settings").stream_buffer_seconds,
|
||||
1
|
||||
);
|
||||
|
||||
store
|
||||
.set_stream_buffer_seconds(99)
|
||||
.expect("set high buffer");
|
||||
assert_eq!(
|
||||
store.get_settings().expect("get settings").stream_buffer_seconds,
|
||||
10
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_fallback_invalid_value_reads_as_ask() {
|
||||
let (dir, store) = fresh_store("quality-invalid");
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = 'bad-value' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("write invalid fallback behavior");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get_quality_fallback_behavior()
|
||||
.expect("get quality fallback"),
|
||||
"ask"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_all_preserves_quality_fallback_behavior() {
|
||||
let (dir, store) = fresh_store("reset-preserves-quality");
|
||||
store
|
||||
.set_quality_fallback_behavior("always_skip")
|
||||
.expect("set quality fallback");
|
||||
store.set_output_device(Some("hw:4,0")).expect("set device");
|
||||
store.set_dac_passthrough(true).expect("set dac");
|
||||
|
||||
let reset = store.reset_all().expect("reset settings");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(reset.quality_fallback_behavior, "always_skip");
|
||||
assert_eq!(settings.quality_fallback_behavior, "always_skip");
|
||||
assert_eq!(settings.output_device, None);
|
||||
assert!(!settings.dac_passthrough);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_persist_and_reopen_all_new_fields() {
|
||||
let dir = unique_test_dir("persist-new-fields");
|
||||
{
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_sync_audio_on_startup(true)
|
||||
.expect("set sync flag");
|
||||
store
|
||||
.set_quality_fallback_behavior("always_fallback")
|
||||
.expect("set quality fallback");
|
||||
store
|
||||
.set_reserve_dac_while_running(true)
|
||||
.expect("set reserve flag");
|
||||
store
|
||||
.set_allow_quality_fallback(true)
|
||||
.expect("set allow fallback");
|
||||
store
|
||||
.set_skip_sink_switch(true)
|
||||
.expect("set skip sink switch");
|
||||
}
|
||||
|
||||
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.sync_audio_on_startup);
|
||||
assert_eq!(settings.quality_fallback_behavior, "always_fallback");
|
||||
assert!(settings.reserve_dac_while_running);
|
||||
assert!(settings.allow_quality_fallback);
|
||||
assert!(settings.skip_sink_switch);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_json_without_reserve_dac_field() {
|
||||
let legacy = r#"{
|
||||
"output_device": null,
|
||||
"exclusive_mode": false,
|
||||
"dac_passthrough": false,
|
||||
"preferred_sample_rate": null,
|
||||
"backend_type": null,
|
||||
"alsa_plugin": null,
|
||||
"alsa_hardware_volume": false,
|
||||
"stream_first_track": false,
|
||||
"stream_buffer_seconds": 3,
|
||||
"streaming_only": false,
|
||||
"limit_quality_to_device": false,
|
||||
"device_max_sample_rate": null,
|
||||
"normalization_enabled": false,
|
||||
"normalization_target_lufs": -14.0,
|
||||
"gapless_enabled": true,
|
||||
"pw_force_bitperfect": false,
|
||||
"sync_audio_on_startup": false,
|
||||
"quality_fallback_behavior": "ask",
|
||||
"skip_sink_switch": false,
|
||||
"allow_quality_fallback": false
|
||||
}"#;
|
||||
|
||||
let settings: AudioSettings =
|
||||
serde_json::from_str(legacy).expect("legacy JSON should deserialize");
|
||||
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,12 @@ impl Drop for ChromecastHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
// Google Cast drops a control connection that goes roughly 10s without a
|
||||
// heartbeat PING. Ping every 5s — the cadence the protocol expects — so a
|
||||
// cast connection left idle between connect and load (e.g. while a track
|
||||
// downloads) is not closed by the receiver. A 25s interval left idle
|
||||
// connections dead, surfacing as EPIPE on the next LOAD (issue #439).
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Main loop for the Chromecast thread
|
||||
fn chromecast_thread_main(receiver: Receiver<CastCommand>) {
|
||||
|
||||
@@ -53,26 +53,60 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
/// Initialize the core
|
||||
///
|
||||
/// This should be called once at startup to set up all subsystems.
|
||||
/// This extracts Qobuz bundle tokens - user must still call login() to authenticate.
|
||||
/// Best-effort: if bundle token extraction fails (e.g. no network), the
|
||||
/// core still finishes initialization so queue manager and player remain
|
||||
/// usable for offline/local playback. API calls will then return
|
||||
/// `CoreError::NotInitialized` until the client is rebuilt with tokens.
|
||||
pub async fn init(&self) -> Result<(), CoreError> {
|
||||
let mut initialized = self.initialized.write().await;
|
||||
if *initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Initialize Qobuz client
|
||||
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
|
||||
|
||||
// Extract bundle tokens (required before any API calls)
|
||||
match client.init().await {
|
||||
Ok(_) => {
|
||||
*self.client.write().await = Some(client);
|
||||
log::info!("QbzCore initialized with bundle tokens");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"QbzCore: bundle token extraction failed ({}). Starting in offline-tolerant mode; API calls will be unavailable until next online start.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
*initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the Qobuz API client is initialized (bundle tokens extracted).
|
||||
/// Returns false when the core is running in offline-tolerant mode after
|
||||
/// a failed bundle extraction at startup.
|
||||
pub async fn is_api_initialized(&self) -> bool {
|
||||
self.client.read().await.is_some()
|
||||
}
|
||||
|
||||
/// Best-effort attempt to rebuild the Qobuz API client. Useful when the
|
||||
/// initial `init()` ran offline and the host has since regained network.
|
||||
/// No-op when the client is already initialized.
|
||||
pub async fn try_init_api(&self) -> Result<(), CoreError> {
|
||||
{
|
||||
let guard = self.client.read().await;
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
|
||||
client
|
||||
.init()
|
||||
.await
|
||||
.map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?;
|
||||
|
||||
*self.client.write().await = Some(client);
|
||||
|
||||
*initialized = true;
|
||||
log::info!("QbzCore initialized with bundle tokens");
|
||||
log::info!("QbzCore: API client initialized lazily");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1789,7 +1789,17 @@ impl LibraryDatabase {
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
COALESCE(album_group_title, album, 'Unknown Album') AS title,
|
||||
-- Prefer `album` (metadata tag) over
|
||||
-- `album_group_title` (scan-time snapshot, which
|
||||
-- falls back to folder name if metadata is
|
||||
-- missing). Fixes #411 — when album metadata is
|
||||
-- valid, the folder name was winning because
|
||||
-- COALESCE returned `album_group_title` first.
|
||||
COALESCE(
|
||||
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
|
||||
album_group_title,
|
||||
'Unknown Album'
|
||||
) AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
year,
|
||||
catalog_number,
|
||||
@@ -1879,6 +1889,449 @@ impl LibraryDatabase {
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// Paginated, sort/filter-aware slice of metadata-grouped local
|
||||
/// albums. Designed to back the chunked-store + recycling-grid pool
|
||||
/// on the frontend: caller asks for `[offset, offset+limit)` and
|
||||
/// receives those rows plus the total count of rows matching the
|
||||
/// same filter (via `COUNT(*) OVER ()`).
|
||||
///
|
||||
/// Sort: one of `"artist"` (default), `"title"`, `"year"`, paired
|
||||
/// with direction `"asc"` (default) or `"desc"`. Unknown values
|
||||
/// fall back to artist-ascending. Albums with no `year` always sink
|
||||
/// to the bottom for the year sort.
|
||||
///
|
||||
/// Search: a non-empty `search` becomes a `LIKE '%pattern%'` match
|
||||
/// applied after aggregation against the album's title or artist
|
||||
/// (mirrors the legacy in-memory `matchesAlbumSearchFast`).
|
||||
///
|
||||
/// Source consolidation: when `plex_cache_path` is provided and
|
||||
/// points to an existing file, the function `ATTACH`es that
|
||||
/// database and unions plex_cache_tracks (aggregated by album_key)
|
||||
/// with the local aggregation. Sort, filter, and pagination apply
|
||||
/// to the union as a single result set, so a Plex-dominant library
|
||||
/// behaves identically to a local-dominant one.
|
||||
pub fn get_albums_metadata_page(
|
||||
&self,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<&str>,
|
||||
sort_by: &str,
|
||||
sort_dir: &str,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_cache_path: Option<&std::path::Path>,
|
||||
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
|
||||
// Best-effort ATTACH of the Plex cache so the union below can
|
||||
// see `plex_cache.plex_cache_tracks`. DETACH first defensively
|
||||
// so a stale attachment from a previous call (or another
|
||||
// connection user) doesn't fail the new one. Failure to
|
||||
// attach is non-fatal — we fall back to local-only.
|
||||
let plex_attached = if let Some(path) = plex_cache_path {
|
||||
if path.exists() {
|
||||
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
|
||||
let path_str = path.to_string_lossy().replace('\'', "''");
|
||||
self.conn
|
||||
.execute(
|
||||
&format!("ATTACH DATABASE '{}' AS plex_cache", path_str),
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let result = self.get_albums_metadata_page_inner(
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
sort_by,
|
||||
sort_dir,
|
||||
include_qobuz_downloads,
|
||||
exclude_network_folders,
|
||||
plex_attached,
|
||||
);
|
||||
if plex_attached {
|
||||
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn get_albums_metadata_page_inner(
|
||||
&self,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<&str>,
|
||||
sort_by: &str,
|
||||
sort_dir: &str,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_attached: bool,
|
||||
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// ORDER BY clause is built from a validated allowlist so user
|
||||
// input never reaches the SQL string directly. The unknown-album
|
||||
// sentinel always sorts last regardless of mode.
|
||||
let order_clause = match (sort_by, sort_dir) {
|
||||
("title", "asc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE",
|
||||
("title", "desc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE DESC",
|
||||
("year", "asc") => "(group_key = '__unknown_album__'), year IS NULL, year ASC, title COLLATE NOCASE",
|
||||
("year", "desc") => "(group_key = '__unknown_album__'), year IS NULL, year DESC, title COLLATE NOCASE",
|
||||
("artist", "desc") => "(group_key = '__unknown_album__'), artist COLLATE NOCASE DESC, title COLLATE NOCASE",
|
||||
// Default = artist asc
|
||||
_ => "(group_key = '__unknown_album__'), artist COLLATE NOCASE, title COLLATE NOCASE",
|
||||
};
|
||||
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
|
||||
let search_pattern = search.unwrap_or("").trim();
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
// When Plex is attached, the plex_aggregated CTE is appended
|
||||
// and the filtered set is built from the UNION of local + plex.
|
||||
// Both CTEs produce the same column shape so the UNION ALL is
|
||||
// straightforward; types are normalised via CAST in the plex
|
||||
// arm (plex stores duration_ms / sampling_rate_hz as INTEGER
|
||||
// while local uses REAL for sample_rate and seconds-INTEGER
|
||||
// for duration).
|
||||
let plex_cte = if plex_attached {
|
||||
r#",
|
||||
plex_aggregated AS (
|
||||
SELECT
|
||||
-- `album_key` is populated by plex/mod.rs::plex_album_key()
|
||||
-- which already returns `"plex:<hash>"`. Only the
|
||||
-- rating_key fallback needs the prefix added.
|
||||
COALESCE(album_key, 'plex:' || rating_key) AS group_key,
|
||||
COALESCE(album, 'Unknown Album') AS title,
|
||||
CASE WHEN COUNT(DISTINCT artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE COALESCE(MIN(artist), 'Unknown Artist')
|
||||
END AS artist,
|
||||
GROUP_CONCAT(DISTINCT artist) AS all_artists,
|
||||
MIN(year) AS year,
|
||||
CAST(NULL AS TEXT) AS catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
|
||||
COUNT(*) AS track_count,
|
||||
CAST(SUM(COALESCE(duration_ms, 0)) / 1000 AS INTEGER) AS total_duration,
|
||||
-- Plex stream-level `codec` is often missing when the
|
||||
-- server hasn't fully analyzed a track. The `container`
|
||||
-- field is populated for the same media and usually
|
||||
-- carries the same value ("flac", "mp3", etc.), so it
|
||||
-- works as a fallback. Without it, any album where
|
||||
-- Plex didn't expose codec on every track ends up
|
||||
-- labeled "Unknown" in the UI even though the file
|
||||
-- format is known via container. Local CTE is not
|
||||
-- affected — local indexing always writes a non-null
|
||||
-- format string.
|
||||
COALESCE(MAX(codec), MAX(container)) AS format,
|
||||
-- Plex frequently omits bitDepth from its Media/Stream
|
||||
-- XML for older releases; the aggregated row inherits
|
||||
-- the gap as NULL. When the format ends up lossless
|
||||
-- and sample rate sits at CD range (<= 48 kHz),
|
||||
-- default to 16 — that's the universal CD-Audio /
|
||||
-- redbook assumption that virtually every lossless
|
||||
-- album at that rate matches. Higher rates leave the
|
||||
-- field NULL (could be 24, could be 32) and the UI
|
||||
-- falls back to its "--" placeholder; the per-track
|
||||
-- view shows the real value when the user clicks in.
|
||||
COALESCE(
|
||||
MAX(bit_depth),
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(MAX(codec), MAX(container))) IN
|
||||
('flac', 'alac', 'wav', 'aiff', 'ape')
|
||||
AND MAX(sampling_rate_hz) <= 48000
|
||||
THEN 16
|
||||
ELSE NULL
|
||||
END
|
||||
) AS bit_depth,
|
||||
CAST(MAX(sampling_rate_hz) AS REAL) AS sample_rate,
|
||||
CAST(NULL AS TEXT) AS source_folders,
|
||||
'plex' AS source
|
||||
FROM plex_cache.plex_cache_tracks
|
||||
GROUP BY COALESCE(album_key, 'plex:' || rating_key)
|
||||
)"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let unioned_clause = if plex_attached {
|
||||
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
|
||||
} else {
|
||||
"SELECT * FROM aggregated"
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
-- Prefer `album` (metadata tag) over
|
||||
-- `album_group_title` (scan-time snapshot, which
|
||||
-- falls back to folder name if metadata is
|
||||
-- missing). Fixes #411 — when album metadata is
|
||||
-- valid, the folder name was winning because
|
||||
-- COALESCE returned `album_group_title` first.
|
||||
COALESCE(
|
||||
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
|
||||
album_group_title,
|
||||
'Unknown Album'
|
||||
) AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
year,
|
||||
catalog_number,
|
||||
artwork_path,
|
||||
duration_secs,
|
||||
format,
|
||||
bit_depth,
|
||||
sample_rate,
|
||||
album_group_key AS source_folder,
|
||||
COALESCE(source, 'user') AS source,
|
||||
artist AS track_artist
|
||||
FROM local_tracks
|
||||
WHERE 1=1 {source_filter} {network_filter}
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
group_key,
|
||||
CASE WHEN group_key = '__unknown_album__'
|
||||
THEN 'Unknown Album'
|
||||
ELSE MIN(title)
|
||||
END AS title,
|
||||
CASE WHEN COUNT(DISTINCT track_artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE MIN(artist)
|
||||
END AS artist,
|
||||
GROUP_CONCAT(DISTINCT track_artist) AS all_artists,
|
||||
MIN(year) AS year,
|
||||
MIN(catalog_number) AS catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
|
||||
COUNT(*) AS track_count,
|
||||
SUM(duration_secs) AS total_duration,
|
||||
MAX(format) AS format,
|
||||
MAX(bit_depth) AS bit_depth,
|
||||
MAX(sample_rate) AS sample_rate,
|
||||
GROUP_CONCAT(DISTINCT source_folder) AS source_folders,
|
||||
MAX(source) AS source
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
){plex_cte},
|
||||
filtered AS (
|
||||
SELECT * FROM ({unioned_clause})
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
)
|
||||
SELECT
|
||||
group_key, title, artist, all_artists, year, catalog_number,
|
||||
artwork, track_count, total_duration, format, bit_depth,
|
||||
sample_rate, source_folders, source,
|
||||
COUNT(*) OVER () AS total
|
||||
FROM filtered
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ?3 OFFSET ?4
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
plex_cte = plex_cte,
|
||||
unioned_clause = unioned_clause,
|
||||
order_clause = order_clause,
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![has_search, search_like, limit as i64, offset as i64],
|
||||
|row| {
|
||||
let group_key: String = row.get(0)?;
|
||||
let title: String = row.get(1)?;
|
||||
let artist: String = row.get(2)?;
|
||||
let all_artists: String =
|
||||
row.get::<_, Option<String>>(3)?.unwrap_or_default();
|
||||
let artwork_path: Option<String> = row.get(6)?;
|
||||
let source_folders: Option<String> = row.get(12)?;
|
||||
let total: u64 = row.get::<_, i64>(14)? as u64;
|
||||
|
||||
Ok((
|
||||
LocalAlbum {
|
||||
id: group_key,
|
||||
title,
|
||||
artist,
|
||||
all_artists,
|
||||
year: row.get(4)?,
|
||||
catalog_number: row.get(5)?,
|
||||
artwork_path,
|
||||
track_count: row.get(7)?,
|
||||
total_duration_secs: row.get(8)?,
|
||||
format: Self::parse_format(
|
||||
&row.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||
),
|
||||
bit_depth: row.get(10)?,
|
||||
sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0),
|
||||
directory_path: String::new(),
|
||||
source_folders,
|
||||
source: row
|
||||
.get::<_, Option<String>>(13)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
},
|
||||
total,
|
||||
))
|
||||
},
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let mut albums = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
for row_result in rows {
|
||||
let (album, t) = row_result.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
total = t;
|
||||
albums.push(album);
|
||||
}
|
||||
|
||||
// Empty page (offset past the end or filter matches nothing).
|
||||
// The window-function trick gives us total only on returned
|
||||
// rows, so when there are none we have to ask separately.
|
||||
if albums.is_empty() {
|
||||
total = self.count_albums_metadata_for_page(
|
||||
search,
|
||||
include_qobuz_downloads,
|
||||
exclude_network_folders,
|
||||
plex_attached,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(crate::models::AlbumsMetadataPage { albums, total })
|
||||
}
|
||||
|
||||
/// Companion to `get_albums_metadata_page` — total count of albums
|
||||
/// matching the same filter. Used when the page is empty (so the
|
||||
/// window-function-derived total isn't available) or when the
|
||||
/// frontend wants to know the count before requesting any page.
|
||||
/// Honours the same Plex attachment state as the caller used.
|
||||
fn count_albums_metadata_for_page(
|
||||
&self,
|
||||
search: Option<&str>,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_attached: bool,
|
||||
) -> Result<u64, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
let search_pattern = search.unwrap_or("").trim();
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
let plex_cte = if plex_attached {
|
||||
r#",
|
||||
plex_aggregated AS (
|
||||
SELECT
|
||||
-- `album_key` is populated by plex/mod.rs::plex_album_key()
|
||||
-- which already returns `"plex:<hash>"`. Only the
|
||||
-- rating_key fallback needs the prefix added.
|
||||
COALESCE(album_key, 'plex:' || rating_key) AS group_key,
|
||||
COALESCE(album, 'Unknown Album') AS title,
|
||||
COALESCE(MIN(artist), 'Unknown Artist') AS artist
|
||||
FROM plex_cache.plex_cache_tracks
|
||||
GROUP BY COALESCE(album_key, 'plex:' || rating_key)
|
||||
)"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let unioned_clause = if plex_attached {
|
||||
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
|
||||
} else {
|
||||
"SELECT * FROM aggregated"
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
-- Prefer `album` (metadata tag) over
|
||||
-- `album_group_title` (scan-time snapshot, which
|
||||
-- falls back to folder name if metadata is
|
||||
-- missing). Fixes #411 — when album metadata is
|
||||
-- valid, the folder name was winning because
|
||||
-- COALESCE returned `album_group_title` first.
|
||||
COALESCE(
|
||||
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
|
||||
album_group_title,
|
||||
'Unknown Album'
|
||||
) AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
artist AS track_artist
|
||||
FROM local_tracks
|
||||
WHERE 1=1 {source_filter} {network_filter}
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
group_key,
|
||||
CASE WHEN group_key = '__unknown_album__'
|
||||
THEN 'Unknown Album'
|
||||
ELSE MIN(title)
|
||||
END AS title,
|
||||
CASE WHEN COUNT(DISTINCT track_artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE MIN(artist)
|
||||
END AS artist
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
){plex_cte}
|
||||
SELECT COUNT(*)
|
||||
FROM ({unioned_clause})
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
plex_cte = plex_cte,
|
||||
unioned_clause = unioned_clause,
|
||||
);
|
||||
|
||||
let total: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
&query,
|
||||
rusqlite::params![has_search, search_like],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
Ok(total as u64)
|
||||
}
|
||||
|
||||
/// Get tracks for a metadata-grouped album. The `metadata_key`
|
||||
/// matches what [`Self::get_albums_metadata_grouped`] returns for
|
||||
/// the album's `id` field.
|
||||
|
||||
@@ -131,6 +131,15 @@ pub struct PlaylistLocalTrack {
|
||||
pub playlist_position: i32,
|
||||
}
|
||||
|
||||
/// One page of metadata-grouped local albums plus the total count of
|
||||
/// albums matching the same filter (for scrollbar pre-allocation on the
|
||||
/// frontend). Returned by `Database::get_albums_metadata_page`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumsMetadataPage {
|
||||
pub albums: Vec<LocalAlbum>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// An album aggregated from local tracks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalAlbum {
|
||||
|
||||
@@ -251,6 +251,14 @@ pub struct Album {
|
||||
#[serde(default)]
|
||||
pub image: ImageSet,
|
||||
pub release_date_original: Option<String>,
|
||||
/// Date the album becomes available for streaming (ISO YYYY-MM-DD).
|
||||
/// When in the future, the album is upcoming and cannot be fetched
|
||||
/// via `get_album` yet — Release Watch uses this to gate clicks.
|
||||
pub release_date_stream: Option<String>,
|
||||
/// Whether the album is currently streamable. False for upcoming
|
||||
/// releases, regional restrictions, or label takedowns.
|
||||
#[serde(default)]
|
||||
pub streamable: Option<bool>,
|
||||
pub label: Option<Label>,
|
||||
pub genre: Option<Genre>,
|
||||
pub tracks_count: Option<u32>,
|
||||
|
||||
+539
-129
@@ -68,6 +68,13 @@ enum AudioCommand {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
duration_secs: u64,
|
||||
/// Resume offset in seconds (#315). When > 0, the audio thread
|
||||
/// waits for enough buffer to cover the offset and pre-skips
|
||||
/// decoder output up to that point before engaging audio.
|
||||
start_position_secs: u64,
|
||||
/// Total content length in bytes. Combined with `duration_secs`
|
||||
/// to estimate bytes-per-second when sizing the resume buffer.
|
||||
content_length: u64,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
@@ -504,6 +511,16 @@ impl StreamType {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Actual output stream rate. On macOS shared mode this is the rate that
|
||||
/// must match CoreAudio's current nominal device rate; decoded track rates
|
||||
/// may differ and are resampled by Rodio.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn output_sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
StreamType::Rodio { sink, .. } => sink.config().sample_rate().get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_engine_volume(
|
||||
@@ -524,6 +541,209 @@ fn apply_engine_volume(
|
||||
engine.set_volume(volume);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn uses_coreaudio_system_default(settings: &AudioSettings) -> bool {
|
||||
settings
|
||||
.backend_type
|
||||
.unwrap_or(AudioBackendType::SystemDefault)
|
||||
== AudioBackendType::SystemDefault
|
||||
}
|
||||
|
||||
/// `evaluate_stream_recreate` runs every track change on the audio thread,
|
||||
/// and `coreaudio_nominal_rate` issues two CoreAudio HAL queries per call
|
||||
/// (`resolve_output_device_id` + `get_nominal_sample_rate`). A short-lived
|
||||
/// cache absorbs the bulk of those repeats — long enough to skip duplicate
|
||||
/// queries within an album, short enough to notice when the user changes the
|
||||
/// system audio rate.
|
||||
#[cfg(target_os = "macos")]
|
||||
const COREAUDIO_RATE_CACHE_TTL: std::time::Duration = std::time::Duration::from_millis(750);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct CachedNominalRate {
|
||||
cached_at: std::time::Instant,
|
||||
device_name: Option<String>,
|
||||
rate: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
std::thread_local! {
|
||||
static COREAUDIO_NOMINAL_RATE_CACHE: std::cell::RefCell<Option<CachedNominalRate>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn query_macos_nominal_rate(device_name: Option<&str>) -> Option<u32> {
|
||||
let device_id = qbz_audio::coreaudio_direct::resolve_output_device_id(device_name)
|
||||
.inspect_err(|e| {
|
||||
log::warn!(
|
||||
"[CoreAudio] Failed to resolve output device for rate check: {}",
|
||||
e
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
qbz_audio::coreaudio_direct::get_nominal_sample_rate(device_id)
|
||||
.inspect_err(|e| log::warn!("[CoreAudio] Failed to query nominal rate: {}", e))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
|
||||
if !uses_coreaudio_system_default(settings) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device_name = settings.output_device.clone();
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
COREAUDIO_NOMINAL_RATE_CACHE.with(|cell| {
|
||||
let mut cell = cell.borrow_mut();
|
||||
if let Some(cached) = cell.as_ref() {
|
||||
if cached.device_name == device_name
|
||||
&& now.duration_since(cached.cached_at) < COREAUDIO_RATE_CACHE_TTL
|
||||
{
|
||||
return cached.rate;
|
||||
}
|
||||
}
|
||||
let rate = query_macos_nominal_rate(device_name.as_deref());
|
||||
*cell = Some(CachedNominalRate {
|
||||
cached_at: now,
|
||||
device_name,
|
||||
rate,
|
||||
});
|
||||
rate
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coreaudio_shared_rate_mismatch(
|
||||
settings: &AudioSettings,
|
||||
stream_opt: &Option<StreamType>,
|
||||
) -> Option<(u32, u32)> {
|
||||
if settings.exclusive_mode || !uses_coreaudio_system_default(settings) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stream_rate = stream_opt.as_ref().map(StreamType::output_sample_rate)?;
|
||||
let nominal_rate = coreaudio_nominal_rate(settings)?;
|
||||
|
||||
(stream_rate != nominal_rate).then_some((stream_rate, nominal_rate))
|
||||
}
|
||||
|
||||
/// Inputs needed by both `Play` and `Stream` handlers to decide whether the
|
||||
/// current output stream must be torn down and recreated.
|
||||
struct StreamRecreateDecision {
|
||||
needs_new_stream: bool,
|
||||
format_changed: bool,
|
||||
dac_passthrough: bool,
|
||||
using_alsa_direct: bool,
|
||||
using_coreaudio_exclusive: bool,
|
||||
coreaudio_shared_rate_mismatch: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Read settings once and evaluate every condition that forces a stream
|
||||
/// rebuild: format change on backends that demand bit-perfect output (DAC
|
||||
/// passthrough, ALSA Direct, CoreAudio Exclusive) and CoreAudio shared-mode
|
||||
/// rate drift (CPAL caches the device rate at open time, so when the OS
|
||||
/// nominal rate moves we must rebuild or playback runs at the wrong speed).
|
||||
///
|
||||
/// `current_track_sample_rate` / `current_track_channels` describe the last
|
||||
/// *decoded source* format and are compared to the incoming `sample_rate` /
|
||||
/// `channels` — never to the output stream's hardware rate, which on macOS
|
||||
/// shared mode may differ and is tracked separately via Rodio's sink config.
|
||||
fn evaluate_stream_recreate(
|
||||
thread_settings: &Arc<Mutex<AudioSettings>>,
|
||||
stream_opt: &Option<StreamType>,
|
||||
current_track_sample_rate: Option<u32>,
|
||||
current_track_channels: Option<u16>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
context: &str,
|
||||
) -> StreamRecreateDecision {
|
||||
let format_changed =
|
||||
current_track_sample_rate != Some(sample_rate) || current_track_channels != Some(channels);
|
||||
|
||||
let settings_guard = thread_settings.lock().ok();
|
||||
|
||||
let dac_passthrough = settings_guard
|
||||
.as_ref()
|
||||
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
let using_alsa_direct = settings_guard
|
||||
.as_ref()
|
||||
.and_then(|s| s.backend_type)
|
||||
.map(|b| b == AudioBackendType::Alsa)
|
||||
.unwrap_or(false);
|
||||
|
||||
let using_coreaudio_exclusive = settings_guard
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
cfg!(target_os = "macos")
|
||||
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
|
||||
== AudioBackendType::SystemDefault
|
||||
&& s.exclusive_mode
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let coreaudio_shared_rate_mismatch = settings_guard
|
||||
.as_ref()
|
||||
.and_then(|s| coreaudio_shared_rate_mismatch(s, stream_opt))
|
||||
.inspect(|(stream_rate, nominal_rate)| {
|
||||
log::warn!(
|
||||
"[CoreAudio] {} shared-mode output rate changed: stream {}Hz, device nominal {}Hz. Recreating stream to avoid wrong-speed playback.",
|
||||
context,
|
||||
stream_rate,
|
||||
nominal_rate
|
||||
);
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let coreaudio_shared_rate_mismatch: Option<(u32, u32)> = {
|
||||
let _ = (stream_opt, context);
|
||||
None
|
||||
};
|
||||
|
||||
drop(settings_guard);
|
||||
|
||||
let needs_new_stream = compute_needs_new_stream(
|
||||
stream_opt.is_some(),
|
||||
format_changed,
|
||||
dac_passthrough,
|
||||
using_alsa_direct,
|
||||
using_coreaudio_exclusive,
|
||||
coreaudio_shared_rate_mismatch.is_some(),
|
||||
);
|
||||
|
||||
StreamRecreateDecision {
|
||||
needs_new_stream,
|
||||
format_changed,
|
||||
dac_passthrough,
|
||||
using_alsa_direct,
|
||||
using_coreaudio_exclusive,
|
||||
coreaudio_shared_rate_mismatch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure decision rule for whether the output stream must be rebuilt.
|
||||
///
|
||||
/// Split out so the truth table can be unit-tested without faking a real
|
||||
/// `MixerDeviceSink` or `AudioSettings` mutex.
|
||||
fn compute_needs_new_stream(
|
||||
has_stream: bool,
|
||||
format_changed: bool,
|
||||
dac_passthrough: bool,
|
||||
using_alsa_direct: bool,
|
||||
using_coreaudio_exclusive: bool,
|
||||
coreaudio_shared_rate_mismatch: bool,
|
||||
) -> bool {
|
||||
!has_stream
|
||||
|| (dac_passthrough && format_changed)
|
||||
|| (using_alsa_direct && format_changed)
|
||||
|| (using_coreaudio_exclusive && format_changed)
|
||||
|| coreaudio_shared_rate_mismatch
|
||||
}
|
||||
|
||||
/// Try to create output stream using the backend system (if configured)
|
||||
/// Returns None if backend system is not configured (backend_type = None)
|
||||
///
|
||||
@@ -608,10 +828,12 @@ fn try_init_stream_with_backend(
|
||||
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
|
||||
match backend.create_output_stream_with_exclusive_guard(&config) {
|
||||
Ok((mixer_sink, _exclusive_guard)) => {
|
||||
let output_sample_rate = mixer_sink.config().sample_rate().get();
|
||||
log::info!(
|
||||
"Stream created via {:?} backend at {}Hz",
|
||||
"Stream created via {:?} backend (requested {}Hz, output {}Hz)",
|
||||
backend_type,
|
||||
sample_rate
|
||||
sample_rate,
|
||||
output_sample_rate
|
||||
);
|
||||
state.set_bit_perfect_mode(Some(BitPerfectMode::Disabled));
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -689,6 +911,10 @@ pub struct SharedState {
|
||||
current_device: Arc<std::sync::RwLock<Option<String>>>,
|
||||
/// Stream error flag (set when ALSA/audio errors are detected)
|
||||
stream_error: Arc<AtomicBool>,
|
||||
/// Optional user-readable explanation paired with `stream_error`.
|
||||
/// Drained by the Tauri polling loop to emit a frontend toast and then
|
||||
/// cleared, so the UI fires the notification exactly once per error.
|
||||
stream_error_message: Arc<std::sync::RwLock<Option<String>>>,
|
||||
/// Actual sample rate of the current stream (Hz)
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
/// Actual bit depth of the current stream
|
||||
@@ -726,6 +952,7 @@ impl SharedState {
|
||||
position_at_start: Arc::new(AtomicU64::new(0)),
|
||||
current_device: Arc::new(std::sync::RwLock::new(None)),
|
||||
stream_error: Arc::new(AtomicBool::new(false)),
|
||||
stream_error_message: Arc::new(std::sync::RwLock::new(None)),
|
||||
sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
bit_depth: Arc::new(AtomicU32::new(0)),
|
||||
normalization_gain: Arc::new(AtomicU32::new(0)),
|
||||
@@ -736,14 +963,45 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clearing (`error = false`) also drops any pending
|
||||
/// `stream_error_message`. This is intentional: if init recovers before
|
||||
/// the Tauri polling loop drains the message, we'd rather swallow the
|
||||
/// toast than surface a notification for a transient failure the user
|
||||
/// never perceived. The trade-off is that a fast record→clear→drain
|
||||
/// sequence loses the message — accepted because a recovered error is
|
||||
/// not a user-actionable event.
|
||||
pub fn set_stream_error(&self, error: bool) {
|
||||
self.stream_error.store(error, Ordering::SeqCst);
|
||||
if !error {
|
||||
if let Ok(mut m) = self.stream_error_message.write() {
|
||||
*m = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_stream_error(&self) -> bool {
|
||||
self.stream_error.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Record a user-readable error explanation alongside `stream_error=true`.
|
||||
/// The message is drained once via `take_stream_error_message` so the UI
|
||||
/// fires the toast exactly once per error.
|
||||
pub fn record_stream_error(&self, message: impl Into<String>) {
|
||||
self.stream_error.store(true, Ordering::SeqCst);
|
||||
if let Ok(mut m) = self.stream_error_message.write() {
|
||||
*m = Some(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically take the pending stream-error message (if any). Returns
|
||||
/// `None` when no message is pending or has already been read.
|
||||
pub fn take_stream_error_message(&self) -> Option<String> {
|
||||
self.stream_error_message
|
||||
.write()
|
||||
.ok()
|
||||
.and_then(|mut m| m.take())
|
||||
}
|
||||
|
||||
pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) {
|
||||
self.sample_rate.store(sample_rate, Ordering::SeqCst);
|
||||
self.bit_depth.store(bit_depth, Ordering::SeqCst);
|
||||
@@ -1068,19 +1326,24 @@ impl Player {
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
// On macOS, the backend path is the only one that
|
||||
// honors Exclusive Mode (CoreAudio Hog Mode). Falling
|
||||
// through to legacy CPAL here would silently create a
|
||||
// shared-mode stream while the UI still shows
|
||||
// Exclusive — surface the failure instead so the
|
||||
// user sees that audio is unavailable rather than
|
||||
// unknowingly playing in shared mode.
|
||||
// understands CoreAudio ownership and nominal-rate
|
||||
// validation. Falling through to legacy CPAL can
|
||||
// create a stream at a stale/source rate; in
|
||||
// shared mode that can produce wrong-speed audio,
|
||||
// and in Exclusive Mode it would silently drop Hog
|
||||
// Mode. Surface the failure instead.
|
||||
#[cfg(target_os = "macos")]
|
||||
if settings.exclusive_mode {
|
||||
if settings
|
||||
.backend_type
|
||||
.unwrap_or(AudioBackendType::SystemDefault)
|
||||
== AudioBackendType::SystemDefault
|
||||
{
|
||||
log::error!(
|
||||
"macOS Exclusive Mode init failed: {} — refusing to fall back to shared CPAL",
|
||||
"Could not start macOS audio output — {}. Not falling back to the legacy CPAL path because it would either play at the wrong speed (shared mode) or silently drop Exclusive Mode.",
|
||||
e
|
||||
);
|
||||
state.set_current_device(None);
|
||||
state.record_stream_error(e.clone());
|
||||
return None;
|
||||
}
|
||||
log::warn!(
|
||||
@@ -1168,8 +1431,8 @@ impl Player {
|
||||
// Initialize audio device lazily on first playback to avoid idle CPU usage.
|
||||
let mut current_device_name = device_name.clone();
|
||||
let mut stream_opt: Option<StreamType> = None;
|
||||
let mut current_sample_rate: Option<u32> = None;
|
||||
let mut current_channels: Option<u16> = None;
|
||||
let mut current_track_sample_rate: Option<u32> = None;
|
||||
let mut current_track_channels: Option<u16> = None;
|
||||
|
||||
#[allow(dead_code)]
|
||||
const MAX_INIT_RETRIES: u32 = 5;
|
||||
@@ -1209,8 +1472,8 @@ impl Player {
|
||||
current_device_name: &mut Option<String>,
|
||||
consecutive_sink_failures: &mut u32,
|
||||
pause_suspend_deadline: &mut Option<Instant>,
|
||||
current_sample_rate: &mut Option<u32>,
|
||||
current_channels: &mut Option<u16>,
|
||||
current_track_sample_rate: &mut Option<u32>,
|
||||
current_track_channels: &mut Option<u16>,
|
||||
current_normalization_gain: &mut Option<f32>,
|
||||
current_gain_atomic: &mut Option<Arc<AtomicU32>>,
|
||||
gapless_pending: &mut Option<GaplessPending>,
|
||||
@@ -1236,46 +1499,32 @@ impl Player {
|
||||
thread_state.set_gapless_ready(false);
|
||||
thread_state.set_gapless_next_track_id(0);
|
||||
|
||||
// Get DAC passthrough setting
|
||||
let dac_passthrough = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if we need to recreate the stream
|
||||
// Recreate on format change if DAC passthrough OR ALSA Direct is enabled (both require bit-perfect)
|
||||
let format_changed = *current_sample_rate != Some(sample_rate)
|
||||
|| *current_channels != Some(channels);
|
||||
|
||||
// Check if using ALSA Direct backend
|
||||
let using_alsa_direct = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.backend_type)
|
||||
.map(|b| b == AudioBackendType::Alsa)
|
||||
.unwrap_or(false);
|
||||
let using_coreaudio_exclusive = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| {
|
||||
cfg!(target_os = "macos")
|
||||
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
|
||||
== AudioBackendType::SystemDefault
|
||||
&& s.exclusive_mode
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let needs_new_stream = stream_opt.is_none()
|
||||
|| (dac_passthrough && format_changed)
|
||||
|| (using_alsa_direct && format_changed)
|
||||
|| (using_coreaudio_exclusive && format_changed);
|
||||
let StreamRecreateDecision {
|
||||
needs_new_stream,
|
||||
format_changed,
|
||||
dac_passthrough,
|
||||
using_alsa_direct,
|
||||
using_coreaudio_exclusive,
|
||||
coreaudio_shared_rate_mismatch,
|
||||
} = evaluate_stream_recreate(
|
||||
&thread_settings,
|
||||
stream_opt,
|
||||
*current_track_sample_rate,
|
||||
*current_track_channels,
|
||||
sample_rate,
|
||||
channels,
|
||||
"Play",
|
||||
);
|
||||
|
||||
if needs_new_stream {
|
||||
if stream_opt.is_some() {
|
||||
if (dac_passthrough
|
||||
|| using_alsa_direct
|
||||
|| using_coreaudio_exclusive)
|
||||
// CoreAudio rate-mismatch case already
|
||||
// logged at warn level by
|
||||
// `evaluate_stream_recreate`.
|
||||
if coreaudio_shared_rate_mismatch.is_none()
|
||||
&& (dac_passthrough
|
||||
|| using_alsa_direct
|
||||
|| using_coreaudio_exclusive)
|
||||
&& format_changed
|
||||
{
|
||||
let mode = if using_coreaudio_exclusive {
|
||||
@@ -1287,14 +1536,12 @@ impl Player {
|
||||
};
|
||||
log::info!(
|
||||
"Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating audio stream ({})",
|
||||
*current_sample_rate,
|
||||
*current_channels,
|
||||
*current_track_sample_rate,
|
||||
*current_track_channels,
|
||||
sample_rate,
|
||||
channels,
|
||||
mode
|
||||
);
|
||||
} else {
|
||||
log::info!("Creating initial audio stream");
|
||||
}
|
||||
// Stop engine FIRST so its writer thread releases its
|
||||
// Arc<AlsaDirectStream> reference before we drop the stream.
|
||||
@@ -1455,8 +1702,6 @@ impl Player {
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
*stream_opt = Some(stream);
|
||||
*current_sample_rate = Some(sample_rate);
|
||||
*current_channels = Some(channels);
|
||||
thread_state.set_stream_error(false);
|
||||
|
||||
// Set current device name from settings (for backend system)
|
||||
@@ -1502,13 +1747,20 @@ impl Player {
|
||||
// Format changed but DAC passthrough is disabled - reuse existing stream
|
||||
log::info!(
|
||||
"Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing audio stream (DAC passthrough disabled, gapless enabled)",
|
||||
*current_sample_rate,
|
||||
*current_channels,
|
||||
*current_track_sample_rate,
|
||||
*current_track_channels,
|
||||
sample_rate,
|
||||
channels
|
||||
);
|
||||
}
|
||||
|
||||
// Track the decoded source format separately from
|
||||
// the OS output stream rate. In shared mode the
|
||||
// stream can stay at the CoreAudio nominal rate
|
||||
// while each track has its own decoded rate.
|
||||
*current_track_sample_rate = Some(sample_rate);
|
||||
*current_track_channels = Some(channels);
|
||||
|
||||
let Some(ref stream) = *stream_opt else {
|
||||
log::error!("Audio thread: no audio device available");
|
||||
return;
|
||||
@@ -1558,8 +1810,8 @@ impl Player {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
// Use last known sample rate/channels to maintain DAC passthrough
|
||||
let sr = current_sample_rate.unwrap_or(48000);
|
||||
let ch = current_channels.unwrap_or(2);
|
||||
let sr = current_track_sample_rate.unwrap_or(48000);
|
||||
let ch = current_track_channels.unwrap_or(2);
|
||||
*stream_opt = init_device(
|
||||
current_device_name,
|
||||
&thread_state,
|
||||
@@ -1698,13 +1950,16 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
} => {
|
||||
log::info!(
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
*pause_suspend_deadline = None;
|
||||
|
||||
@@ -1714,44 +1969,32 @@ impl Player {
|
||||
*current_audio_data = None; // Clear regular audio data
|
||||
thread_state.set_loaded_audio(true);
|
||||
|
||||
// Get DAC passthrough setting
|
||||
let dac_passthrough = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if we need to recreate the stream
|
||||
let format_changed = *current_sample_rate != Some(sample_rate)
|
||||
|| *current_channels != Some(channels);
|
||||
|
||||
let using_alsa_direct = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.backend_type)
|
||||
.map(|b| b == AudioBackendType::Alsa)
|
||||
.unwrap_or(false);
|
||||
let using_coreaudio_exclusive = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| {
|
||||
cfg!(target_os = "macos")
|
||||
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
|
||||
== AudioBackendType::SystemDefault
|
||||
&& s.exclusive_mode
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let needs_new_stream = stream_opt.is_none()
|
||||
|| (dac_passthrough && format_changed)
|
||||
|| (using_alsa_direct && format_changed)
|
||||
|| (using_coreaudio_exclusive && format_changed);
|
||||
let StreamRecreateDecision {
|
||||
needs_new_stream,
|
||||
format_changed,
|
||||
dac_passthrough,
|
||||
using_alsa_direct,
|
||||
using_coreaudio_exclusive,
|
||||
coreaudio_shared_rate_mismatch,
|
||||
} = evaluate_stream_recreate(
|
||||
&thread_settings,
|
||||
stream_opt,
|
||||
*current_track_sample_rate,
|
||||
*current_track_channels,
|
||||
sample_rate,
|
||||
channels,
|
||||
"Streaming",
|
||||
);
|
||||
|
||||
if needs_new_stream {
|
||||
if stream_opt.is_some() {
|
||||
if (dac_passthrough
|
||||
|| using_alsa_direct
|
||||
|| using_coreaudio_exclusive)
|
||||
// CoreAudio rate-mismatch case already
|
||||
// logged at warn level by
|
||||
// `evaluate_stream_recreate`.
|
||||
if coreaudio_shared_rate_mismatch.is_none()
|
||||
&& (dac_passthrough
|
||||
|| using_alsa_direct
|
||||
|| using_coreaudio_exclusive)
|
||||
&& format_changed
|
||||
{
|
||||
let mode = if using_coreaudio_exclusive {
|
||||
@@ -1863,8 +2106,6 @@ impl Player {
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
*stream_opt = Some(stream);
|
||||
*current_sample_rate = Some(sample_rate);
|
||||
*current_channels = Some(channels);
|
||||
thread_state.set_stream_error(false);
|
||||
log::info!(
|
||||
"Streaming audio stream ready at {}Hz",
|
||||
@@ -1884,6 +2125,11 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep decoded source format current even when
|
||||
// macOS shared mode reuses the same output stream.
|
||||
*current_track_sample_rate = Some(sample_rate);
|
||||
*current_track_channels = Some(channels);
|
||||
|
||||
let Some(ref stream) = *stream_opt else {
|
||||
log::error!(
|
||||
"Audio thread: no audio device available for streaming"
|
||||
@@ -1935,12 +2181,44 @@ impl Player {
|
||||
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
|
||||
apply_engine_volume(&stream_opt, &engine, volume);
|
||||
|
||||
// Wait for minimum buffer before starting playback
|
||||
// Wait for minimum buffer before starting playback.
|
||||
// When start_position_secs > 0 (session resume),
|
||||
// also wait for enough buffer to cover the resume
|
||||
// offset plus an 8s headroom — the eager pre-skip
|
||||
// below decodes-and-discards up to the offset and
|
||||
// needs the bytes available without blocking the
|
||||
// audio device on the first pull.
|
||||
log::info!("Streaming: waiting for initial buffer...");
|
||||
let start_wait = Instant::now();
|
||||
let max_wait = Duration::from_secs(30);
|
||||
let max_wait = Duration::from_secs(60);
|
||||
|
||||
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
|
||||
let bytes_per_sec_estimate: u64 = if duration_secs > 0
|
||||
&& content_length > 0
|
||||
{
|
||||
content_length / duration_secs
|
||||
} else {
|
||||
200_000
|
||||
};
|
||||
let resume_buffer_target: u64 = if start_position_secs > 0 {
|
||||
bytes_per_sec_estimate
|
||||
.saturating_mul(start_position_secs.saturating_add(8))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let buffer_sufficient = |src: &Arc<BufferedMediaSource>| -> bool {
|
||||
if !src.has_min_buffer() {
|
||||
return false;
|
||||
}
|
||||
if resume_buffer_target == 0 {
|
||||
return true;
|
||||
}
|
||||
(src.buffer_size() as u64) >= resume_buffer_target
|
||||
};
|
||||
|
||||
while !buffer_sufficient(&source)
|
||||
&& start_wait.elapsed() < max_wait
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
@@ -1948,11 +2226,20 @@ impl Player {
|
||||
log::error!("Streaming: timeout waiting for initial buffer");
|
||||
return;
|
||||
}
|
||||
if resume_buffer_target > 0
|
||||
&& (source.buffer_size() as u64) < resume_buffer_target
|
||||
{
|
||||
log::warn!(
|
||||
"Streaming: timed out waiting for resume buffer (got {} bytes, wanted {}); pre-skip may underrun briefly",
|
||||
source.buffer_size(),
|
||||
resume_buffer_target
|
||||
);
|
||||
}
|
||||
|
||||
let buffer_wait_ms = start_wait.elapsed().as_millis();
|
||||
log::info!(
|
||||
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
|
||||
buffer_wait_ms
|
||||
"Streaming: buffer ready in {}ms ({} bytes, target {}), creating incremental decoder...",
|
||||
buffer_wait_ms, source.buffer_size(), resume_buffer_target
|
||||
);
|
||||
|
||||
// Create incremental streaming source - this starts playback IMMEDIATELY
|
||||
@@ -2030,8 +2317,40 @@ impl Player {
|
||||
thread_state.set_normalization_gain(normalization);
|
||||
|
||||
// Box the incremental source to match the expected type
|
||||
let source_to_play: Box<dyn Source<Item = f32> + Send> =
|
||||
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
|
||||
Box::new(incremental_source);
|
||||
|
||||
// Eager pre-skip for session resume. Decode and
|
||||
// discard samples here so the engine's first pull
|
||||
// doesn't have to do the work synchronously, which
|
||||
// would underrun the audio device for multi-second
|
||||
// offsets. The buffer wait above guarantees
|
||||
// there's enough downloaded data to feed this loop.
|
||||
if start_position_secs > 0 {
|
||||
let target_samples: u64 = (start_position_secs)
|
||||
.saturating_mul(actual_sr as u64)
|
||||
.saturating_mul(actual_ch as u64);
|
||||
let skip_start = Instant::now();
|
||||
let mut skipped: u64 = 0;
|
||||
while skipped < target_samples {
|
||||
if source_to_play.next().is_none() {
|
||||
log::warn!(
|
||||
"Resume: source ended before reaching {}s (pre-skipped {} samples)",
|
||||
start_position_secs,
|
||||
skipped
|
||||
);
|
||||
break;
|
||||
}
|
||||
skipped += 1;
|
||||
}
|
||||
log::info!(
|
||||
"Resume: pre-skipped {} samples ({}s) in {}ms",
|
||||
skipped,
|
||||
start_position_secs,
|
||||
skip_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap source with diagnostic, normalization, and visualizer
|
||||
let source_to_play = wrap_source(
|
||||
source_to_play,
|
||||
@@ -2046,16 +2365,19 @@ impl Player {
|
||||
}
|
||||
|
||||
thread_state.is_playing.store(true, Ordering::SeqCst);
|
||||
thread_state.position.store(0, Ordering::SeqCst);
|
||||
thread_state
|
||||
.position
|
||||
.store(start_position_secs, Ordering::SeqCst);
|
||||
thread_state
|
||||
.current_track_id
|
||||
.store(track_id, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
thread_state.start_playback_timer(start_position_secs);
|
||||
|
||||
*current_engine = Some(engine);
|
||||
log::info!(
|
||||
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
|
||||
start_wait.elapsed().as_millis()
|
||||
"Audio thread: streaming playback STARTED in {}ms at {}s (incremental decode active)",
|
||||
start_wait.elapsed().as_millis(),
|
||||
start_position_secs
|
||||
);
|
||||
}
|
||||
AudioCommand::Pause => {
|
||||
@@ -2109,8 +2431,8 @@ impl Player {
|
||||
|
||||
if stream_opt.is_none() {
|
||||
// Use last known sample rate/channels to maintain DAC passthrough
|
||||
let sr = current_sample_rate.unwrap_or(48000);
|
||||
let ch = current_channels.unwrap_or(2);
|
||||
let sr = current_track_sample_rate.unwrap_or(48000);
|
||||
let ch = current_track_channels.unwrap_or(2);
|
||||
log::info!(
|
||||
"Resume: reinitializing stream at {}Hz/{}ch",
|
||||
sr,
|
||||
@@ -2496,8 +2818,8 @@ impl Player {
|
||||
|
||||
*current_device_name = new_device;
|
||||
// Use last known sample rate/channels to maintain DAC passthrough
|
||||
let sr = current_sample_rate.unwrap_or(48000);
|
||||
let ch = current_channels.unwrap_or(2);
|
||||
let sr = current_track_sample_rate.unwrap_or(48000);
|
||||
let ch = current_track_channels.unwrap_or(2);
|
||||
log::info!("ReinitDevice: reinitializing at {}Hz/{}ch", sr, ch);
|
||||
*stream_opt = init_device(current_device_name, &thread_state, sr, ch);
|
||||
|
||||
@@ -2537,7 +2859,7 @@ impl Player {
|
||||
|
||||
// Verify format compatibility (same sample rate and channels)
|
||||
if let (Some(cur_sr), Some(cur_ch)) =
|
||||
(*current_sample_rate, *current_channels)
|
||||
(*current_track_sample_rate, *current_track_channels)
|
||||
{
|
||||
if sample_rate != cur_sr || channels != cur_ch {
|
||||
log::info!(
|
||||
@@ -2653,8 +2975,8 @@ impl Player {
|
||||
&mut current_device_name,
|
||||
&mut consecutive_sink_failures,
|
||||
&mut pause_suspend_deadline,
|
||||
&mut current_sample_rate,
|
||||
&mut current_channels,
|
||||
&mut current_track_sample_rate,
|
||||
&mut current_track_channels,
|
||||
&mut current_normalization_gain,
|
||||
&mut current_gain_atomic,
|
||||
&mut gapless_pending,
|
||||
@@ -2899,8 +3221,8 @@ impl Player {
|
||||
&mut current_device_name,
|
||||
&mut consecutive_sink_failures,
|
||||
&mut pause_suspend_deadline,
|
||||
&mut current_sample_rate,
|
||||
&mut current_channels,
|
||||
&mut current_track_sample_rate,
|
||||
&mut current_track_channels,
|
||||
&mut current_normalization_gain,
|
||||
&mut current_gain_atomic,
|
||||
&mut gapless_pending,
|
||||
@@ -2927,8 +3249,8 @@ impl Player {
|
||||
&mut current_device_name,
|
||||
&mut consecutive_sink_failures,
|
||||
&mut pause_suspend_deadline,
|
||||
&mut current_sample_rate,
|
||||
&mut current_channels,
|
||||
&mut current_track_sample_rate,
|
||||
&mut current_track_channels,
|
||||
&mut current_normalization_gain,
|
||||
&mut current_gain_atomic,
|
||||
&mut gapless_pending,
|
||||
@@ -3065,8 +3387,11 @@ impl Player {
|
||||
})
|
||||
}
|
||||
|
||||
/// Play from streaming source (starts playback before full download)
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
/// Play from streaming source (starts playback before full download).
|
||||
/// Returns the BufferWriter so caller can push data as it downloads.
|
||||
/// `start_position_secs` > 0 turns this into a session-resume play
|
||||
/// (#315): the audio thread waits for enough buffer to cover the
|
||||
/// offset and pre-skips decoder output up to that point.
|
||||
pub fn play_streaming(
|
||||
&self,
|
||||
track_id: u64,
|
||||
@@ -3075,14 +3400,16 @@ impl Player {
|
||||
content_length: u64,
|
||||
buffer_seconds: u8,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
|
||||
// Use StreamingConfig::from_seconds for proper buffer sizing
|
||||
@@ -3098,6 +3425,8 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
@@ -3108,8 +3437,8 @@ impl Player {
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Play from streaming source with dynamic buffer based on measured speed
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
/// Play from streaming source with dynamic buffer based on measured speed.
|
||||
/// `start_position_secs` > 0 signals session resume (see `play_streaming`).
|
||||
pub fn play_streaming_dynamic(
|
||||
&self,
|
||||
track_id: u64,
|
||||
@@ -3119,16 +3448,18 @@ impl Player {
|
||||
content_length: u64,
|
||||
speed_mbps: f64,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
|
||||
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
content_length as f64 / (1024.0 * 1024.0),
|
||||
speed_mbps,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
|
||||
// Update shared state with actual stream quality
|
||||
@@ -3147,6 +3478,8 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
@@ -3311,3 +3644,80 @@ pub struct PlaybackState {
|
||||
pub track_id: u64,
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compute_needs_new_stream;
|
||||
|
||||
#[test]
|
||||
fn no_stream_always_needs_new() {
|
||||
// Without an existing stream there is nothing to reuse — every
|
||||
// other flag is irrelevant.
|
||||
assert!(compute_needs_new_stream(
|
||||
false, false, false, false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_format_on_default_backend_reuses_stream() {
|
||||
// Default rodio backend resamples internally, so an unchanged
|
||||
// decoded format on an existing stream needs no rebuild.
|
||||
assert!(!compute_needs_new_stream(
|
||||
true, false, false, false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_change_alone_does_not_force_rebuild_on_default_backend() {
|
||||
// Rodio handles sample-rate conversion; a format change without a
|
||||
// bit-perfect backend does not require a new stream.
|
||||
assert!(!compute_needs_new_stream(
|
||||
true, true, false, false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_change_with_dac_passthrough_rebuilds() {
|
||||
assert!(compute_needs_new_stream(
|
||||
true, true, true, false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_change_with_alsa_direct_rebuilds() {
|
||||
assert!(compute_needs_new_stream(
|
||||
true, true, false, true, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_change_with_coreaudio_exclusive_rebuilds() {
|
||||
assert!(compute_needs_new_stream(
|
||||
true, true, false, false, true, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_perfect_backends_without_format_change_reuse_stream() {
|
||||
// Bit-perfect flags only force a rebuild *together with* a format
|
||||
// change. On their own they should not.
|
||||
assert!(!compute_needs_new_stream(
|
||||
true, false, true, false, false, false
|
||||
));
|
||||
assert!(!compute_needs_new_stream(
|
||||
true, false, false, true, false, false
|
||||
));
|
||||
assert!(!compute_needs_new_stream(
|
||||
true, false, false, false, true, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coreaudio_shared_rate_mismatch_rebuilds_regardless_of_format_change() {
|
||||
// The CoreAudio shared-mode rate-drift case has nothing to do with
|
||||
// track format; it must rebuild whenever detected.
|
||||
assert!(compute_needs_new_stream(
|
||||
true, false, false, false, false, true
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1284,13 +1284,37 @@ impl QobuzClient {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fetch full Track objects for a batch of track IDs (max 50 per call).
|
||||
/// Uses the `track/getList` endpoint.
|
||||
///
|
||||
/// Tries multiple API call strategies:
|
||||
/// POST to track/getList with JSON body {"tracks_id": [...]}
|
||||
/// Returns full Track objects for the given IDs (max 50 per call).
|
||||
/// Fetch full Track objects for a batch of track IDs.
|
||||
/// Uses the `track/getList` endpoint, which caps at 50 IDs per call,
|
||||
/// so larger inputs are split into 50-ID windows and fetched serially.
|
||||
/// Input order is preserved in the returned vector.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>> {
|
||||
const MAX_PER_CALL: usize = 50;
|
||||
|
||||
if track_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if track_ids.len() <= MAX_PER_CALL {
|
||||
return self.get_tracks_batch_chunk(track_ids).await;
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[API] get_tracks_batch chunking {} IDs into {}-windows",
|
||||
track_ids.len(),
|
||||
MAX_PER_CALL
|
||||
);
|
||||
let mut all = Vec::with_capacity(track_ids.len());
|
||||
for chunk in track_ids.chunks(MAX_PER_CALL) {
|
||||
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
|
||||
all.append(&mut tracks);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// Single `track/getList` POST. Caller is responsible for keeping
|
||||
/// `track_ids.len() <= 50` — `get_tracks_batch` handles that.
|
||||
async fn get_tracks_batch_chunk(&self, track_ids: &[u64]) -> Result<Vec<Track>> {
|
||||
let url = endpoints::build_url(paths::TRACK_GET_LIST);
|
||||
let headers = self.api_headers().await?;
|
||||
let timestamp = get_timestamp();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
c747c72e chore(release): bump version to 1.2.13 "400+ stars"
|
||||
bfcd7690 fix(cast): heartbeat the Chromecast every 5s to keep the connection alive
|
||||
bf734546 Merge bugfix/pre-1.2.13: graphics recommendation cleanup, faster track resume
|
||||
3abb7f66 fix(playback): gate within-track resume on cache, drop slow seek path
|
||||
3d29cf13 fix(graphics): make recommendation advisory-only, GPU-aware DMA-BUF help
|
||||
29b766ae fix(graphics): recommend CPU for NVIDIA-only, allow DMA-BUF on hybrid
|
||||
f02e1925 Merge branch 'pre-release' into bugfix/pre-1.2.13
|
||||
420fd4d2 Merge refactor/qbz-app-boot-watchdog: boot watchdog
|
||||
1a5dbe07 refactor(qbz-app): extract boot watchdog decision
|
||||
fbf3a5a1 Merge refactor/qbz-app-graphics-autoconfig: graphics autoconfig
|
||||
3c963cda refactor(qbz-app): extract graphics autoconfig
|
||||
f457401d Merge refactor/qbz-app-graphics-detection: graphics detection
|
||||
dceeacb5 refactor(tauri): use qbz-app graphics detection
|
||||
2fc9270c refactor(qbz-app): add graphics detection
|
||||
2beb83cb Merge refactor/qbz-app-user-data-paths: user data paths
|
||||
b65feee5 refactor(tauri): use qbz-app user data paths
|
||||
a497d61b refactor(qbz-app): add user data paths
|
||||
e4ec5cd3 Merge refactor/qbz-app-developer-settings: developer settings store
|
||||
be500956 refactor(qbz-app): add developer settings store
|
||||
1fe0ac35 Merge refactor/qbz-audio-settings-foundation: audio settings store
|
||||
87e426ab refactor(tauri): use qbz-audio settings
|
||||
f85df353 refactor(qbz-audio): align audio settings store
|
||||
c4a0a52a Merge refactor/qbz-app-graphics-settings: graphics settings store
|
||||
441bcc5f refactor(tauri): use qbz-app graphics settings
|
||||
bcdbc699 refactor(qbz-app): add graphics settings store
|
||||
88432500 Merge refactor/qbz-app-image-cache-settings: image cache settings store
|
||||
4d1feec7 refactor(tauri): use qbz-app image cache settings
|
||||
f8f8bb88 refactor(qbz-app): add image cache settings store
|
||||
62cd6ae1 Merge refactor/qbz-app-download-settings: download settings store
|
||||
10b0e259 refactor(tauri): use qbz-app download settings
|
||||
26012e98 refactor(qbz-app): add download settings store
|
||||
74ce1c32 Merge refactor/qbz-app-remote-control-settings: remote control settings store
|
||||
faca8376 chore(tauri): update qbz-app lock dependencies
|
||||
f527186c refactor(tauri): use qbz-app remote control settings
|
||||
2e438318 refactor(qbz-app): add remote control settings store
|
||||
ebe03193 Merge refactor/qbz-app-ui-preferences-foundation: qbz-app UI preference stores
|
||||
7f8e3533 Merge bugfix/pre-1.2.13: release watch filter, album tooltip, copyable settings text
|
||||
0b83c5a0 fix(css): keep pre, code, kbd, samp selectable globally
|
||||
29e70de5 feat(discovery-v2): tooltip with full album title on AlbumCardLite
|
||||
3903086f fix(release-watch): drop upcoming and non-streamable albums
|
||||
04d5b451 docs(qbz-app): keep app comments frontend agnostic
|
||||
2d7639c6 refactor(tauri): use qbz-app ui preference stores
|
||||
d1082317 refactor(qbz-app): add ui preference stores
|
||||
2b7b2312 Merge bugfix/pre-1.2.13: offline-tolerant CoreBridge + Continue Listening hydration
|
||||
db390724 fix(discovery-v2): hydrate Continue Listening tracks with full metadata and context
|
||||
a40b1b6c fix(core): make QbzCore::init tolerant to offline startup
|
||||
7f38dbcc refactor(tauri): use qbz-app preference stores
|
||||
61d5715a refactor(qbz-app): add preference stores
|
||||
d48a1019 chore(deps): bump svelte to 5.55.7
|
||||
917b2a8b fix(ui): avoid t shadow in mixtape detail
|
||||
dac1b3a7 refactor(tauri): use qbz-app settings stores
|
||||
800447d3 refactor(qbz-app): add settings stores
|
||||
dc74db64 test(qbz-app): cover runtime state transitions
|
||||
0877f4a5 refactor(tauri): use qbz-app runtime types
|
||||
d16ef003 refactor(qbz-app): move runtime core
|
||||
4f008cd2 refactor(qbz-app): scaffold app crate
|
||||
1bb8d53d feat(graphics): NVIDIA Wayland compat toggle + watchdog fallback to CPU
|
||||
db45051c chore(security): patch file-type 16.5.4 ASF infinite-loop (GHSA, alert 56)
|
||||
2832213d Merge pull request #424 from vicrodh/dependabot/npm_and_yarn/pre-release/types/node-25.7.0
|
||||
8b176a18 chore(deps): bump discord-rich-presence 0.2.5 → 1.1.0 with API adjustment
|
||||
47c9c618 Merge pull request #432 from vicrodh/dependabot/cargo/src-tauri/pre-release/tower-http-0.6.8
|
||||
875ad452 chore(deps-dev): bump @types/node from 22.19.18 to 25.7.0
|
||||
7ee53599 Merge pull request #429 from vicrodh/dependabot/npm_and_yarn/pre-release/vite-8.0.12
|
||||
b165ddb0 Merge pull request #430 from vicrodh/dependabot/npm_and_yarn/pre-release/sveltejs/vite-plugin-svelte-7.1.2
|
||||
69fbb1df Merge pull request #426 from vicrodh/dependabot/npm_and_yarn/pre-release/tauri-apps/cli-2.11.1
|
||||
11854a99 Merge pull request #427 from vicrodh/dependabot/npm_and_yarn/pre-release/tauri-apps/plugin-opener-2.5.4
|
||||
0eae6a56 Merge pull request #425 from vicrodh/dependabot/cargo/src-tauri/pre-release/tokio-1.52.3
|
||||
dc6725b1 Merge pull request #431 from vicrodh/dependabot/cargo/src-tauri/pre-release/filetime-0.2.29
|
||||
91bfb056 Merge pull request #433 from vicrodh/dependabot/cargo/src-tauri/pre-release/tauri-plugin-single-instance-2.4.2
|
||||
921f8e1a fix(session): resume Qobuz streams at saved position without losing seek (#315)
|
||||
b2960e80 feat(graphics): smart GPU preference with boot watchdog and crash recovery
|
||||
77754e16 feat(discovery): redesign Radio Station Lite cards to match iOS reference
|
||||
cd035a34 feat(graphics): detect hybrid NVIDIA+iGPU and surface GPU + DE in Diagnostics
|
||||
58807448 chore(deps): bump tauri-plugin-single-instance in /src-tauri
|
||||
7ce112ba chore(deps): bump tower-http from 0.5.2 to 0.6.8 in /src-tauri
|
||||
620a393b chore(deps): bump filetime from 0.2.27 to 0.2.29 in /src-tauri
|
||||
b33ce39f chore(deps-dev): bump @sveltejs/vite-plugin-svelte from 7.0.0 to 7.1.2
|
||||
9c83e116 chore(deps-dev): bump vite from 8.0.10 to 8.0.12
|
||||
d53844c2 chore(deps): bump @tauri-apps/plugin-opener from 2.5.3 to 2.5.4
|
||||
7e7f3e05 chore(deps-dev): bump @tauri-apps/cli from 2.10.1 to 2.11.1
|
||||
060766e1 chore(deps): bump tokio from 1.52.2 to 1.52.3 in /src-tauri
|
||||
b06580af feat(local-library): unify album quality badge + add source icon across surfaces
|
||||
b5918e1c fix(library): recover Plex album format + bit-depth when codec is missing
|
||||
0f5bff3a refactor(immersive): extract shared ImmersiveSongCard from 9 panel copies
|
||||
5bc5122d fix(ephemeral): replace 1 << 48 with 2 ** 48 for ephemeral track id floor
|
||||
7bb10472 fix(linebed): size artwork off the live measured column height
|
||||
c715475d fix(linebed): use align-self: stretch on artwork instead of percentage height
|
||||
783f7e41 fix(linebed): scale artwork to match track-meta column height
|
||||
cb21653a fix(quality-badge): tighten framed-badge right gutter from 8px to 4px
|
||||
72a1cbab fix(quality-badge): match framed-badge right gutter to Hi-Res SVG transparent space
|
||||
978d5d68 feat(discovery): unified quality badge on album cards, Qobuz-style layout
|
||||
d0349119 feat(settings): add dismiss + manual re-detect to the graphics recommendation banner
|
||||
700d5ef2 feat(settings): surface graphics recommendation banner in the Graphics tab
|
||||
11721594 refactor(settings): split Graphics out of Appearance into its own section
|
||||
dc96c5ac feat(graphics): flip DMA-BUF default to opt-in
|
||||
3061de21 chore(scripts): add dev-igpu-mode.sh for iGPU-only paint path testing
|
||||
a2ab509f fix(toast): keep error text selectable for debug copy-paste
|
||||
48b83d14 Merge pull request #423 from afonsojramos/rework-titlebar-drag-region
|
||||
032021de feat(lyrics): drag region on sidebar header and selectable lyrics panel
|
||||
e54c5de0 feat(views): drag regions on top-bars across remaining views
|
||||
47f3a9f3 feat(views): drag regions on home, library, and favorites top-bars
|
||||
897f6ac8 feat(detail-views): drag regions and selectable metadata blocks
|
||||
eb57c70e feat(modals): mark info modals and legal notice selectable
|
||||
418bf371 refactor(styles): drop now-redundant local user-select: none
|
||||
a34e4185 feat(sidebar): drag window from aside top padding band on macos
|
||||
69d7ee02 refactor(layout): remove macos overlay drag and main-content padding
|
||||
5610861e refactor(titlebar): drag on spacer divs not full bar
|
||||
c1c151ee refactor(css): user-select none on body with .selectable opt-in
|
||||
f12b4e29 Merge branch 'feature/queue-persistence-315' into pre-release
|
||||
d4745f84 feat(session): restore local on startup, let QConnect overwrite when it lands (#315)
|
||||
267440f5 feat(session): round-trip streamable, parental_warning, source_item_id_hint (#315)
|
||||
7284568c feat(session): persist queue on every mutation, not just track-change (#315)
|
||||
6f8f6f43 Merge branch 'bugfix/qconnect-batch-pagination-419' into pre-release
|
||||
6f05fd30 fix(qobuz): chunk get_tracks_batch into 50-ID windows (#419)
|
||||
93f51d26 Merge pull request #422 from afonsojramos/document-stream-error-clear
|
||||
8445f845 Merge pull request #421 from afonsojramos/close-other-filters-on-open
|
||||
5865c166 docs(player): explain set_stream_error(false) message swallow
|
||||
0daca399 Merge pull request #420 from afonsojramos/refactor/internal/macos-shared-mode-followup
|
||||
9231ea2b fix(mixtapes): replace click-swallowing backdrop with document click-outside
|
||||
b3b0fce5 fix(purchases): replace click-swallowing backdrop with document click-outside
|
||||
3382a36e fix(mixtape-collection): replace click-swallowing backdrop with document click-outside
|
||||
29598747 fix(collections): replace click-swallowing backdrop with document click-outside
|
||||
ff11dea8 fix(label): drop tracks context menu backdrop in favor of click-outside listener
|
||||
ddf0ed41 fix(artist-detail): consolidate section sort menus and drop tracks context backdrop
|
||||
126dff12 fix(library): consolidate toolbar dropdowns into single openMenu state
|
||||
7fccf9bd fix(favorites): consolidate toolbar and section dropdowns into single openMenu state
|
||||
fc56fb4b feat(genre-filter): make isOpen bindable for parent dropdown coordination
|
||||
65320134 perf(lyrics): drop karaoke gradient in non-immersive CPU mode
|
||||
427e1b5b Merge branch 'feature/multiselect-completion-381' into pre-release
|
||||
58a80b57 feat(favorites): select-all checkbox for track multiselect
|
||||
2e2c7549 feat(local-library): finalize #381 multiselect with cross-context apagador
|
||||
000f7c10 Merge pull request #418 from afonsojramos/fix-macos-lyrics-text-color
|
||||
84dcd7eb perf(+page): guard lyrics state writes against subscriber churn
|
||||
640dead3 fix(lyricsStore): claim rAF handle pre-tick and notify on every change
|
||||
6166584e perf(playerStore): extrapolate audio time between backend position events
|
||||
64511c4f feat(lyrics): segment active-line gradient by visual line via pretext
|
||||
57f83079 feat(ui): toast on audio:init-failed event
|
||||
a2518490 i18n(toast): add audioInitFailed translation key
|
||||
6d91ec0e feat(audio): emit audio:init-failed Tauri event on backend init failure
|
||||
04e17cc4 feat(player): expose user-readable stream_error_message channel
|
||||
a7b40bf6 perf(player): cache CoreAudio nominal rate query for 750ms
|
||||
83fb109b Merge pull request #416 from afonsojramos/debug-local-update-issue
|
||||
da3c1e86 Merge pull request #410 from Vudgekek/fix/internal/macos-shared-mode
|
||||
f5e00e0a Merge branch 'feature/cpu-mode-lite-panels' into pre-release
|
||||
4b290308 feat(immersive): CPU-mode lite path + per-panel toggles + warning modal + badge unification
|
||||
f528129f Merge branch 'feature/412-track-list-album-covers' into pre-release
|
||||
145831fe feat(local-library): opt-in album artwork in tracks/folders list (#412)
|
||||
9abc82b8 Merge bugfix/411-album-title-prefer-metadata into pre-release
|
||||
bdf6ca30 fix(local-library): prefer album metadata over folder-derived title (#411)
|
||||
cd799211 Merge branch 'feature/discovery-v2-scaffold' into pre-release
|
||||
185d5abd fix(search): card width + quick-menu portal
|
||||
5045096e fix(album-card-lite): contain: paint on cover-wrap forces clip
|
||||
de30ede4 fix(album-card-lite): bound cover img to parent + delete dead legacy views
|
||||
234af9d6 fix(search): align-items: flex-start + fixed wrapper width
|
||||
36b6a2ee feat(purchases): migrate AlbumCard → AlbumCardLibraryLite
|
||||
d5d8d29d feat(favorites): migrate AlbumCard → AlbumCardLite
|
||||
d02e5d38 feat(views): migrate Award + AlbumDetail + ArtistsByLocation to AlbumCardLite
|
||||
79cee870 feat(label): migrate AlbumCard → AlbumCardLite
|
||||
550d75bb feat(search): migrate AlbumCard → AlbumCardLite
|
||||
10e32aa0 feat(artist-detail): migrate AlbumCard → AlbumCardLite
|
||||
447344c8 fix(local-library): plex id double-prefix + scrim z-index over buttons
|
||||
9f417999 perf(plex): request 220x220 transcoded artwork for thumbnail contexts
|
||||
4bc1b0fa fix(local-library): hover ring above img, scrollbar height + style match
|
||||
87d16926 fix(local-library): visible scrollbars + list as default view
|
||||
1aea33cf feat(local-library): Plex consolidation in chunked albums page query
|
||||
2e58c889 fix(library-card): hover indicator uses inset box-shadow (not clipped)
|
||||
9c2ce894 style(legacy-grid): match Discovery 32px gap in VirtualizedAlbumList
|
||||
478582e2 style(library-card): hover outline + 220px card width to match Home
|
||||
82014205 style(grid-pool): bump card gaps to 32px to match Home visual rhythm
|
||||
1a5131ba style(library-card): drop solid theme buttons for white outline + shadow
|
||||
55a139da style(library-card): align action buttons at bottom 44px (matches Home Lite)
|
||||
694423c7 perf(library-card): drop hover entirely, theme-coloured persistent buttons
|
||||
36ce669c perf(grid-pool): revert transform + contain, drop bufferRows 2→1
|
||||
9003d93a perf(grid-pool): drop bufferRows 3→2 + skeleton placeholder
|
||||
f494b1e6 perf(library-card): drop full-cover hover scrim, keep floating buttons
|
||||
7d26522f perf(grid-pool): contain: layout paint on each slot
|
||||
a3f1426c perf(grid-pool): position slots with transform instead of top/left
|
||||
7508c3cd fix(local-library): fall back to legacy path when Plex is enabled
|
||||
8f2cd965 feat(local-library): server-paginated chunked store for Albums grid
|
||||
810a41b4 feat(local-library): recycling-pool virtualizer for Albums grid
|
||||
3eb62a50 feat(discovery-v2): AlbumCardLibraryLite + migrate LocalLibrary grid
|
||||
b2e88c47 refactor(settings): hard-disable chrome toggle without GPU, terser note
|
||||
d1ad0fc8 feat(window): disable transparent chrome when hardware acceleration is off
|
||||
884da638 chore(scripts): add dev-cpu-mode.sh for forced software rendering
|
||||
b5db0241 fix(LyricsLines): make line-text block to expand background-clip paint area
|
||||
f9a9d103 fix(LyricsLines): let EMA accept rAF-rate block measurements
|
||||
c68b1e28 refactor(modals): migrate all 22 shared-modal callers to ModalLite
|
||||
f4f8fcf9 fix(LyricsLines): use activeProgress as karaoke floor, lookahead as bonus
|
||||
4de657e2 feat(LyricsLines): JS-driven karaoke with measured block-interval and lookahead
|
||||
dcb13568 feat(discovery-v2): ModalLite — add withScrim + footerAlign + overlay backdrop
|
||||
d729c68d feat(discovery-v2): spotlight rewrite + artist follow + loading skeleton
|
||||
6d251001 refactor(LyricsLines): single-div template with class-toggle transitions
|
||||
4d426a80 fix(lyrics): snap line progress to 1 when at least 99% through
|
||||
959f6568 refactor(lyrics): notify listeners on every progress change
|
||||
13d1d691 perf(lyrics): drive active-line updates via requestAnimationFrame
|
||||
a5100a32 perf(page): memoize lyrics sidebar lines projection
|
||||
9aacf1d7 feat(lyrics): parse LRC gap markers and use endMs for line bounds
|
||||
556a345c feat(discovery-v2): RadioCardLite + Spotlight radio + Mixes resize + i18n fixes
|
||||
5a8d3a20 feat(discovery-v2): For-You-exclusive sections (Spotlight, Rediscover, etc.)
|
||||
fdd8de48 feat(discovery-v2): per-tab section preferences (Home / Editor's / For You)
|
||||
704392d3 feat(runtime): conditional flicker fix gated by HW-accel state
|
||||
da1b29b2 feat(graphics): default hardware_acceleration to 1 (was 0)
|
||||
e9504efa perf(modals): gate 8 always-mounted modals with {#if}, drop blur shadow
|
||||
e3cdc97a feat(discovery-v2): ModalLite — opacity-only animation, no scale transform
|
||||
7696b1ee fix(discovery-v2): prioritize Album of the Week over other ribbons
|
||||
86195fcb feat(discovery-v2): rephrase queue + offline actions, reorder
|
||||
35520e93 feat(discovery-v2): playlist hover overlay + quick menu (refactored, not ported)
|
||||
87423e92 feat(discovery-v2): ribbon to bottom, meta to top-left, actions raised
|
||||
fdeb544c fix(lyrics): correct active-line color on macOS WebKit
|
||||
2baa2e28 feat(discovery-v2): responsive Top Albums grid + Hi-Res only badge + meta tweak
|
||||
9285a380 feat(discovery-v2): Top Albums rank counter + hover-play + kebab menu
|
||||
b4e08ac5 feat(discovery-v2): wire add-to-playlist + songlink share to album quick menu
|
||||
5d33db2d feat(discovery-v2): wire kebab to minimal action quick-menu
|
||||
d9c9ec11 feat(discovery-v2): genre/year meta + slide-up actions + bottom positioning
|
||||
871e550d feat(discovery-v2): Qobuz-style hover overlay with 3 action buttons
|
||||
c1510201 feat(discovery-v2): cheap hover overlay on album cards
|
||||
f9479ac8 feat(discovery-v2): Popular albums as compact 4×3 grid
|
||||
3e4e04a4 feat(discovery-v2): playlist cover with object-fit:contain + dominant color bg
|
||||
0c8cde75 feat(discovery-v2): compact track-row grid for "Continue Listening"
|
||||
42f59033 feat(discovery-v2): quality badges + press / award ribbons on album cards
|
||||
b9a902a8 chore(discovery-v2): per-section item count 6 → 18
|
||||
29a6f386 perf(zoom): only register wheel listener while Ctrl/Meta is held
|
||||
67118b21 perf(back-to-top): keep button mounted, swap mount/unmount for opacity
|
||||
e477d0ce perf(modal): drop backdrop-filter blur on overlay
|
||||
3fb9e554 feat(discovery-v2): customize modal + gear icon + bigger gaps
|
||||
efa7b2b1 feat(discovery-v2): section preferences (default 7 enabled)
|
||||
5b608e1d feat(discovery-v2): bump card size 180px → 220px (matches image.small)
|
||||
fb987366 feat(discovery-v2): wire ideal-discography section
|
||||
5ae5087f feat(discovery-v2): drag-to-paginate, fade transition, bump to 20 items
|
||||
fa78f6af feat(discovery-v2): route artwork through cachedSrc
|
||||
1ad08f2d feat(discovery-v2): pagination by DOM slice (no scroll containers)
|
||||
368f0b42 Revert "feat(discovery-v2): horizontal-scroll sections (Cider pattern)"
|
||||
612384ae feat(discovery-v2): horizontal-scroll sections (Cider pattern)
|
||||
f6ac95ec feat(discovery-v2): highlight currently-playing track in continue listening
|
||||
fcdd6021 feat(discovery-v2): wire genre filter button into toolbar
|
||||
5c30ced8 feat(discovery-v2): gate scroll-area by active tab
|
||||
e4040044 feat(discovery-v2): wire personalized + playlist + artist sections
|
||||
aa526fb8 feat(discovery-v2): wire editorial sections from discover-index
|
||||
b6d2c121 feat(discovery-v2): first section — new releases
|
||||
373018a1 feat(discovery-v2): scaffold clean-room rebuild of home view
|
||||
03af983a chore(scripts): add measure-cpu-window for structured perf sampling
|
||||
3c6f5f90 perf(now-playing-bar): drop invisible backdrop-filter
|
||||
efcdad55 perf(cached-image): drop forced compositing layer on every artwork
|
||||
82b3217c perf(playlist-card): drop blur(30px) on artwork backdrop
|
||||
9aa10e95 perf(album-card): drop hover marquee, decorative blurs, add content-visibility
|
||||
6029e93f Revert "Merge pull request #407 from afonsojramos/fix-glued-top-animation"
|
||||
43a35647 Merge branch 'bugfix/unavailable-track-row-dim' into pre-release
|
||||
03d1142f fix(ui): dim unavailable tracks across all track list views
|
||||
4fd85b5b fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater
|
||||
1edde8a2 fix(audio): rephrase CoreAudio rate-mismatch errors for end users
|
||||
9e632386 fix(audio): retry CoreAudio shared-mode open on rate mismatch
|
||||
c408ea73 test(player): cover compute_needs_new_stream decision rule
|
||||
ac60113c refactor(player): extract StreamRecreateDecision helper
|
||||
3b34a734 refactor(player): rename current_sample_rate to current_track_sample_rate
|
||||
7bad704e fix(audio): fix macOS Shared Mode playback from changing speed
|
||||
d44ee7c0 build(flake): bump qbz to v1.2.12
|
||||
@@ -0,0 +1,53 @@
|
||||
# 1.2.13 — 400+ stars
|
||||
|
||||
qbz just passed **400 stars on GitHub**. Thanks to everyone who has tried the player, filed an issue, or sent a patch — this release is named for that milestone.
|
||||
|
||||
There are no headline features this time. 1.2.13 is a polish and stability release: most of the work went into **macOS**, where @afonsojramos and @Vudgekek tightened audio playback, the title bar, and the updater.
|
||||
|
||||
The rest is a steady round of bug fixes, a new graphics auto-config helper for Linux, and groundwork under the hood.
|
||||
|
||||
---
|
||||
|
||||
## macOS
|
||||
|
||||
- **Shared Mode playback** — fixed a speed drift, retries CoreAudio on a sample-rate mismatch, and rate-mismatch errors are now phrased for end users (@Vudgekek PR #410, @afonsojramos PR #420)
|
||||
- **Title bar & window dragging** — drag regions reworked across the sidebar and view top-bars; the legacy macOS overlay drag region is gone (@afonsojramos PR #423)
|
||||
- **Synced lyrics** — active-line color corrected under macOS WebKit
|
||||
- **Auto-updater** — `.app.tar.gz` bundles are suffixed with the architecture so the updater picks the matching build
|
||||
|
||||
---
|
||||
|
||||
## Session restore
|
||||
|
||||
- **Full queue** — the entire play queue is saved on every change and restored on next launch, not just the current track (#315)
|
||||
- **Qobuz Connect priority** — when a local session and a Qobuz Connect session both exist at startup, the Qobuz Connect state takes over
|
||||
|
||||
---
|
||||
|
||||
## Linux graphics
|
||||
|
||||
- **Graphics auto-config** — a new helper detects your GPU and recommends rendering settings; an advisory banner in Settings → Graphics shows what it suggests
|
||||
- **Boot watchdog** — if a risky graphics setting crashes the app before it paints, the next launch reverts it on its own
|
||||
- **GPU selection** — WebKit can be pinned to a specific GPU, which matters on hybrid NVIDIA + integrated laptops
|
||||
|
||||
---
|
||||
|
||||
## Fixes & polish
|
||||
|
||||
- **Chromecast** — fixed casts that connected and chimed but never played; the keep-alive heartbeat now matches Google Cast timing (#439)
|
||||
- **Favorites on Qobuz tracks** — the favorite and add-to-playlist buttons are no longer wrongly disabled on streaming tracks
|
||||
- **Menus & dropdowns** — a click outside a menu always closes it, toolbar dropdowns no longer swallow that click, and opening one filter closes the others (@afonsojramos PR #421)
|
||||
- **Plex** — album format and bit-depth are recovered when the codec tag is missing
|
||||
- **Track lists** — unavailable tracks are dimmed consistently across every list view
|
||||
- **Local library** — optional album artwork in track and folder lists (#412), and smoother scrolling on large Albums grids
|
||||
|
||||
---
|
||||
|
||||
## Under the hood
|
||||
|
||||
- **Dependency bumps** — Tauri 2.11, tokio, vite 8, and the usual dependabot round
|
||||
- **App-core groundwork** — playback, audio, and settings logic continues moving into framework-agnostic crates, with no change in behavior
|
||||
|
||||
---
|
||||
|
||||
Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.12...v1.2.13
|
||||
@@ -15,7 +15,7 @@
|
||||
# VERSION BUMP: update version, rev, and hashes
|
||||
# when tagging a new release.
|
||||
# ──────────────────────────────────────────────
|
||||
qbzVersion = "1.2.12";
|
||||
qbzVersion = "1.2.13";
|
||||
qbzRev = "v${qbzVersion}";
|
||||
srcHash = "sha256-SuEmBxwwEkINmH/cswV9Ed6FtBKP6IRrjQ7sIYMPOTg=";
|
||||
npmHash = "sha256-WjZg3xaJFOdCA0XaIFwWhd4kglUjkWei6Cw3+NiP6zs=";
|
||||
|
||||
Generated
+907
-160
File diff suppressed because it is too large.
Load diff
+8
-5
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "qbz-nix",
|
||||
"version": "1.2.12",
|
||||
"version": "1.2.13",
|
||||
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"dev": "svelte-kit sync && vite dev",
|
||||
"dev:clean-runtime": "bash ./scripts/clean-dev-runtime.sh",
|
||||
"dev:ghost-doctor": "bash ./scripts/dev-ghost-doctor.sh",
|
||||
@@ -26,6 +27,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.7",
|
||||
"@icons-pack/svelte-simple-icons": "^7.2.0",
|
||||
"@kawarp/core": "^1.1.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
@@ -42,15 +44,16 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.59.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/node": "^22.19.18",
|
||||
"@types/node": "^25.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.55.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-check": "^4.4.8",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -69,6 +69,19 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="1.2.13" date="2026-05-16">
|
||||
<description>
|
||||
<p>400+ stars: a polish and stability release led by macOS fixes, with the usual round of bug fixes and groundwork under the hood.</p>
|
||||
<ul>
|
||||
<li>macOS Shared Mode: playback no longer drifts in speed, CoreAudio retries on a sample-rate mismatch, and rate-mismatch errors are rephrased for end users (PRs by @Vudgekek and @afonsojramos)</li>
|
||||
<li>macOS title bar and window drag regions reworked; lyrics active-line color corrected on WebKit; auto-updater bundle naming fixed</li>
|
||||
<li>Session restore now brings back the full queue, not just the current track</li>
|
||||
<li>New Linux graphics auto-config: detects the GPU and recommends safe rendering settings</li>
|
||||
<li>Favorite and add-to-playlist buttons are no longer wrongly disabled on Qobuz tracks</li>
|
||||
<li>Assorted bug fixes, dropdown and menu behavior cleanup, and dependency bumps</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.2.12" date="2026-05-09">
|
||||
<description>
|
||||
<p>Hotfix: rolls back the broken titlebar-mode-selector that left v1.2.11 with no visible window chrome on Linux. Restores v1.2.10 title bar behavior (System / Custom / Hidden). All other v1.2.11 features remain.</p>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
diff --git a/node_modules/file-type/core.js b/node_modules/file-type/core.js
|
||||
index d653e66..baf9fa4 100644
|
||||
--- a/node_modules/file-type/core.js
|
||||
+++ b/node_modules/file-type/core.js
|
||||
@@ -1074,6 +1074,7 @@ async function _fromTokenizer(tokenizer) {
|
||||
await tokenizer.ignore(30);
|
||||
// Search for header should be in first 1KB of file.
|
||||
while (tokenizer.position + 24 < tokenizer.fileInfo.size) {
|
||||
+ const previousPosition = tokenizer.position;
|
||||
const header = await readHeader();
|
||||
let payload = header.size - 24;
|
||||
if (_check(header.id, [0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65])) {
|
||||
@@ -1101,6 +1102,11 @@ async function _fromTokenizer(tokenizer) {
|
||||
}
|
||||
|
||||
await tokenizer.ignore(payload);
|
||||
+
|
||||
+ // Safeguard against malformed files: break if the position did not advance.
|
||||
+ if (tokenizer.position <= previousPosition) {
|
||||
+ break;
|
||||
+ }
|
||||
}
|
||||
|
||||
// Default to ASF generic extension
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Force QBZ to run under software/CPU rendering across every layer that
|
||||
# can sneak the GPU back in. Useful for testing the degraded paint path
|
||||
# (modal mount cost, Discovery V2 perf, etc.) without touching the
|
||||
# persisted graphics_settings DB. Sister script of dev-ultra-restore.sh.
|
||||
#
|
||||
# Layers forced off / to SW:
|
||||
# QBZ_HARDWARE_ACCEL=0 — QBZ's own nuclear opt-out
|
||||
# (overrides graphics_settings DB)
|
||||
# WEBKIT_DISABLE_COMPOSITING_MODE=1 — WebKit redraws via SW compositor
|
||||
# WEBKIT_DISABLE_DMABUF_RENDERER=1 — no DMA-BUF GPU texture sharing
|
||||
# GSK_RENDERER=cairo — GTK4 picks the cairo SW renderer
|
||||
# (Vulkan / GL paths disabled)
|
||||
# LIBGL_ALWAYS_SOFTWARE=1 — Mesa GL falls back to llvmpipe
|
||||
#
|
||||
# Usage:
|
||||
# bash ./scripts/dev-cpu-mode.sh # kill stale + start tauri dev
|
||||
# bash ./scripts/dev-cpu-mode.sh --no-kill # skip process cleanup
|
||||
# bash ./scripts/dev-cpu-mode.sh --print # print the env vars and exit
|
||||
# (for sourcing into another shell)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
KILL_STALE=true
|
||||
PRINT_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-kill)
|
||||
KILL_STALE=false
|
||||
;;
|
||||
--print)
|
||||
PRINT_ONLY=true
|
||||
;;
|
||||
*)
|
||||
echo "[qbz] Unknown option: ${arg}"
|
||||
echo "Usage: bash ./scripts/dev-cpu-mode.sh [--no-kill] [--print]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
export QBZ_HARDWARE_ACCEL=0
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
export GSK_RENDERER=cairo
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
echo "[qbz] dev-cpu-mode: software-rendering env applied"
|
||||
echo "[qbz] QBZ_HARDWARE_ACCEL = ${QBZ_HARDWARE_ACCEL}"
|
||||
echo "[qbz] WEBKIT_DISABLE_COMPOSITING_MODE = ${WEBKIT_DISABLE_COMPOSITING_MODE}"
|
||||
echo "[qbz] WEBKIT_DISABLE_DMABUF_RENDERER = ${WEBKIT_DISABLE_DMABUF_RENDERER}"
|
||||
echo "[qbz] GSK_RENDERER = ${GSK_RENDERER}"
|
||||
echo "[qbz] LIBGL_ALWAYS_SOFTWARE = ${LIBGL_ALWAYS_SOFTWARE}"
|
||||
|
||||
if [[ "${PRINT_ONLY}" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${KILL_STALE}" == "true" ]]; then
|
||||
echo "[qbz] dev-cpu-mode: stopping stale dev processes..."
|
||||
pkill -f '[n]ode .*vite dev' || true
|
||||
pkill -f '[n]ode .*tauri dev' || true
|
||||
pkill -f '[c]argo run' || true
|
||||
pkill -f '[t]arget/debug/qbz' || true
|
||||
pkill -f '[t]arget/debug/qbz-nix' || true
|
||||
|
||||
for port in 1420 1421; do
|
||||
pids="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids}" ]]; then
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 0.5
|
||||
still_alive="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${still_alive}" ]]; then
|
||||
kill -9 ${still_alive} 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "[qbz] dev-cpu-mode: starting tauri dev in foreground..."
|
||||
exec npm run tauri dev
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# Force QBZ to run on the integrated GPU (Intel/AMD), bypassing the
|
||||
# discrete NVIDIA GPU. Unlike dev-cpu-mode.sh (which forces pure
|
||||
# software rendering), this keeps WebKit GPU compositing enabled so
|
||||
# we can observe how QBZ behaves with modest-but-real HW accel.
|
||||
#
|
||||
# Why this matters: the current `hardware_accel_enabled` detector is
|
||||
# binary (on / off). A user with an iGPU shouldn't be punished with
|
||||
# the lite path designed for true software rendering — but right now
|
||||
# the only way to test that intermediate tier is to actually constrain
|
||||
# the runtime to the iGPU. This script is the manual harness for that.
|
||||
#
|
||||
# Layers forced to iGPU / Mesa:
|
||||
# __EGL_VENDOR_LIBRARY_FILENAMES — pin EGL to Mesa only, skipping
|
||||
# /usr/share/glvnd/egl_vendor.d/10_nvidia.json.
|
||||
# This is the critical knob —
|
||||
# WebKitGTK prefers EGL and the
|
||||
# NVIDIA EGL ICD wins by priority
|
||||
# unless we point it elsewhere.
|
||||
# __GLX_VENDOR_LIBRARY_NAME=mesa — same idea for GLX (mostly belt
|
||||
# and braces; default already
|
||||
# resolves to Mesa+Intel on this box).
|
||||
# DRI_PRIME=0 — explicit primary GPU selection;
|
||||
# no PRIME render offload.
|
||||
# __NV_PRIME_RENDER_OFFLOAD unset — make sure we are not asking
|
||||
# nvidia-prime to bring the dGPU
|
||||
# back via offload.
|
||||
# QBZ_HARDWARE_ACCEL unset — let QBZ honor its persisted
|
||||
# graphics_settings (HW on by
|
||||
# default). We want the iGPU
|
||||
# active, not software rendering.
|
||||
# GSK_RENDERER unset — let GTK4 pick its best renderer
|
||||
# under the constrained GL/EGL.
|
||||
#
|
||||
# Layers explicitly NOT set (the opposite of dev-cpu-mode.sh):
|
||||
# WEBKIT_DISABLE_COMPOSITING_MODE — leave at default so WebKit
|
||||
# keeps GPU compositing.
|
||||
# WEBKIT_DISABLE_DMABUF_RENDERER — leave at default so DMA-BUF
|
||||
# texture sharing works on the
|
||||
# Intel/Mesa side.
|
||||
# LIBGL_ALWAYS_SOFTWARE — leave unset so Mesa runs on the
|
||||
# real Intel HW, not llvmpipe.
|
||||
#
|
||||
# Verification after launch (in a separate shell):
|
||||
# nvidia-smi --query-compute-apps=pid,used_memory --format=csv
|
||||
# → QBZ pid must NOT appear in the list.
|
||||
# nvtop / intel_gpu_top
|
||||
# → Activity on Intel iGPU when QBZ paints; idle on NVIDIA.
|
||||
#
|
||||
# Usage:
|
||||
# bash ./scripts/dev-igpu-mode.sh # kill stale + start tauri dev
|
||||
# bash ./scripts/dev-igpu-mode.sh --no-kill # skip process cleanup
|
||||
# bash ./scripts/dev-igpu-mode.sh --print # print env and exit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
KILL_STALE=true
|
||||
PRINT_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-kill)
|
||||
KILL_STALE=false
|
||||
;;
|
||||
--print)
|
||||
PRINT_ONLY=true
|
||||
;;
|
||||
*)
|
||||
echo "[qbz] Unknown option: ${arg}"
|
||||
echo "Usage: bash ./scripts/dev-igpu-mode.sh [--no-kill] [--print]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
MESA_EGL_JSON="/usr/share/glvnd/egl_vendor.d/50_mesa.json"
|
||||
if [[ ! -f "${MESA_EGL_JSON}" ]]; then
|
||||
echo "[qbz] dev-igpu-mode: ${MESA_EGL_JSON} not found — your distro packages Mesa EGL elsewhere."
|
||||
echo "[qbz] dev-igpu-mode: list the candidates with: ls /usr/share/glvnd/egl_vendor.d/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export __EGL_VENDOR_LIBRARY_FILENAMES="${MESA_EGL_JSON}"
|
||||
export __GLX_VENDOR_LIBRARY_NAME=mesa
|
||||
export DRI_PRIME=0
|
||||
unset __NV_PRIME_RENDER_OFFLOAD
|
||||
unset QBZ_HARDWARE_ACCEL
|
||||
unset GSK_RENDERER
|
||||
unset WEBKIT_DISABLE_COMPOSITING_MODE
|
||||
unset WEBKIT_DISABLE_DMABUF_RENDERER
|
||||
unset LIBGL_ALWAYS_SOFTWARE
|
||||
|
||||
echo "[qbz] dev-igpu-mode: iGPU-only env applied"
|
||||
echo "[qbz] __EGL_VENDOR_LIBRARY_FILENAMES = ${__EGL_VENDOR_LIBRARY_FILENAMES}"
|
||||
echo "[qbz] __GLX_VENDOR_LIBRARY_NAME = ${__GLX_VENDOR_LIBRARY_NAME}"
|
||||
echo "[qbz] DRI_PRIME = ${DRI_PRIME}"
|
||||
echo "[qbz] QBZ_HARDWARE_ACCEL = (unset → DB default, HW on)"
|
||||
echo "[qbz] compositing / DMA-BUF / llvmpipe = NOT forced (real iGPU paint path)"
|
||||
|
||||
if [[ "${PRINT_ONLY}" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${KILL_STALE}" == "true" ]]; then
|
||||
echo "[qbz] dev-igpu-mode: stopping stale dev processes..."
|
||||
pkill -f '[n]ode .*vite dev' || true
|
||||
pkill -f '[n]ode .*tauri dev' || true
|
||||
pkill -f '[c]argo run' || true
|
||||
pkill -f '[t]arget/debug/qbz' || true
|
||||
pkill -f '[t]arget/debug/qbz-nix' || true
|
||||
|
||||
for port in 1420 1421; do
|
||||
pids="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids}" ]]; then
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 0.5
|
||||
still_alive="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${still_alive}" ]]; then
|
||||
kill -9 ${still_alive} 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "[qbz] dev-igpu-mode: starting tauri dev in foreground..."
|
||||
exec npm run tauri dev
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# measure-cpu-window.sh — sample CPU% of QBZ + WebKit children over a window
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/measure-cpu-window.sh [label] [duration_seconds]
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/measure-cpu-window.sh blur-on # 30s sample, labelled "blur-on"
|
||||
# ./scripts/measure-cpu-window.sh blur-off 60 # 60s sample, labelled "blur-off"
|
||||
# ./scripts/measure-cpu-window.sh # auto-label with timestamp
|
||||
#
|
||||
# Reads /proc/PID/stat directly (no pidstat dependency). Samples per second.
|
||||
# CPU% is normalized per-core (100% = one fully busy core), matching the
|
||||
# convention used in `top` and in the bug report at issue #414/#415.
|
||||
#
|
||||
# Output:
|
||||
# - Live one-line update per second (qbz + webkit + total)
|
||||
# - At end: mean, p50, p95, max for the whole window
|
||||
# - CSV row appended to /tmp/qbz-cpu-measurements.csv for cross-run comparison
|
||||
#
|
||||
# To compare runs:
|
||||
# 1. Pick a stable window state (e.g. 4K full-screen, home view, track playing)
|
||||
# 2. Run script, do NOT touch the window during the sample
|
||||
# 3. Change the variable (e.g. resize, toggle backdrop-filter), repeat
|
||||
# 4. cat /tmp/qbz-cpu-measurements.csv
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LABEL="${1:-run-$(date +%H%M%S)}"
|
||||
DURATION="${2:-30}"
|
||||
LOG_CSV="/tmp/qbz-cpu-measurements.csv"
|
||||
CLK_TCK="$(getconf CLK_TCK)"
|
||||
|
||||
if [[ ! "$DURATION" =~ ^[0-9]+$ ]] || (( DURATION < 5 )); then
|
||||
echo "error: duration must be a positive integer >= 5" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Find QBZ main process — match both legacy binary name (qbz-nix, dev builds)
|
||||
# and the production binary (qbz). Prefer the largest-RSS match to dodge
|
||||
# wrapper shells.
|
||||
find_qbz_pid() {
|
||||
local candidates pid best_rss=0 best_pid=""
|
||||
candidates="$(pgrep -f 'target/(debug|release)/qbz(-nix)?$|/usr/(local/)?bin/qbz$|^qbz$' 2>/dev/null || true)"
|
||||
if [[ -z "$candidates" ]]; then
|
||||
candidates="$(pgrep -x qbz 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$candidates" ]]; then
|
||||
candidates="$(pgrep -f 'qbz-nix' 2>/dev/null || true)"
|
||||
fi
|
||||
for pid in $candidates; do
|
||||
[[ -r "/proc/$pid/status" ]] || continue
|
||||
local rss
|
||||
rss="$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status" 2>/dev/null || echo 0)"
|
||||
if (( rss > best_rss )); then
|
||||
best_rss=$rss
|
||||
best_pid=$pid
|
||||
fi
|
||||
done
|
||||
[[ -n "$best_pid" ]] && echo "$best_pid"
|
||||
}
|
||||
|
||||
QBZ_PID="$(find_qbz_pid || true)"
|
||||
if [[ -z "${QBZ_PID:-}" ]]; then
|
||||
echo "error: no qbz process found. Is the app running?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All WebKit child processes. WebKitWebProcess is the one that does CSS
|
||||
# layout/paint, so its CPU% is the most relevant for backdrop-filter cost.
|
||||
# WebKitNetworkProcess does I/O — usually low CPU.
|
||||
find_webkit_pids() {
|
||||
pgrep -f 'WebKit(Web|Network|GPU)Process' 2>/dev/null || true
|
||||
}
|
||||
|
||||
WEBKIT_PIDS=()
|
||||
mapfile -t WEBKIT_PIDS < <(find_webkit_pids)
|
||||
|
||||
read_jiffies() {
|
||||
# Field 14 (utime) + field 15 (stime) of /proc/PID/stat. Pre-paren tokens
|
||||
# in field 2 (comm) can contain spaces, so split off comm carefully.
|
||||
local pid="$1" stat content rest
|
||||
[[ -r "/proc/$pid/stat" ]] || { echo 0; return; }
|
||||
content="$(cat "/proc/$pid/stat" 2>/dev/null || echo "")"
|
||||
[[ -z "$content" ]] && { echo 0; return; }
|
||||
rest="${content#* (}"
|
||||
rest="${rest#*) }"
|
||||
read -r _state _ppid _pgrp _sid _tty _tpgid _flags _minflt _cminflt _majflt _cmajflt utime stime _rest <<< "$rest"
|
||||
echo $(( utime + stime ))
|
||||
}
|
||||
|
||||
read_window_size() {
|
||||
# Best-effort: read the persisted window size from the qbz log. Not used in
|
||||
# math, just printed as context in the CSV.
|
||||
local logf="$HOME/.local/share/qbz/qbz.log"
|
||||
if [[ -r "$logf" ]]; then
|
||||
grep -oE 'size=[0-9]+x[0-9]+' "$logf" 2>/dev/null | tail -1 | cut -d= -f2 || echo "unknown"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
printf '%s\n' "=== qbz CPU sampler ==="
|
||||
printf 'label: %s\n' "$LABEL"
|
||||
printf 'qbz pid: %s\n' "$QBZ_PID"
|
||||
if (( ${#WEBKIT_PIDS[@]} > 0 )); then
|
||||
printf 'webkit: %s\n' "${WEBKIT_PIDS[*]}"
|
||||
else
|
||||
printf 'webkit: (none found — is the GUI window open?)\n'
|
||||
fi
|
||||
printf 'duration: %ss\n' "$DURATION"
|
||||
printf 'window: %s (persisted)\n' "$(read_window_size)"
|
||||
printf 'log: %s\n' "$LOG_CSV"
|
||||
printf '\nSampling — leave the window UNTOUCHED for the full window.\n\n'
|
||||
|
||||
# Take initial snapshot
|
||||
prev_qbz="$(read_jiffies "$QBZ_PID")"
|
||||
declare -A prev_wk
|
||||
for pid in "${WEBKIT_PIDS[@]:-}"; do
|
||||
[[ -z "$pid" ]] && continue
|
||||
prev_wk[$pid]="$(read_jiffies "$pid")"
|
||||
done
|
||||
prev_ts="$(date +%s.%N)"
|
||||
|
||||
samples_qbz=()
|
||||
samples_wk_total=()
|
||||
samples_total=()
|
||||
|
||||
printf '%4s %8s %8s %8s\n' "t/s" "qbz%" "webkit%" "total%"
|
||||
printf '%4s %8s %8s %8s\n' "---" "----" "-------" "------"
|
||||
|
||||
for (( t=1; t<=DURATION; t++ )); do
|
||||
sleep 1
|
||||
now_ts="$(date +%s.%N)"
|
||||
dt="$(awk -v a="$now_ts" -v b="$prev_ts" 'BEGIN{printf "%.4f", a-b}')"
|
||||
prev_ts="$now_ts"
|
||||
|
||||
cur_qbz="$(read_jiffies "$QBZ_PID")"
|
||||
d_qbz=$(( cur_qbz - prev_qbz ))
|
||||
prev_qbz="$cur_qbz"
|
||||
pct_qbz="$(awk -v j="$d_qbz" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
|
||||
|
||||
wk_total=0
|
||||
for pid in "${WEBKIT_PIDS[@]:-}"; do
|
||||
[[ -z "$pid" ]] && continue
|
||||
if [[ ! -r "/proc/$pid/stat" ]]; then
|
||||
continue
|
||||
fi
|
||||
cur="$(read_jiffies "$pid")"
|
||||
d=$(( cur - ${prev_wk[$pid]:-$cur} ))
|
||||
prev_wk[$pid]="$cur"
|
||||
(( d < 0 )) && d=0
|
||||
wk_total=$(( wk_total + d ))
|
||||
done
|
||||
pct_wk="$(awk -v j="$wk_total" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
|
||||
|
||||
pct_total="$(awk -v a="$pct_qbz" -v b="$pct_wk" 'BEGIN{ printf "%.1f", a+b }')"
|
||||
|
||||
samples_qbz+=("$pct_qbz")
|
||||
samples_wk_total+=("$pct_wk")
|
||||
samples_total+=("$pct_total")
|
||||
|
||||
printf '%4d %8s %8s %8s\n' "$t" "$pct_qbz" "$pct_wk" "$pct_total"
|
||||
done
|
||||
|
||||
# Aggregate
|
||||
summarize() {
|
||||
local name="$1"
|
||||
shift
|
||||
local n="$#"
|
||||
local sorted
|
||||
sorted="$(printf '%s\n' "$@" | sort -n)"
|
||||
local mean p50 p95 max
|
||||
mean="$(printf '%s\n' "$@" | awk '{s+=$1} END{ if (NR>0) printf "%.1f", s/NR; else print 0 }')"
|
||||
p50="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.50)+1 || (NR==1 && n==1) {print; exit}')"
|
||||
p95="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.95)+1 || (NR==n) {print; exit}')"
|
||||
max="$(printf '%s\n' "$sorted" | tail -1)"
|
||||
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "$name" "$mean" "$p50" "$p95" "$max"
|
||||
echo "$mean,$p50,$p95,$max"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
summary_qbz="$(summarize "qbz" "${samples_qbz[@]}" | tail -1)"
|
||||
summarize "qbz" "${samples_qbz[@]}" >/dev/null
|
||||
|
||||
read -r mean_qbz p50_qbz p95_qbz max_qbz <<< "$(echo "$summary_qbz" | tr ',' ' ')"
|
||||
|
||||
mean_wk=0 p50_wk=0 p95_wk=0 max_wk=0
|
||||
if (( ${#samples_wk_total[@]} > 0 )); then
|
||||
summary_wk="$(summarize "webkit" "${samples_wk_total[@]}" | tail -1)"
|
||||
read -r mean_wk p50_wk p95_wk max_wk <<< "$(echo "$summary_wk" | tr ',' ' ')"
|
||||
fi
|
||||
|
||||
summary_total="$(summarize "total" "${samples_total[@]}" | tail -1)"
|
||||
read -r mean_total p50_total p95_total max_total <<< "$(echo "$summary_total" | tr ',' ' ')"
|
||||
|
||||
# Re-print summaries to stdout (the summarize calls above piped them out)
|
||||
{
|
||||
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "qbz" "$mean_qbz" "$p50_qbz" "$p95_qbz" "$max_qbz"
|
||||
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "webkit" "$mean_wk" "$p50_wk" "$p95_wk" "$max_wk"
|
||||
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "total" "$mean_total" "$p50_total" "$p95_total" "$max_total"
|
||||
}
|
||||
|
||||
# CSV header if first run
|
||||
if [[ ! -f "$LOG_CSV" ]]; then
|
||||
echo "timestamp,label,duration_s,window_size,qbz_mean,qbz_p50,qbz_p95,qbz_max,webkit_mean,webkit_p50,webkit_p95,webkit_max,total_mean,total_p50,total_p95,total_max" > "$LOG_CSV"
|
||||
fi
|
||||
|
||||
WINDOW="$(read_window_size)"
|
||||
echo "$(date -Iseconds),$LABEL,$DURATION,$WINDOW,$mean_qbz,$p50_qbz,$p95_qbz,$max_qbz,$mean_wk,$p50_wk,$p95_wk,$max_wk,$mean_total,$p50_total,$p95_total,$max_total" >> "$LOG_CSV"
|
||||
|
||||
echo ""
|
||||
echo "Appended to $LOG_CSV"
|
||||
echo "Compare runs: cat $LOG_CSV | column -t -s,"
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
name: qbz-player
|
||||
title: "QBZ — Native Qobuz Client for Linux"
|
||||
version: '1.2.12'
|
||||
version: '1.2.13'
|
||||
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
|
||||
description: |
|
||||
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
|
||||
@@ -97,7 +97,7 @@ parts:
|
||||
qbz:
|
||||
plugin: rust
|
||||
source: https://github.com/vicrodh/qbz.git
|
||||
source-tag: v1.2.12
|
||||
source-tag: v1.2.13
|
||||
build-packages:
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libgtk-3-dev
|
||||
|
||||
Generated
+25
-22
@@ -1536,14 +1536,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"thiserror 2.0.18",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
@@ -5021,6 +5023,22 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qbz-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"log",
|
||||
"md-5",
|
||||
"rand 0.10.1",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-audio"
|
||||
version = "0.1.0"
|
||||
@@ -5148,7 +5166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "qbz-nix"
|
||||
version = "1.2.12"
|
||||
version = "1.2.13"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -5177,6 +5195,7 @@ dependencies = [
|
||||
"mupdf",
|
||||
"notify-rust",
|
||||
"open",
|
||||
"qbz-app",
|
||||
"qbz-audio",
|
||||
"qbz-cache",
|
||||
"qbz-cast",
|
||||
@@ -5220,7 +5239,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http 0.5.2",
|
||||
"tower-http",
|
||||
"urlencoding",
|
||||
"uuid 1.23.1",
|
||||
"walkdir",
|
||||
@@ -5724,7 +5743,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.6.8",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -5764,7 +5783,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.6.8",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -7834,22 +7853,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "qbz-nix"
|
||||
version = "1.2.12"
|
||||
version = "1.2.13"
|
||||
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
|
||||
authors = ["blitzkriegfc"]
|
||||
edition = "2021"
|
||||
@@ -25,6 +25,7 @@ updater = ["dep:tauri-plugin-updater", "dep:tauri-plugin-process"]
|
||||
|
||||
[dependencies]
|
||||
# QBZ Crates (extracted core functionality)
|
||||
qbz-app = { path = "../crates/qbz-app" }
|
||||
qbz-models = { path = "../crates/qbz-models" }
|
||||
qbz-audio = { path = "../crates/qbz-audio" }
|
||||
qbz-player = { path = "../crates/qbz-player" }
|
||||
@@ -66,7 +67,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
qrcode-generator = "5.0"
|
||||
rcgen = "0.14"
|
||||
|
||||
@@ -98,7 +99,7 @@ rand = "0.10"
|
||||
strsim = "0.11"
|
||||
|
||||
# Discord Rich Presence (#336)
|
||||
discord-rich-presence = "0.2.4"
|
||||
discord-rich-presence = "1.1.0"
|
||||
|
||||
# Audio playback
|
||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
|
||||
@@ -652,6 +652,7 @@ async fn play_queue_track(ctx: &ApiContext, track: QueueTrack) -> Result<QueueTr
|
||||
None,
|
||||
None,
|
||||
Some(track.duration_secs),
|
||||
None, // start_position_secs: remote-driven play always starts at 0
|
||||
bridge,
|
||||
offline_cache,
|
||||
audio_settings,
|
||||
@@ -884,6 +885,7 @@ async fn play_queue_index(
|
||||
None,
|
||||
None,
|
||||
Some(t.duration_secs),
|
||||
None, // start_position_secs: remote-driven play always starts at 0
|
||||
bridge,
|
||||
offline_cache,
|
||||
audio_settings,
|
||||
@@ -1059,6 +1061,7 @@ async fn play_album(
|
||||
None,
|
||||
None,
|
||||
Some(first_track.duration_secs),
|
||||
None, // start_position_secs: remote-driven play always starts at 0
|
||||
bridge,
|
||||
offline_cache,
|
||||
audio_settings,
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
//! Graphics auto-configuration tool
|
||||
//! Graphics auto-configuration shell adapter.
|
||||
//!
|
||||
//! Detects the current GPU, display server, desktop environment, and compositor,
|
||||
//! then recommends and applies optimal graphics settings.
|
||||
//!
|
||||
//! Usage: `qbz --autoconfig-graphics`
|
||||
//! Recommendation and host detection live in `qbz-app`; this module keeps the
|
||||
//! Tauri/WebKit-specific surfaces that prompt the CLI user and persist the
|
||||
//! selected recommendation into current settings stores.
|
||||
|
||||
use crate::config::graphics_settings::GraphicsSettingsStore;
|
||||
use qbz_app::graphics_autoconfig::{
|
||||
compute_recommendation, detect_environment, Environment, Recommendation,
|
||||
};
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
/// Detected environment information
|
||||
struct Environment {
|
||||
display_server: String,
|
||||
gpu_nvidia: bool,
|
||||
gpu_amd: bool,
|
||||
gpu_intel: bool,
|
||||
gpu_name: String,
|
||||
desktop: String,
|
||||
is_vm: bool,
|
||||
}
|
||||
|
||||
/// Recommended configuration
|
||||
struct Recommendation {
|
||||
hardware_acceleration: bool,
|
||||
force_x11: bool,
|
||||
gsk_renderer: Option<String>,
|
||||
disable_dmabuf: bool,
|
||||
disable_blur_background: bool,
|
||||
rationale: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run the autoconfig-graphics CLI tool.
|
||||
pub fn run() {
|
||||
eprintln!("[QBZ AutoConfig] Detecting environment...");
|
||||
@@ -57,139 +38,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_environment() -> Environment {
|
||||
let display_server = detect_display_server();
|
||||
let gpu_nvidia = is_nvidia_gpu();
|
||||
let gpu_amd = is_amd_gpu();
|
||||
let gpu_intel = is_intel_gpu();
|
||||
let gpu_name = detect_gpu_name(gpu_nvidia, gpu_amd, gpu_intel);
|
||||
let desktop = detect_desktop();
|
||||
let is_vm = is_virtual_machine();
|
||||
|
||||
Environment {
|
||||
display_server,
|
||||
gpu_nvidia,
|
||||
gpu_amd,
|
||||
gpu_intel,
|
||||
gpu_name,
|
||||
desktop,
|
||||
is_vm,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_display_server() -> String {
|
||||
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
|
||||
|
||||
if is_wayland {
|
||||
"Wayland".to_string()
|
||||
} else {
|
||||
"X11".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_nvidia_gpu() -> bool {
|
||||
std::path::Path::new("/proc/driver/nvidia/version").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("nvidia")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_amd_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/amdgpu").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("amdgpu")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_intel_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/i915").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("i915")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_virtual_machine() -> bool {
|
||||
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
|
||||
let p = product.trim().to_lowercase();
|
||||
if p.contains("virtualbox")
|
||||
|| p.contains("vmware")
|
||||
|| p.contains("qemu")
|
||||
|| p.contains("bochs")
|
||||
|| p.contains("hyper-v")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") {
|
||||
let v = vendor.trim().to_lowercase();
|
||||
if v.contains("innotek")
|
||||
|| v.contains("vmware")
|
||||
|| v.contains("qemu")
|
||||
|| v.contains("xen")
|
||||
|| v.contains("parallels")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(h) = std::fs::read_to_string("/sys/hypervisor/type") {
|
||||
if !h.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn detect_gpu_name(nvidia: bool, amd: bool, intel: bool) -> String {
|
||||
// Try to get a human-readable GPU name
|
||||
if nvidia {
|
||||
if let Ok(version) = std::fs::read_to_string("/proc/driver/nvidia/version") {
|
||||
if let Some(line) = version.lines().next() {
|
||||
return format!("NVIDIA ({})", line.trim());
|
||||
}
|
||||
}
|
||||
return "NVIDIA (driver loaded)".to_string();
|
||||
}
|
||||
if amd {
|
||||
// Try to read GPU model from DRM
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("card") && !name.contains('-') {
|
||||
let model_path = entry.path().join("device/product_name");
|
||||
if let Ok(model) = std::fs::read_to_string(&model_path) {
|
||||
let model = model.trim();
|
||||
if !model.is_empty() {
|
||||
return format!("AMD {}", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "AMD (amdgpu driver loaded)".to_string();
|
||||
}
|
||||
if intel {
|
||||
return "Intel (i915 driver loaded)".to_string();
|
||||
}
|
||||
"Unknown / None detected".to_string()
|
||||
}
|
||||
|
||||
fn detect_desktop() -> String {
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||
let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default();
|
||||
let de = std::env::var("DESKTOP_SESSION").unwrap_or_default();
|
||||
|
||||
if !desktop.is_empty() {
|
||||
desktop
|
||||
} else if !session.is_empty() {
|
||||
session
|
||||
} else if !de.is_empty() {
|
||||
de
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_environment(env: &Environment) {
|
||||
eprintln!(" Display server : {}", env.display_server);
|
||||
eprintln!(" GPU : {}", env.gpu_name);
|
||||
@@ -200,130 +48,6 @@ fn print_environment(env: &Environment) {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
fn compute_recommendation(env: &Environment) -> Recommendation {
|
||||
let mut rationale = Vec::new();
|
||||
|
||||
// VM: software rendering, no blur
|
||||
if env.is_vm {
|
||||
rationale.push("Virtual machine detected: using software rendering".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: false,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("cairo".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
let is_wayland = env.display_server == "Wayland";
|
||||
let desktop_lower = env.desktop.to_lowercase();
|
||||
let is_gnome = desktop_lower.contains("gnome");
|
||||
|
||||
// NVIDIA + Wayland + GNOME: known stutter combo
|
||||
if env.gpu_nvidia && is_wayland && is_gnome {
|
||||
rationale.push("NVIDIA + Wayland + GNOME: using GL renderer, DMA-BUF off".to_string());
|
||||
rationale.push("This combination has known compositing issues".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// NVIDIA + Wayland (non-GNOME)
|
||||
if env.gpu_nvidia && is_wayland {
|
||||
rationale.push("NVIDIA + Wayland: using GL renderer, DMA-BUF off".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// NVIDIA + X11
|
||||
if env.gpu_nvidia {
|
||||
rationale.push("NVIDIA + X11: full hardware acceleration, DMA-BUF off".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + Wayland
|
||||
if env.gpu_amd && is_wayland {
|
||||
rationale.push("AMD + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + X11
|
||||
if env.gpu_amd {
|
||||
rationale.push("AMD + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + Wayland
|
||||
if env.gpu_intel && is_wayland {
|
||||
rationale.push("Intel + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + X11
|
||||
if env.gpu_intel {
|
||||
rationale.push("Intel + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown GPU: safe defaults
|
||||
rationale.push("No known GPU detected: using safe defaults".to_string());
|
||||
Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_recommendation(rec: &Recommendation) {
|
||||
eprintln!("[QBZ AutoConfig] Recommended configuration:");
|
||||
eprintln!(
|
||||
@@ -361,9 +85,40 @@ fn print_recommendation(rec: &Recommendation) {
|
||||
}
|
||||
|
||||
fn apply_recommendation(rec: &Recommendation) {
|
||||
match write_recommendation(rec) {
|
||||
Ok(()) => {
|
||||
if rec.disable_blur_background {
|
||||
eprintln!(
|
||||
"[QBZ AutoConfig] Note: blur background will be disabled. You can toggle this in Settings > Appearance."
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Configuration applied successfully.");
|
||||
eprintln!("[QBZ AutoConfig] Restart QBZ to take effect.");
|
||||
}
|
||||
Err(errors) => {
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Some settings could not be applied:");
|
||||
for e in &errors {
|
||||
eprintln!(" - {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the recommendation to the persistence layer. Used by the CLI prompt
|
||||
/// (`apply_recommendation`); the Settings UI is advisory-only and no longer
|
||||
/// writes the recommendation. Returns the list of write errors so the caller
|
||||
/// can surface them on stderr.
|
||||
///
|
||||
/// DMA-BUF semantics after the 1.2.13 opt-in flip:
|
||||
/// - `rec.disable_dmabuf = true` -> force_dmabuf = false (matches default;
|
||||
/// runtime keeps DMA-BUF off).
|
||||
/// - `rec.disable_dmabuf = false` -> force_dmabuf = true (user opts in via
|
||||
/// this recommendation; runtime turns DMA-BUF on).
|
||||
fn write_recommendation(rec: &Recommendation) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Apply graphics settings to DB
|
||||
match GraphicsSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_hardware_acceleration(rec.hardware_acceleration) {
|
||||
@@ -381,41 +136,21 @@ fn apply_recommendation(rec: &Recommendation) {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply developer settings (force_dmabuf)
|
||||
if rec.disable_dmabuf {
|
||||
// Note: we don't set force_dmabuf=true here because the default behavior
|
||||
// already disables DMA-BUF for NVIDIA+Wayland. We only reset it to ensure
|
||||
// it's not forced ON.
|
||||
match crate::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_dmabuf(false) {
|
||||
errors.push(format!("force_dmabuf: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("developer settings store: {}", e));
|
||||
let desired_force_dmabuf = !rec.disable_dmabuf;
|
||||
match crate::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_dmabuf(desired_force_dmabuf) {
|
||||
errors.push(format!("force_dmabuf: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply blur background setting to localStorage config file
|
||||
// Note: This is stored in localStorage by the frontend, so we write a hint
|
||||
// that the frontend will read on next startup
|
||||
if rec.disable_blur_background {
|
||||
eprintln!(
|
||||
"[QBZ AutoConfig] Note: blur background will be disabled. You can toggle this in Settings > Appearance."
|
||||
);
|
||||
Err(e) => {
|
||||
errors.push(format!("developer settings store: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Configuration applied successfully.");
|
||||
eprintln!("[QBZ AutoConfig] Restart QBZ to take effect.");
|
||||
Ok(())
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Some settings could not be applied:");
|
||||
for e in &errors {
|
||||
eprintln!(" - {}", e);
|
||||
}
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Boot watchdog for graphics settings that can crash WebKit before it
|
||||
//! paints a single frame (HW acceleration, DMA-BUF, preferred-GPU
|
||||
//! selection that picks an unreachable stack).
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. `before_webkit_init()` is called at startup with the risky
|
||||
//! settings of the current boot. It writes a `pending.json` marker
|
||||
//! to `<data_dir>/qbz/boot_state/` recording which settings were
|
||||
//! attempted.
|
||||
//! 2. After WebKit boots and the frontend signals first-paint (via
|
||||
//! `v2_mark_boot_succeeded`), `mark_boot_succeeded()` removes the
|
||||
//! pending marker and writes `last-good.json` carrying the
|
||||
//! successful settings.
|
||||
//! 3. On the NEXT boot, `before_webkit_init` first calls
|
||||
//! `check_previous_boot()`. If a pending marker exists without a
|
||||
//! matching success, the previous boot crashed during graphics
|
||||
//! init — we auto-revert the offending setting(s), record a
|
||||
//! crash recovery flag, and only THEN write the new pending
|
||||
//! marker for the current boot.
|
||||
//!
|
||||
//! Two consecutive crashes with the same setting promote to "lockout"
|
||||
//! state: the setting stays disabled until the user explicitly clears
|
||||
//! the flag from Settings. Prevents an infinite retry loop.
|
||||
|
||||
use qbz_app::boot_watchdog::{
|
||||
clear_crash_flag_value, mark_boot_success_flags, resolve_boot_attempt,
|
||||
};
|
||||
pub use qbz_app::boot_watchdog::{BootAttempt, CrashFlags, WatchdogResolution};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STATE_DIR: &str = "boot_state";
|
||||
const PENDING_FILE: &str = "pending.json";
|
||||
const LAST_GOOD_FILE: &str = "last-good.json";
|
||||
const CRASH_FLAGS_FILE: &str = "crash-flags.json";
|
||||
|
||||
fn state_dir() -> Option<PathBuf> {
|
||||
let base = dirs::data_dir()?.join("qbz").join(STATE_DIR);
|
||||
fs::create_dir_all(&base).ok()?;
|
||||
Some(base)
|
||||
}
|
||||
|
||||
fn read_pending() -> Option<BootAttempt> {
|
||||
let path = state_dir()?.join(PENDING_FILE);
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn write_pending(attempt: &BootAttempt) {
|
||||
let Some(dir) = state_dir() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(attempt) {
|
||||
let _ = fs::write(dir.join(PENDING_FILE), text);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_pending() {
|
||||
if let Some(dir) = state_dir() {
|
||||
let _ = fs::remove_file(dir.join(PENDING_FILE));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_last_good(attempt: &BootAttempt) {
|
||||
let Some(dir) = state_dir() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(attempt) {
|
||||
let _ = fs::write(dir.join(LAST_GOOD_FILE), text);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_crash_flags() -> CrashFlags {
|
||||
let Some(dir) = state_dir() else {
|
||||
return CrashFlags::default();
|
||||
};
|
||||
let path = dir.join(CRASH_FLAGS_FILE);
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
return CrashFlags::default();
|
||||
};
|
||||
serde_json::from_str(&text).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_crash_flags(flags: &CrashFlags) {
|
||||
let Some(dir) = state_dir() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(flags) {
|
||||
let _ = fs::write(dir.join(CRASH_FLAGS_FILE), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called at startup with the settings the current boot WOULD apply if
|
||||
/// nothing crashed. Returns the resolved settings to actually use —
|
||||
/// usually the same input, but with risky knobs reverted if the
|
||||
/// previous boot left a pending marker.
|
||||
///
|
||||
/// Also writes the new pending marker for THIS boot so a crash before
|
||||
/// the frontend signals success is detectable on the next launch.
|
||||
pub fn before_webkit_init(intended: BootAttempt) -> WatchdogResolution {
|
||||
let decision = resolve_boot_attempt(intended.clone(), read_pending(), read_crash_flags());
|
||||
if decision.should_persist_reverts {
|
||||
persist_reverts_to_db(&decision.resolved_attempt, &intended);
|
||||
}
|
||||
|
||||
write_crash_flags(&decision.crash_flags);
|
||||
write_pending(&decision.resolved_attempt);
|
||||
decision.resolution()
|
||||
}
|
||||
|
||||
/// Called from a Tauri command after the frontend signals first paint.
|
||||
/// Removes the pending marker so the next launch doesn't think this
|
||||
/// boot crashed; writes last-good as the new baseline; clears
|
||||
/// consecutive-failure counters for whatever settings were active.
|
||||
pub fn mark_boot_succeeded() {
|
||||
// Read what we attempted in this boot — the pending file is the
|
||||
// source of truth for "what was active when WebKit first paint
|
||||
// happened".
|
||||
let attempt = read_pending().unwrap_or_default();
|
||||
clear_pending();
|
||||
write_last_good(&attempt);
|
||||
|
||||
let flags = mark_boot_success_flags(&attempt, read_crash_flags());
|
||||
write_crash_flags(&flags);
|
||||
}
|
||||
|
||||
/// Read-only view of the crash flags for the Settings UI.
|
||||
pub fn get_crash_flags() -> CrashFlags {
|
||||
read_crash_flags()
|
||||
}
|
||||
|
||||
/// Clear a single crash recovery flag by name. Frontend uses this when
|
||||
/// the user clicks "Try again" / "I want to re-enable this" on the
|
||||
/// recovery banner. Unknown names are no-ops.
|
||||
pub fn clear_crash_flag(flag: &str) {
|
||||
if let Some(flags) = clear_crash_flag_value(read_crash_flags(), flag) {
|
||||
write_crash_flags(&flags);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort write of reverted values back to graphics_settings.db
|
||||
/// and developer_settings.db. Failure is non-fatal — the runtime
|
||||
/// values for THIS boot are still the reverted ones (we pass them
|
||||
/// back to main.rs via WatchdogResolution), and the next boot's read
|
||||
/// will see the reverted DB rows too.
|
||||
fn persist_reverts_to_db(resolved: &BootAttempt, intended: &BootAttempt) {
|
||||
if resolved.hardware_acceleration != intended.hardware_acceleration {
|
||||
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
|
||||
let _ = store.set_hardware_acceleration(resolved.hardware_acceleration);
|
||||
}
|
||||
}
|
||||
if resolved.force_dmabuf != intended.force_dmabuf {
|
||||
if let Ok(store) = crate::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
let _ = store.set_force_dmabuf(resolved.force_dmabuf);
|
||||
}
|
||||
}
|
||||
if resolved.preferred_gpu != intended.preferred_gpu {
|
||||
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
|
||||
let _ = store.set_preferred_gpu(&resolved.preferred_gpu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,18 +299,51 @@ pub async fn v2_get_release_watch(
|
||||
results
|
||||
.items
|
||||
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
|
||||
let filtered_count = original_count - results.items.len();
|
||||
if filtered_count > 0 {
|
||||
let blacklist_filtered = original_count - results.items.len();
|
||||
if blacklist_filtered > 0 {
|
||||
log::debug!(
|
||||
"[V2/Blacklist] Filtered {} albums from release watch",
|
||||
filtered_count
|
||||
blacklist_filtered
|
||||
);
|
||||
results.total = results.total.saturating_sub(filtered_count as u32);
|
||||
}
|
||||
|
||||
let before_unreleased = results.items.len();
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
results.items.retain(|album| is_album_available(album, &today));
|
||||
let unreleased_filtered = before_unreleased - results.items.len();
|
||||
if unreleased_filtered > 0 {
|
||||
log::debug!(
|
||||
"[V2/ReleaseWatch] Filtered {} unreleased/unstreamable albums",
|
||||
unreleased_filtered
|
||||
);
|
||||
}
|
||||
|
||||
let total_filtered = blacklist_filtered + unreleased_filtered;
|
||||
if total_filtered > 0 {
|
||||
results.total = results.total.saturating_sub(total_filtered as u32);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Whether an album from Release Watch is actually available to fetch.
|
||||
///
|
||||
/// Drops albums that are either marked non-streamable (regional / takedown)
|
||||
/// or whose stream availability date is in the future. Upcoming releases
|
||||
/// 404 on `get_album`, so they should never reach the UI grid in the first
|
||||
/// place.
|
||||
fn is_album_available(album: &Album, today_iso: &str) -> bool {
|
||||
if matches!(album.streamable, Some(false)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(stream_date) = album.release_date_stream.as_deref() {
|
||||
if stream_date > today_iso {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Get artist page (V2 - uses QbzCore)
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct RuntimeDiagnostics {
|
||||
pub runtime_is_vm: bool,
|
||||
pub runtime_hw_accel_enabled: bool,
|
||||
pub runtime_force_x11_active: bool,
|
||||
/// Human-readable GPU model name (driver-reported on Linux).
|
||||
/// For hybrid laptops joins both vendors: "NVIDIA (...) + Intel (...)".
|
||||
pub runtime_gpu_name: String,
|
||||
/// Desktop environment string ($XDG_CURRENT_DESKTOP or fallbacks).
|
||||
pub runtime_desktop_environment: String,
|
||||
|
||||
// Developer settings
|
||||
pub dev_force_dmabuf: bool,
|
||||
@@ -152,6 +157,15 @@ pub fn v2_get_runtime_diagnostics(
|
||||
runtime_is_vm: gfx_status.is_vm,
|
||||
runtime_hw_accel_enabled: gfx_status.hardware_accel_enabled,
|
||||
runtime_force_x11_active: gfx_status.force_x11_active,
|
||||
runtime_gpu_name: qbz_app::graphics_autoconfig::detect_gpu_name(
|
||||
gfx_status.has_nvidia,
|
||||
gfx_status.has_amd,
|
||||
gfx_status.has_intel,
|
||||
),
|
||||
runtime_desktop_environment: std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
|
||||
.or_else(|_| std::env::var("DESKTOP_SESSION"))
|
||||
.unwrap_or_else(|_| "Unknown".to_string()),
|
||||
|
||||
dev_force_dmabuf: dev.force_dmabuf,
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! V2 commands that expose the graphics auto-config recommendation to the
|
||||
//! Settings UI. The detection + decision logic lives in
|
||||
//! `autoconfig_graphics.rs` (shared with the `qbz --autoconfig-graphics` CLI
|
||||
//! tool); this module is just the Tauri surface that the Graphics tab uses
|
||||
//! to render the "Detected / Recommended" banner.
|
||||
|
||||
use qbz_app::graphics_autoconfig::{
|
||||
compute_recommendation, detect_environment, Environment, Recommendation,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Payload returned to the Settings UI. Splits environment from
|
||||
/// recommendation so the frontend can show a human-readable detection
|
||||
/// line and then compare the recommendation to the user's current
|
||||
/// persisted settings to decide whether to surface the banner at all.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GraphicsRecommendationPayload {
|
||||
pub environment: Environment,
|
||||
pub recommendation: Recommendation,
|
||||
}
|
||||
|
||||
/// Compute the recommendation for the current host. Cheap — runs the
|
||||
/// same detection that the CLI tool runs and synthesizes the matrix
|
||||
/// decision. The Settings UI calls this on Graphics-tab mount.
|
||||
#[tauri::command]
|
||||
pub fn v2_get_graphics_recommendation() -> GraphicsRecommendationPayload {
|
||||
let environment = detect_environment();
|
||||
let recommendation = compute_recommendation(&environment);
|
||||
GraphicsRecommendationPayload {
|
||||
environment,
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for the rendering-GPU dropdown in Settings > Graphics.
|
||||
/// `gpus` is the full list (physical PCI devices intersected with
|
||||
/// available EGL vendor stacks, plus the Software fallback).
|
||||
/// `auto_resolved_id` says which entry, if any, "Auto" currently pins
|
||||
/// to on this system — frontend uses it to label the Auto option (e.g.
|
||||
/// "Automatic (Intel Graphics)") so the user can see what default they
|
||||
/// are getting without picking explicitly. `None` means Auto is a
|
||||
/// no-op on this system (single GPU or no GPU detected).
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GpuList {
|
||||
pub gpus: Vec<crate::graphics_detection::DetectedGpu>,
|
||||
pub auto_resolved_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Enumerate detected GPUs for the rendering-GPU dropdown in
|
||||
/// Settings > Graphics. Returns physical PCI devices intersected with
|
||||
/// available EGL vendor stacks — entries with `is_usable = false`
|
||||
/// should be hidden / disabled by the UI. Always includes a Software
|
||||
/// fallback so the list is never empty.
|
||||
#[tauri::command]
|
||||
pub fn v2_enumerate_gpus() -> GpuList {
|
||||
let gpus = crate::graphics_detection::enumerate_gpus();
|
||||
let auto_resolved_id =
|
||||
crate::graphics_detection::auto_resolves_to(&gpus).map(|g| g.id.clone());
|
||||
GpuList {
|
||||
gpus,
|
||||
auto_resolved_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the frontend after first paint to clear the boot
|
||||
/// watchdog's pending marker. If this is never called (because WebKit
|
||||
/// crashed before painting), the next launch sees the marker and
|
||||
/// auto-reverts the offending risky setting.
|
||||
#[tauri::command]
|
||||
pub fn v2_mark_boot_succeeded() {
|
||||
crate::boot_watchdog::mark_boot_succeeded();
|
||||
}
|
||||
|
||||
/// Read the sticky crash flags so Settings > Graphics can render
|
||||
/// the recovery banner. The flags say which risky setting was
|
||||
/// auto-reverted in this boot, the consecutive-failure counter, and
|
||||
/// whether the knob is in lockout state (2+ failures → user must
|
||||
/// explicitly clear before it can be re-enabled).
|
||||
#[tauri::command]
|
||||
pub fn v2_get_crash_flags() -> crate::boot_watchdog::CrashFlags {
|
||||
crate::boot_watchdog::get_crash_flags()
|
||||
}
|
||||
|
||||
/// User-driven clear of a single crash recovery flag. Called when the
|
||||
/// user clicks "Try again" / "Re-enable" on the banner. Valid flag
|
||||
/// names: "hardware_acceleration", "force_dmabuf", "preferred_gpu".
|
||||
#[tauri::command]
|
||||
pub fn v2_clear_crash_flag(flag: String) {
|
||||
crate::boot_watchdog::clear_crash_flag(&flag);
|
||||
}
|
||||
@@ -98,27 +98,7 @@ pub struct V2SearchAllResults {
|
||||
/// Used by runtime_bootstrap (once at startup) and v2_reinit_audio_device
|
||||
/// to ensure the Player has fresh settings from the database.
|
||||
pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_audio::AudioSettings {
|
||||
qbz_audio::AudioSettings {
|
||||
output_device: settings.output_device.clone(),
|
||||
exclusive_mode: settings.exclusive_mode,
|
||||
dac_passthrough: settings.dac_passthrough,
|
||||
preferred_sample_rate: settings.preferred_sample_rate,
|
||||
limit_quality_to_device: settings.limit_quality_to_device,
|
||||
device_max_sample_rate: settings.device_max_sample_rate,
|
||||
device_sample_rate_limits: settings.device_sample_rate_limits.clone(),
|
||||
backend_type: settings.backend_type.clone(),
|
||||
alsa_plugin: settings.alsa_plugin.clone(),
|
||||
alsa_hardware_volume: settings.alsa_hardware_volume,
|
||||
stream_first_track: settings.stream_first_track,
|
||||
stream_buffer_seconds: settings.stream_buffer_seconds,
|
||||
streaming_only: settings.streaming_only,
|
||||
normalization_enabled: settings.normalization_enabled,
|
||||
normalization_target_lufs: settings.normalization_target_lufs,
|
||||
gapless_enabled: settings.gapless_enabled,
|
||||
pw_force_bitperfect: settings.pw_force_bitperfect,
|
||||
skip_sink_switch: settings.skip_sink_switch,
|
||||
allow_quality_fallback: settings.allow_quality_fallback,
|
||||
}
|
||||
settings.clone()
|
||||
}
|
||||
|
||||
/// Reload audio settings from the per-user store into the CoreBridge player.
|
||||
|
||||
@@ -2580,6 +2580,63 @@ pub async fn v2_library_get_albums_metadata(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// V2 paginated metadata-grouped albums for Local Library. Backs the
|
||||
/// recycling-grid pool + chunked store on the frontend: caller asks for
|
||||
/// `[offset, offset+limit)` and receives that page plus the total count
|
||||
/// of albums matching the same filter (drives scrollbar pre-allocation).
|
||||
///
|
||||
/// When `include_plex` is true and the `plex_cache.db` cache file
|
||||
/// exists in the user's data dir, the result is the UNION of local and
|
||||
/// plex sources — sort, filter, and pagination apply to both at once.
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_albums_page(
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_dir: Option<String>,
|
||||
exclude_network_folders: Option<bool>,
|
||||
include_plex: Option<bool>,
|
||||
state: State<'_, LibraryState>,
|
||||
download_settings_state: State<'_, DownloadSettingsState>,
|
||||
) -> Result<qbz_library::AlbumsMetadataPage, String> {
|
||||
let include_qobuz = download_settings_state
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock download settings: {}", e))?
|
||||
.as_ref()
|
||||
.and_then(|s| s.get_settings().ok())
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Resolve the plex_cache.db path the same way `plex/mod.rs`
|
||||
// does (`dirs::data_dir() + qbz/plex_cache.db`). The qbz-library
|
||||
// crate doesn't know about Plex — passing `None` here keeps the
|
||||
// local-only behavior; passing the path lets the SQL ATTACH + UNION
|
||||
// light up.
|
||||
let plex_path: Option<std::path::PathBuf> = if include_plex.unwrap_or(false) {
|
||||
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let guard__ = state.db.lock().await;
|
||||
let db = guard__
|
||||
.as_ref()
|
||||
.ok_or("No active session - please log in")?;
|
||||
|
||||
db.get_albums_metadata_page(
|
||||
offset,
|
||||
limit,
|
||||
search.as_deref(),
|
||||
sort_by.as_deref().unwrap_or("artist"),
|
||||
sort_dir.as_deref().unwrap_or("asc"),
|
||||
include_qobuz,
|
||||
exclude_network_folders.unwrap_or(false),
|
||||
plex_path.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_stats(
|
||||
state: State<'_, LibraryState>,
|
||||
|
||||
@@ -815,6 +815,36 @@ pub fn v2_set_gsk_renderer(
|
||||
store.set_gsk_renderer(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_set_preferred_gpu(
|
||||
value: String,
|
||||
state: State<'_, GraphicsSettingsState>,
|
||||
) -> Result<(), String> {
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or("Graphics settings store not initialized")?;
|
||||
store.set_preferred_gpu(&value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_set_nvidia_compat_mode(
|
||||
enabled: bool,
|
||||
state: State<'_, GraphicsSettingsState>,
|
||||
) -> Result<(), String> {
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or("Graphics settings store not initialized")?;
|
||||
store.set_nvidia_compat_mode(enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_clear_cache(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.audio_cache.clear();
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct V2PlaylistSuggestionsInput {
|
||||
pub(crate) mod helpers;
|
||||
pub use helpers::*;
|
||||
|
||||
mod graphics_recommendation;
|
||||
pub use graphics_recommendation::*;
|
||||
|
||||
mod runtime;
|
||||
pub use runtime::*;
|
||||
|
||||
|
||||
@@ -887,12 +887,18 @@ pub struct V2PlayTrackResult {
|
||||
/// 3. Downloads audio
|
||||
/// 4. Plays via CoreBridge.player() (qbz-player crate)
|
||||
/// 5. Caches for future playback
|
||||
// `start_position_secs` (#315): when > 0, the streaming path waits for
|
||||
// enough buffer to cover this offset and pre-skips decoder output up to
|
||||
// that point, then engages audio. Avoids the play-then-seek race that
|
||||
// silently dropped the saved position on first-track session restore.
|
||||
// 0 / None = start from beginning.
|
||||
#[tauri::command]
|
||||
pub async fn v2_play_track(
|
||||
track_id: u64,
|
||||
quality: Option<String>,
|
||||
force_lowest_quality: Option<bool>,
|
||||
duration_secs: Option<u64>,
|
||||
start_position_secs: Option<u64>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
offline_cache: State<'_, OfflineCacheState>,
|
||||
audio_settings: State<'_, AudioSettingsState>,
|
||||
@@ -901,6 +907,7 @@ pub async fn v2_play_track(
|
||||
app_handle: tauri::AppHandle,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<V2PlayTrackResult, RuntimeError> {
|
||||
let start_position_secs = start_position_secs.unwrap_or(0);
|
||||
// Runtime contract: require CoreBridge auth for V2 playback
|
||||
runtime
|
||||
.manager()
|
||||
@@ -1294,6 +1301,7 @@ pub async fn v2_play_track(
|
||||
total_flac_size,
|
||||
speed_mbps,
|
||||
duration_secs.unwrap_or(0),
|
||||
start_position_secs,
|
||||
)
|
||||
.map_err(RuntimeError::Internal)?;
|
||||
|
||||
@@ -1528,6 +1536,7 @@ pub async fn v2_play_track(
|
||||
stream_info.content_length,
|
||||
stream_info.speed_mbps,
|
||||
duration_secs.unwrap_or(0),
|
||||
start_position_secs,
|
||||
)
|
||||
.map_err(RuntimeError::Internal)?;
|
||||
|
||||
|
||||
@@ -1,754 +1,10 @@
|
||||
//! Audio settings persistence
|
||||
//!
|
||||
//! Stores user preferences for audio output device, exclusive mode, and DAC passthrough.
|
||||
//! Tauri command adapters for the canonical qbz-audio settings store.
|
||||
|
||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioSettings {
|
||||
pub output_device: Option<String>, // None = system default
|
||||
pub exclusive_mode: bool,
|
||||
pub dac_passthrough: bool,
|
||||
pub preferred_sample_rate: Option<u32>, // None = auto
|
||||
pub backend_type: Option<AudioBackendType>, // None = auto-detect
|
||||
pub alsa_plugin: Option<AlsaPlugin>, // Only used when backend is ALSA
|
||||
pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices)
|
||||
/// When true, uncached tracks start playing via streaming instead of waiting for full download
|
||||
pub stream_first_track: bool,
|
||||
/// Initial buffer size in seconds before starting streaming playback (1-10, default 3)
|
||||
pub stream_buffer_seconds: u8,
|
||||
/// When true, skip L1+L2 cache writes (streaming-only mode). Offline cache still works.
|
||||
pub streaming_only: bool,
|
||||
/// When true, limit streaming quality to device's max supported sample rate.
|
||||
/// This ensures bit-perfect playback by avoiding tracks that exceed device capabilities.
|
||||
/// Default: true (recommended for bit-perfect setups)
|
||||
pub limit_quality_to_device: bool,
|
||||
/// Cached max sample rate of the selected device (set when device is selected)
|
||||
/// Used when limit_quality_to_device is true
|
||||
pub device_max_sample_rate: Option<u32>,
|
||||
/// Per-device sample rate limits: device_id -> max_sample_rate
|
||||
/// Allows different DACs to have independent max sample rate configurations
|
||||
#[serde(default)]
|
||||
pub device_sample_rate_limits: HashMap<String, u32>,
|
||||
/// When true, apply volume normalization using ReplayGain metadata.
|
||||
/// When false (default), the audio pipeline is 100% bit-perfect — no sample modification.
|
||||
pub normalization_enabled: bool,
|
||||
/// Target loudness in LUFS for normalization.
|
||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||
pub normalization_target_lufs: f32,
|
||||
/// When true, consecutive same-format tracks play without gap.
|
||||
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||
pub gapless_enabled: bool,
|
||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
||||
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, reload audio settings from DB into the player on app startup.
|
||||
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
|
||||
/// Default: false (most users don't need this).
|
||||
pub sync_audio_on_startup: bool,
|
||||
/// User preference for what happens when all quality retries fail.
|
||||
/// Values: "ask" (default), "always_fallback", "always_skip"
|
||||
/// Protected by ADR-003: must survive reset_all() and migrations.
|
||||
pub quality_fallback_behavior: String,
|
||||
/// When true, skip `pactl set-default-sink` on stream creation.
|
||||
/// Preserves external routing (JACK, qjackctl, Reaper).
|
||||
/// Mutually exclusive with dac_passthrough.
|
||||
pub skip_sink_switch: bool,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
/// When false (default), playback or download fails if the exact quality is unavailable.
|
||||
pub allow_quality_fallback: bool,
|
||||
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
|
||||
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
|
||||
/// clients won't grab the DAC and break exclusive playback. Off by default.
|
||||
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
|
||||
#[serde(default)]
|
||||
pub reserve_dac_while_running: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_device: None,
|
||||
exclusive_mode: false,
|
||||
dac_passthrough: false,
|
||||
preferred_sample_rate: None,
|
||||
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
|
||||
// elsewhere) instead of None. None used to mean "auto-detect" but it
|
||||
// actually fell through to the legacy CPAL path in init_device, which
|
||||
// has a known race against rodio's DeviceSink drop on resume — visible
|
||||
// as "audio doesn't come back after pause" (#375). Picking a concrete
|
||||
// backend here puts new installs on the proper backend init path; the
|
||||
// runtime still degrades gracefully if the chosen backend isn't
|
||||
// available on the host.
|
||||
backend_type: Some(AudioBackendType::default()),
|
||||
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
|
||||
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
|
||||
stream_first_track: false, // Disabled by default — user opts in
|
||||
stream_buffer_seconds: 3, // 3 seconds initial buffer
|
||||
streaming_only: false, // Disabled by default (cache tracks for instant replay)
|
||||
limit_quality_to_device: false, // Disabled in 1.1.9 — detection logic unreliable (#45)
|
||||
device_max_sample_rate: None, // Set when device is selected
|
||||
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: true, // On by default — works for same-format tracks on all backends
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
|
||||
quality_fallback_behavior: "ask".to_string(),
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AudioSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open audio settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for audio settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS audio_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
output_device TEXT,
|
||||
exclusive_mode INTEGER NOT NULL DEFAULT 0,
|
||||
dac_passthrough INTEGER NOT NULL DEFAULT 0,
|
||||
preferred_sample_rate INTEGER,
|
||||
backend_type TEXT,
|
||||
alsa_plugin TEXT,
|
||||
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
|
||||
stream_first_track INTEGER NOT NULL DEFAULT 0,
|
||||
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
|
||||
);
|
||||
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
|
||||
VALUES (1, 0, 0);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create audio settings table: {}", e))?;
|
||||
|
||||
// Migration: Add new columns if they don't exist (for existing databases)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN backend_type TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN streaming_only INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN limit_quality_to_device INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN device_max_sample_rate INTEGER",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN normalization_enabled INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN normalization_target_lufs REAL DEFAULT -14.0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN gapless_enabled INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN device_sample_rate_limits TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration (#375): existing installs persisted backend_type = NULL when
|
||||
// the user never explicitly picked a backend. NULL fell through to the
|
||||
// legacy CPAL path in the player, which has a race against rodio's
|
||||
// DeviceSink drop on resume that produces silent streams after pause.
|
||||
// Backfill NULL rows with the per-OS default so those installs move onto
|
||||
// the proper backend init path. set_backend_type is the canonical writer
|
||||
// (it serialises via serde_json), so we go through the same JSON shape
|
||||
// here for the row read path to round-trip cleanly.
|
||||
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback only matters if serde_json::to_string fails for a
|
||||
// unit-variant enum, which it can't — but keep a sane string in
|
||||
// case the enum gains a non-trivial variant in the future.
|
||||
if cfg!(target_os = "linux") {
|
||||
"\"PipeWire\"".to_string()
|
||||
} else {
|
||||
"\"SystemDefault\"".to_string()
|
||||
}
|
||||
});
|
||||
let _ = conn.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
|
||||
params![default_backend_json],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "audio_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "audio_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
let backend_type: Option<AudioBackendType> = row
|
||||
.get::<_, Option<String>>(4)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
|
||||
// Parse alsa_plugin from JSON string
|
||||
let alsa_plugin: Option<AlsaPlugin> = row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
|
||||
// Parse device_sample_rate_limits from JSON string
|
||||
let device_sample_rate_limits: HashMap<String, u32> = row
|
||||
.get::<_, Option<String>>(15)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(AudioSettings {
|
||||
output_device: row.get(0)?,
|
||||
exclusive_mode: row.get::<_, i64>(1)? != 0,
|
||||
dac_passthrough: row.get::<_, i64>(2)? != 0,
|
||||
preferred_sample_rate: row.get(3)?,
|
||||
backend_type,
|
||||
alsa_plugin,
|
||||
alsa_hardware_volume: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
|
||||
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
|
||||
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
|
||||
streaming_only: row.get::<_, Option<i64>>(9)?.unwrap_or(0) != 0,
|
||||
limit_quality_to_device: row.get::<_, Option<i64>>(10)?.unwrap_or(1) != 0,
|
||||
device_max_sample_rate: row.get::<_, Option<i64>>(11)?.map(|r| r as u32),
|
||||
device_sample_rate_limits,
|
||||
normalization_enabled: row.get::<_, Option<i64>>(12)?.unwrap_or(0) != 0,
|
||||
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
|
||||
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
quality_fallback_behavior: row
|
||||
.get::<_, Option<String>>(18)?
|
||||
.unwrap_or_else(|| "ask".to_string()),
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
|
||||
reserve_dac_while_running: row
|
||||
.get::<_, Option<i64>>(21)?
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get audio settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_output_device(&self, device: Option<&str>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET output_device = ?1 WHERE id = 1",
|
||||
params![device],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set output device: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_exclusive_mode(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET exclusive_mode = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set exclusive mode: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_dac_passthrough(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET dac_passthrough = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set DAC passthrough: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET preferred_sample_rate = ?1 WHERE id = 1",
|
||||
params![rate.map(|r| r as i64)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sample rate: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_backend_type(&self, backend: Option<AudioBackendType>) -> Result<(), String> {
|
||||
let backend_json = backend
|
||||
.map(|b| serde_json::to_string(&b))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1",
|
||||
params![backend_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set backend type: {}", e))?;
|
||||
|
||||
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
|
||||
if backend == Some(AudioBackendType::Alsa) {
|
||||
let current_plugin: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
|
||||
|
||||
if current_plugin.is_none() {
|
||||
log::info!("[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw");
|
||||
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_alsa_plugin(&self, plugin: Option<AlsaPlugin>) -> Result<(), String> {
|
||||
let plugin_json = plugin
|
||||
.map(|p| serde_json::to_string(&p))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET alsa_plugin = ?1 WHERE id = 1",
|
||||
params![plugin_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ALSA plugin: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_alsa_hardware_volume(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET alsa_hardware_volume = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream first track: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> {
|
||||
// Clamp to valid range 1-10
|
||||
let clamped = seconds.clamp(1, 10);
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1",
|
||||
params![clamped as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_streaming_only(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET streaming_only = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set streaming only: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_limit_quality_to_device(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET limit_quality_to_device = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set limit quality to device: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_device_max_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET device_max_sample_rate = ?1 WHERE id = 1",
|
||||
params![rate.map(|r| r as i64)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set device max sample rate: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the sample rate limit for a specific device
|
||||
/// If rate is None, removes the limit for that device
|
||||
pub fn set_device_sample_rate_limit(
|
||||
&self,
|
||||
device_id: &str,
|
||||
rate: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
// Get current limits
|
||||
let mut limits = self.get_device_sample_rate_limits()?;
|
||||
|
||||
// Update or remove the limit for this device
|
||||
if let Some(r) = rate {
|
||||
limits.insert(device_id.to_string(), r);
|
||||
} else {
|
||||
limits.remove(device_id);
|
||||
}
|
||||
|
||||
// Serialize and save
|
||||
let json = serde_json::to_string(&limits)
|
||||
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET device_sample_rate_limits = ?1 WHERE id = 1",
|
||||
params![json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set device sample rate limits: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the sample rate limit for a specific device
|
||||
/// Returns None if no limit is set for this device
|
||||
pub fn get_device_sample_rate_limit(&self, device_id: &str) -> Result<Option<u32>, String> {
|
||||
let limits = self.get_device_sample_rate_limits()?;
|
||||
Ok(limits.get(device_id).copied())
|
||||
}
|
||||
|
||||
/// Get all device sample rate limits
|
||||
fn get_device_sample_rate_limits(&self) -> Result<HashMap<String, u32>, String> {
|
||||
let json: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT device_sample_rate_limits FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
|
||||
|
||||
match json {
|
||||
Some(s) if !s.is_empty() => serde_json::from_str(&s)
|
||||
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
|
||||
_ => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_normalization_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET normalization_enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set normalization enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gapless_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET gapless_enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gapless enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
|
||||
/// ALSA exclusive-hardening design spec). Toggling this only updates
|
||||
/// the DB row; applying the change to the live `DeviceReservation`
|
||||
/// guard is the caller's responsibility — see
|
||||
/// `AppState::apply_dac_reservation`.
|
||||
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET pw_force_bitperfect = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set pw_force_bitperfect: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
|
||||
let settings = self.get_settings()?;
|
||||
let value = &settings.quality_fallback_behavior;
|
||||
match value.as_str() {
|
||||
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
|
||||
_ => Ok("ask".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
|
||||
match behavior {
|
||||
"ask" | "always_fallback" | "always_skip" => {}
|
||||
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![behavior],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET normalization_target_lufs = ?1 WHERE id = 1",
|
||||
params![target as f64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set normalization target LUFS: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all audio settings to their default values
|
||||
pub fn reset_all(&self) -> Result<AudioSettings, String> {
|
||||
// ADR-003: quality_fallback_behavior must survive reset_all()
|
||||
let saved_fallback = self
|
||||
.get_quality_fallback_behavior()
|
||||
.unwrap_or_else(|_| "ask".to_string());
|
||||
|
||||
let defaults = AudioSettings::default();
|
||||
let backend_json: Option<String> = defaults
|
||||
.backend_type
|
||||
.map(|b| serde_json::to_string(&b))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
|
||||
let plugin_json: Option<String> = defaults
|
||||
.alsa_plugin
|
||||
.map(|p| serde_json::to_string(&p))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
|
||||
|
||||
// Serialize per-device limits (empty on reset)
|
||||
let limits_json = serde_json::to_string(&defaults.device_sample_rate_limits)
|
||||
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET
|
||||
output_device = ?1,
|
||||
exclusive_mode = ?2,
|
||||
dac_passthrough = ?3,
|
||||
preferred_sample_rate = ?4,
|
||||
backend_type = ?5,
|
||||
alsa_plugin = ?6,
|
||||
alsa_hardware_volume = ?7,
|
||||
stream_first_track = ?8,
|
||||
stream_buffer_seconds = ?9,
|
||||
streaming_only = ?10,
|
||||
limit_quality_to_device = ?11,
|
||||
device_max_sample_rate = ?12,
|
||||
normalization_enabled = ?13,
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18,
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20,
|
||||
reserve_dac_while_running = ?21
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
defaults.exclusive_mode as i64,
|
||||
defaults.dac_passthrough as i64,
|
||||
defaults.preferred_sample_rate.map(|r| r as i64),
|
||||
backend_json,
|
||||
plugin_json,
|
||||
defaults.alsa_hardware_volume as i64,
|
||||
defaults.stream_first_track as i64,
|
||||
defaults.stream_buffer_seconds as i64,
|
||||
defaults.streaming_only as i64,
|
||||
defaults.limit_quality_to_device as i64,
|
||||
defaults.device_max_sample_rate.map(|r| r as i64),
|
||||
defaults.normalization_enabled as i64,
|
||||
defaults.normalization_target_lufs as f64,
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
defaults.skip_sink_switch as i64,
|
||||
defaults.allow_quality_fallback as i64,
|
||||
defaults.reserve_dac_while_running as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![saved_fallback],
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore quality_fallback_behavior after reset: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = defaults;
|
||||
result.quality_fallback_behavior = saved_fallback;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
pub struct AudioSettingsState {
|
||||
pub store: Arc<Mutex<Option<AudioSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl AudioSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(AudioSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = AudioSettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock audio settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock audio settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub use qbz_audio::settings::{AudioSettings, AudioSettingsState, AudioSettingsStore};
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
@@ -1014,7 +270,9 @@ pub fn set_audio_skip_sink_switch(
|
||||
if enabled {
|
||||
let settings = store.get_settings()?;
|
||||
if settings.dac_passthrough {
|
||||
return Err("Cannot enable skip sink switch while DAC passthrough is active".to_string());
|
||||
return Err(
|
||||
"Cannot enable skip sink switch while DAC passthrough is active".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +1,11 @@
|
||||
//! Developer settings persistence
|
||||
//! Tauri adapter for developer settings.
|
||||
//!
|
||||
//! Stores developer-mode toggles (e.g. force DMA-BUF override for NVIDIA+Wayland).
|
||||
//! These settings are device-level (not per-user) and persist across sessions.
|
||||
//! The portable settings store lives in `qbz-app`; this module keeps the
|
||||
//! existing Tauri command surface unchanged.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeveloperSettings {
|
||||
pub force_dmabuf: bool,
|
||||
}
|
||||
|
||||
impl Default for DeveloperSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
force_dmabuf: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeveloperSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DeveloperSettingsStore {
|
||||
/// Lightweight read-only open for startup (before Tauri state).
|
||||
pub fn new_readonly() -> Result<Self, String> {
|
||||
let db_path = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("developer_settings.db");
|
||||
|
||||
if !db_path.exists() {
|
||||
return Err("Developer settings DB does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
&db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| format!("Failed to open developer settings database (readonly): {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("developer_settings.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for developer settings database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS developer_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
force_dmabuf INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO developer_settings (id, force_dmabuf) VALUES (1, 0);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create developer settings table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<DeveloperSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT force_dmabuf FROM developer_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(DeveloperSettings {
|
||||
force_dmabuf: row.get::<_, i64>(0)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get developer settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_force_dmabuf(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE developer_settings SET force_dmabuf = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set force_dmabuf: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for Tauri state management
|
||||
pub struct DeveloperSettingsState {
|
||||
pub store: Arc<Mutex<Option<DeveloperSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl DeveloperSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(DeveloperSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
pub use qbz_app::settings::developer::{
|
||||
DeveloperSettings, DeveloperSettingsState, DeveloperSettingsStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_developer_settings(
|
||||
|
||||
@@ -1,142 +1,15 @@
|
||||
//! Download settings persistence
|
||||
//! Tauri commands for download settings.
|
||||
//!
|
||||
//! Stores user preferences for download path and library integration.
|
||||
//! Persistence lives in `qbz-app`. Host filesystem validation stays here.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadSettings {
|
||||
pub download_root: String,
|
||||
pub show_in_library: bool,
|
||||
}
|
||||
|
||||
impl Default for DownloadSettings {
|
||||
fn default() -> Self {
|
||||
let default_root = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("qbz")
|
||||
.join("audio")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
download_root: default_root,
|
||||
// New installs surface offline-cached Qobuz tracks in the
|
||||
// Local Library by default — most users want them visible.
|
||||
// Existing users with a persisted value aren't affected:
|
||||
// INSERT OR IGNORE in open_at() only sets the default on
|
||||
// first-ever row creation.
|
||||
show_in_library: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DownloadSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DownloadSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open download settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for download settings database: {}", e))?;
|
||||
|
||||
let default_settings = DownloadSettings::default();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS download_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
download_root TEXT NOT NULL,
|
||||
show_in_library INTEGER NOT NULL DEFAULT 1
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create download settings table: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO download_settings (id, download_root, show_in_library)
|
||||
VALUES (1, ?1, ?2)",
|
||||
params![
|
||||
default_settings.download_root,
|
||||
default_settings.show_in_library as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize download settings: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "download_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "download_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<DownloadSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT download_root, show_in_library FROM download_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(DownloadSettings {
|
||||
download_root: row.get(0)?,
|
||||
show_in_library: row.get::<_, i64>(1)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get download settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_download_root(&self, path: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE download_settings SET download_root = ?1 WHERE id = 1",
|
||||
params![path],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set download root: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_show_in_library(&self, show: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE download_settings SET show_in_library = ?1 WHERE id = 1",
|
||||
params![show as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set show_in_library: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type DownloadSettingsState = Arc<Mutex<Option<DownloadSettingsStore>>>;
|
||||
|
||||
pub fn create_download_settings_state() -> Result<DownloadSettingsState, String> {
|
||||
let store = DownloadSettingsStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_download_settings_state() -> DownloadSettingsState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
pub use qbz_app::settings::download::{
|
||||
create_download_settings_state, create_empty_download_settings_state, DownloadSettings,
|
||||
DownloadSettingsState, DownloadSettingsStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_download_settings(
|
||||
state: tauri::State<DownloadSettingsState>,
|
||||
state: tauri::State<'_, DownloadSettingsState>,
|
||||
) -> Result<DownloadSettings, String> {
|
||||
log::info!("Command: get_download_settings");
|
||||
let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
@@ -147,11 +20,10 @@ pub fn get_download_settings(
|
||||
#[tauri::command]
|
||||
pub fn set_download_root(
|
||||
path: String,
|
||||
state: tauri::State<DownloadSettingsState>,
|
||||
state: tauri::State<'_, DownloadSettingsState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: set_download_root to: {}", path);
|
||||
|
||||
// Basic validation
|
||||
let path_obj = std::path::Path::new(&path);
|
||||
if !path_obj.exists() {
|
||||
return Err("Path does not exist".to_string());
|
||||
@@ -168,7 +40,7 @@ pub fn set_download_root(
|
||||
#[tauri::command]
|
||||
pub fn set_show_downloads_in_library(
|
||||
show: bool,
|
||||
state: tauri::State<DownloadSettingsState>,
|
||||
state: tauri::State<'_, DownloadSettingsState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: set_show_downloads_in_library to: {}", show);
|
||||
let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
@@ -189,7 +61,6 @@ pub fn validate_download_root(path: String) -> Result<bool, String> {
|
||||
return Err("Path exists but is not a directory".to_string());
|
||||
}
|
||||
|
||||
// Check write permissions by trying to create a temp file
|
||||
let test_file = path_obj.join(".qbz_write_test");
|
||||
match std::fs::write(&test_file, b"test") {
|
||||
Ok(_) => {
|
||||
|
||||
@@ -1,262 +1,9 @@
|
||||
use chrono::Utc;
|
||||
use md5::{Digest, Md5};
|
||||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
//! Tauri commands for favorites preferences.
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FavoritesPreferences {
|
||||
pub custom_icon_path: Option<String>,
|
||||
pub custom_icon_preset: Option<String>,
|
||||
pub icon_background: Option<String>,
|
||||
pub tab_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for FavoritesPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_icon_path: None,
|
||||
custom_icon_preset: Some("heart".to_string()),
|
||||
icon_background: None,
|
||||
tab_order: vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FavoritesPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl FavoritesPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS favorites_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
custom_icon_path TEXT,
|
||||
custom_icon_preset TEXT,
|
||||
tab_order TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create favorites preferences table: {}", e))?;
|
||||
|
||||
// Migration: Add icon_background column if it doesn't exist
|
||||
let has_icon_background = conn
|
||||
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
|
||||
.is_ok();
|
||||
|
||||
if !has_icon_background {
|
||||
conn.execute(
|
||||
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add icon_background column: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "favorites_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "favorites_preferences.db")
|
||||
}
|
||||
|
||||
fn favorites_icon_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or("Could not determine cache directory")?
|
||||
.join("qbz")
|
||||
.join("favorites");
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("Failed to create favorites icon directory: {}", e))?;
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
fn normalize_custom_icon_path(&self, path: String) -> Result<String, String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let source = Path::new(trimmed);
|
||||
if !source.exists() {
|
||||
return Err(format!("Source icon does not exist: {}", trimmed));
|
||||
}
|
||||
|
||||
let icon_dir = Self::favorites_icon_dir()?;
|
||||
if source.starts_with(&icon_dir) {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
|
||||
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("png");
|
||||
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(trimmed.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let filename = format!(
|
||||
"favorites_icon_{}_{}.{}",
|
||||
hash,
|
||||
Utc::now().timestamp(),
|
||||
extension
|
||||
);
|
||||
let dest_path = icon_dir.join(filename);
|
||||
|
||||
fs::copy(source, &dest_path)
|
||||
.map_err(|e| format!("Failed to copy favorites icon: {}", e))?;
|
||||
|
||||
Ok(dest_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<FavoritesPreferences, String> {
|
||||
let mut stmt = self.conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")
|
||||
.map_err(|e| format!("Failed to prepare select: {}", e))?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let custom_icon_path: Option<String> = row.get(0)?;
|
||||
let custom_icon_preset: Option<String> = row.get(1)?;
|
||||
let icon_background: Option<String> = row.get(2)?;
|
||||
let tab_order_str: String = row.get(3)?;
|
||||
|
||||
let custom_icon_path = custom_icon_path.and_then(|value| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
});
|
||||
|
||||
let tab_order: Vec<String> =
|
||||
serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
|
||||
vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(FavoritesPreferences {
|
||||
custom_icon_path,
|
||||
custom_icon_preset,
|
||||
icon_background,
|
||||
tab_order,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(mut prefs) => {
|
||||
if let Some(path) = prefs.custom_icon_path.clone() {
|
||||
match self.normalize_custom_icon_path(path) {
|
||||
Ok(resolved) => {
|
||||
let normalized = if resolved.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(resolved)
|
||||
};
|
||||
if normalized != prefs.custom_icon_path {
|
||||
prefs.custom_icon_path = normalized;
|
||||
let _ = self.save_preferences(prefs.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
prefs.custom_icon_path = None;
|
||||
let _ = self.save_preferences(prefs.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(prefs)
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
|
||||
Err(e) => Err(format!("Failed to query preferences: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(
|
||||
&self,
|
||||
mut prefs: FavoritesPreferences,
|
||||
) -> Result<FavoritesPreferences, String> {
|
||||
if let Some(path) = prefs.custom_icon_path.clone() {
|
||||
let resolved = self.normalize_custom_icon_path(path)?;
|
||||
if resolved.is_empty() {
|
||||
prefs.custom_icon_path = None;
|
||||
} else {
|
||||
prefs.custom_icon_path = Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order)
|
||||
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save preferences: {}", e))?;
|
||||
Ok(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FavoritesPreferencesState {
|
||||
pub store: Arc<Mutex<Option<FavoritesPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl FavoritesPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = FavoritesPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = FavoritesPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub use qbz_app::settings::favorites::{
|
||||
create_table, load_preferences, save_preferences, FavoritesPreferences,
|
||||
FavoritesPreferencesState, FavoritesPreferencesStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_favorites_preferences(
|
||||
@@ -282,81 +29,3 @@ pub fn save_favorites_preferences(
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.save_preferences(prefs)
|
||||
}
|
||||
|
||||
pub fn create_table(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS favorites_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
custom_icon_path TEXT,
|
||||
custom_icon_preset TEXT,
|
||||
tab_order TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migration: Add icon_background column if it doesn't exist
|
||||
let has_icon_background = conn
|
||||
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
|
||||
.is_ok();
|
||||
|
||||
if !has_icon_background {
|
||||
conn.execute(
|
||||
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_preferences(conn: &Connection) -> Result<FavoritesPreferences> {
|
||||
let mut stmt = conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let custom_icon_path: Option<String> = row.get(0)?;
|
||||
let custom_icon_preset: Option<String> = row.get(1)?;
|
||||
let icon_background: Option<String> = row.get(2)?;
|
||||
let tab_order_str: String = row.get(3)?;
|
||||
|
||||
let custom_icon_path = custom_icon_path.and_then(|value| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
});
|
||||
|
||||
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
|
||||
vec![
|
||||
"tracks".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
"playlists".to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(FavoritesPreferences {
|
||||
custom_icon_path,
|
||||
custom_icon_preset,
|
||||
icon_background,
|
||||
tab_order,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(prefs) => Ok(prefs),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(conn: &Connection, prefs: &FavoritesPreferences) -> Result<()> {
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
//! Graphics settings persistence
|
||||
//! Tauri graphics settings commands and startup status.
|
||||
//!
|
||||
//! Stores GPU/rendering preferences that take effect before WebView initialization.
|
||||
//! These settings are device-level (not per-user) and persist across sessions.
|
||||
//!
|
||||
//! - hardware_acceleration: GPU rendering toggle (default: on). Read at startup
|
||||
//! as the default value; env var QBZ_HARDWARE_ACCEL=0|1 overrides.
|
||||
//! - force_x11: force X11/XWayland backend on Wayland sessions (default: off)
|
||||
//! Env var QBZ_FORCE_X11=1|0 always overrides the stored value.
|
||||
//! - gdk_scale / gdk_dpi_scale: display scaling overrides for XWayland
|
||||
//! Portable graphics settings persistence lives in `qbz-app`. This module keeps
|
||||
//! Tauri command wrappers and process-local startup status only.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use qbz_app::settings::graphics::{GraphicsSettings, GraphicsSettingsStore};
|
||||
|
||||
// --- Global startup state (set once in main.rs, read-only thereafter) ---
|
||||
static GRAPHICS_USING_FALLBACK: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_IS_WAYLAND: AtomicBool = AtomicBool::new(false);
|
||||
@@ -50,162 +45,6 @@ pub fn is_using_graphics_fallback() -> bool {
|
||||
GRAPHICS_USING_FALLBACK.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphicsSettings {
|
||||
/// GPU rendering toggle. Read at startup as default; env var QBZ_HARDWARE_ACCEL overrides.
|
||||
pub hardware_acceleration: bool,
|
||||
/// Force X11 (XWayland) backend on Wayland sessions (requires restart)
|
||||
pub force_x11: bool,
|
||||
/// GDK_SCALE override for XWayland (None = auto). Integer values: "1", "2"
|
||||
pub gdk_scale: Option<String>,
|
||||
/// GDK_DPI_SCALE override for XWayland (None = auto). Float values: "0.5", "1", "1.5"
|
||||
pub gdk_dpi_scale: Option<String>,
|
||||
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo"
|
||||
pub gsk_renderer: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GraphicsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gdk_scale: None,
|
||||
gdk_dpi_scale: None,
|
||||
gsk_renderer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GraphicsSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl GraphicsSettingsStore {
|
||||
/// Lightweight read-only open for startup (before Tauri state).
|
||||
/// Opens existing DB without creating tables/running migrations.
|
||||
pub fn new_readonly() -> Result<Self, String> {
|
||||
let db_path = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("graphics_settings.db");
|
||||
|
||||
if !db_path.exists() {
|
||||
return Err("Graphics settings DB does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
&db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| format!("Failed to open graphics settings database (readonly): {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("graphics_settings.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS graphics_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
hardware_acceleration INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO graphics_settings (id, hardware_acceleration) VALUES (1, 0);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create graphics settings table: {}", e))?;
|
||||
|
||||
// Migrations: add columns (no-op if already present)
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE graphics_settings ADD COLUMN force_x11 INTEGER NOT NULL DEFAULT 0;",
|
||||
);
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer FROM graphics_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(GraphicsSettings {
|
||||
hardware_acceleration: row.get::<_, i64>(0)? != 0,
|
||||
force_x11: row.get::<_, i64>(1)? != 0,
|
||||
gdk_scale: row.get::<_, Option<String>>(2)?,
|
||||
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
|
||||
gsk_renderer: row.get::<_, Option<String>>(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get graphics settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_hardware_acceleration(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET hardware_acceleration = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set hardware_acceleration: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_force_x11(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET force_x11 = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set force_x11: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gdk_scale(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gdk_scale = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gdk_scale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gdk_dpi_scale(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gdk_dpi_scale = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gdk_dpi_scale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gsk_renderer(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gsk_renderer = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for Tauri state management
|
||||
pub struct GraphicsSettingsState {
|
||||
pub store: Arc<Mutex<Option<GraphicsSettingsStore>>>,
|
||||
@@ -317,6 +156,44 @@ pub fn set_gdk_dpi_scale(
|
||||
store.set_gdk_dpi_scale(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_preferred_gpu(
|
||||
state: tauri::State<'_, GraphicsSettingsState>,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[GraphicsSettings] Setting preferred_gpu to {:?} (restart required)",
|
||||
value
|
||||
);
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or("Graphics settings store not initialized")?;
|
||||
store.set_preferred_gpu(&value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_nvidia_compat_mode(
|
||||
state: tauri::State<'_, GraphicsSettingsState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[GraphicsSettings] Setting nvidia_compat_mode to {} (restart required)",
|
||||
enabled
|
||||
);
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or("Graphics settings store not initialized")?;
|
||||
store.set_nvidia_compat_mode(enabled)
|
||||
}
|
||||
|
||||
// --- Startup Status (set once at app launch, read-only thereafter) ---
|
||||
|
||||
/// Tracks graphics configuration status from startup
|
||||
|
||||
@@ -1,111 +1,8 @@
|
||||
//! Image cache settings persistence
|
||||
//! Tauri-facing re-exports for image cache settings.
|
||||
//!
|
||||
//! Stores user preferences for the Qobuz image cache:
|
||||
//! - enabled: whether to cache images locally (default: true)
|
||||
//! - max_size_mb: maximum cache size in megabytes (default: 200)
|
||||
//! Persistence lives in `qbz-app`. Image cache runtime, file management,
|
||||
//! stats, and clear behavior remain host-owned.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageCacheSettings {
|
||||
pub enabled: bool,
|
||||
pub max_size_mb: u32,
|
||||
}
|
||||
|
||||
impl Default for ImageCacheSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_size_mb: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsStore {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("image_cache_settings.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS image_cache_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 200
|
||||
);
|
||||
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
|
||||
VALUES (1, 1, 200);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ImageCacheSettings {
|
||||
enabled: row.get::<_, i32>(0)? != 0,
|
||||
max_size_mb: row.get::<_, u32>(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read image cache settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
|
||||
params![max_size_mb],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsState {
|
||||
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use qbz_app::settings::image_cache::{
|
||||
ImageCacheSettings, ImageCacheSettingsState, ImageCacheSettingsStore,
|
||||
};
|
||||
@@ -1,116 +1,9 @@
|
||||
//! Legal settings storage
|
||||
//!
|
||||
//! Persists user acceptance of legal terms:
|
||||
//! - qobuz_tos_accepted: User has accepted Qobuz Terms of Service
|
||||
//! Tauri commands for legal settings.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegalSettings {
|
||||
/// User has accepted Qobuz Terms of Service
|
||||
pub qobuz_tos_accepted: bool,
|
||||
}
|
||||
|
||||
impl Default for LegalSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
qobuz_tos_accepted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LegalSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LegalSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open legal settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for legal settings database: {}", e))?;
|
||||
|
||||
// Create table
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS legal_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
qobuz_tos_accepted INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create legal settings table: {}", e))?;
|
||||
|
||||
// Insert default row if it doesn't exist
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO legal_settings (id, qobuz_tos_accepted) VALUES (1, 0)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default legal settings: {}", e))?;
|
||||
|
||||
info!("[LegalSettings] Database initialized");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "legal_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "legal_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<LegalSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT qobuz_tos_accepted FROM legal_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let qobuz_tos_accepted: i32 = row.get(0)?;
|
||||
Ok(LegalSettings {
|
||||
qobuz_tos_accepted: qobuz_tos_accepted != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get legal settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_qobuz_tos_accepted(&self, accepted: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE legal_settings SET qobuz_tos_accepted = ? WHERE id = 1",
|
||||
params![accepted as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update ToS acceptance: {}", e))?;
|
||||
|
||||
info!("[LegalSettings] Qobuz ToS accepted: {}", accepted);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type LegalSettingsState = Arc<Mutex<Option<LegalSettingsStore>>>;
|
||||
|
||||
pub fn create_legal_settings_state() -> Result<LegalSettingsState, String> {
|
||||
let store = LegalSettingsStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_legal_settings_state() -> LegalSettingsState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
pub use qbz_app::settings::legal::{
|
||||
create_empty_legal_settings_state, create_legal_settings_state, LegalSettings,
|
||||
LegalSettingsState, LegalSettingsStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_legal_settings(
|
||||
|
||||
@@ -1,270 +1,8 @@
|
||||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
//! Tauri commands for library preferences.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FoldersViewMode {
|
||||
Flat,
|
||||
Tree,
|
||||
}
|
||||
|
||||
impl Default for FoldersViewMode {
|
||||
fn default() -> Self {
|
||||
FoldersViewMode::Flat
|
||||
}
|
||||
}
|
||||
|
||||
impl FoldersViewMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FoldersViewMode::Flat => "flat",
|
||||
FoldersViewMode::Tree => "tree",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"tree" => FoldersViewMode::Tree,
|
||||
_ => FoldersViewMode::Flat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibraryPreferences {
|
||||
pub tab_order: Vec<String>,
|
||||
pub hidden_tabs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub folders_view_mode: FoldersViewMode,
|
||||
/// Width (in CSS pixels) of the tree-mode left sidebar in the Folders
|
||||
/// tab. `None` means "use the frontend default" (currently 302px).
|
||||
/// Persisted on drag-end so the choice survives restarts. Bounds are
|
||||
/// enforced on the frontend (min 200px, max 40% of content area).
|
||||
#[serde(default)]
|
||||
pub folders_tree_sidebar_width: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for LibraryPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Default order matches the current LocalLibraryView tab list,
|
||||
// with the renamed 'folders' tab in place of the old 'albums'
|
||||
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
|
||||
// to this default.
|
||||
tab_order: vec![
|
||||
"tracks".to_string(),
|
||||
"folders".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
],
|
||||
hidden_tabs: vec![],
|
||||
folders_view_mode: FoldersViewMode::Flat,
|
||||
folders_tree_sidebar_width: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS library_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
tab_order TEXT NOT NULL,
|
||||
hidden_tabs TEXT NOT NULL DEFAULT '[]'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
|
||||
|
||||
// Idempotent migrations for columns added after the initial schema.
|
||||
// These ALTERs fail when the column already exists; that failure is
|
||||
// expected and ignored.
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE library_preferences ADD COLUMN folders_view_mode TEXT DEFAULT 'flat'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE library_preferences ADD COLUMN folders_tree_sidebar_width INTEGER DEFAULT NULL",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width \
|
||||
FROM library_preferences WHERE id = 1",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare select: {}", e))?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let tab_order_str: String = row.get(0)?;
|
||||
let hidden_tabs_str: String = row.get(1)?;
|
||||
let folders_view_mode_str: Option<String> = row.get(2)?;
|
||||
let folders_tree_sidebar_width: Option<i64> = row.get(3)?;
|
||||
|
||||
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
|
||||
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
|
||||
let hidden_tabs: Vec<String> =
|
||||
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
|
||||
let folders_view_mode = folders_view_mode_str
|
||||
.map(|s| FoldersViewMode::from_str(&s))
|
||||
.unwrap_or_default();
|
||||
// Stored as INTEGER in SQLite; clamp to non-negative on read.
|
||||
// Frontend re-clamps against the live max anyway.
|
||||
let folders_tree_sidebar_width = folders_tree_sidebar_width
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| v as u32);
|
||||
|
||||
Ok(LibraryPreferences {
|
||||
tab_order,
|
||||
hidden_tabs,
|
||||
folders_view_mode,
|
||||
folders_tree_sidebar_width,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(prefs) => Ok(prefs),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
|
||||
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(
|
||||
&self,
|
||||
prefs: LibraryPreferences,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order)
|
||||
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
|
||||
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
|
||||
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO library_preferences \
|
||||
(id, tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width) \
|
||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
tab_order_str,
|
||||
hidden_tabs_str,
|
||||
prefs.folders_view_mode.as_str(),
|
||||
prefs.folders_tree_sidebar_width.map(|v| v as i64),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
pub fn set_folders_tree_sidebar_width(&self, width: u32) -> Result<(), String> {
|
||||
// Same upsert pattern as `set_folders_view_mode`: try the cheap
|
||||
// UPDATE first; if no row exists yet, fall back to inserting a
|
||||
// default row carrying just the new width.
|
||||
let updated = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE library_preferences SET folders_tree_sidebar_width = ?1 WHERE id = 1",
|
||||
params![width as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set folders_tree_sidebar_width: {}", e))?;
|
||||
|
||||
if updated == 0 {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_tree_sidebar_width = Some(width);
|
||||
self.save_preferences(prefs)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folders_view_mode(&self, mode: FoldersViewMode) -> Result<(), String> {
|
||||
// Ensure the row exists before updating; if no preferences have been
|
||||
// saved yet, fall back to inserting a default row carrying the new
|
||||
// folders_view_mode value.
|
||||
let updated = self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE library_preferences SET folders_view_mode = ?1 WHERE id = 1",
|
||||
params![mode.as_str()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set folders_view_mode: {}", e))?;
|
||||
|
||||
if updated == 0 {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = mode;
|
||||
self.save_preferences(prefs)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesState {
|
||||
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = LibraryPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = LibraryPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub use qbz_app::settings::library::{
|
||||
FoldersViewMode, LibraryPreferences, LibraryPreferencesState, LibraryPreferencesStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_library_preferences(
|
||||
@@ -290,152 +28,3 @@ pub fn save_library_preferences(
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.save_preferences(prefs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn fresh_store() -> (tempfile::TempDir, LibraryPreferencesStore) {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let store =
|
||||
LibraryPreferencesStore::new_at(dir.path()).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_flat() {
|
||||
let prefs = LibraryPreferences::default();
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_folders_view_mode_defaults_to_flat() {
|
||||
let legacy = r#"{
|
||||
"tab_order": ["tracks", "folders", "albums", "artists"],
|
||||
"hidden_tabs": []
|
||||
}"#;
|
||||
let prefs: LibraryPreferences =
|
||||
serde_json::from_str(legacy).expect("parse legacy json");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_tree_set() {
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = FoldersViewMode::Tree;
|
||||
let json = serde_json::to_string(&prefs).expect("serialize");
|
||||
let parsed: LibraryPreferences =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(parsed.folders_view_mode, FoldersViewMode::Tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_view_mode_str_round_trip() {
|
||||
assert_eq!(FoldersViewMode::Flat.as_str(), "flat");
|
||||
assert_eq!(FoldersViewMode::Tree.as_str(), "tree");
|
||||
assert_eq!(FoldersViewMode::from_str("flat"), FoldersViewMode::Flat);
|
||||
assert_eq!(FoldersViewMode::from_str("tree"), FoldersViewMode::Tree);
|
||||
assert_eq!(
|
||||
FoldersViewMode::from_str("garbage"),
|
||||
FoldersViewMode::Flat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_returns_default_folders_view_mode() {
|
||||
let (_dir, store) = fresh_store();
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_view_mode_persists_on_empty_table() {
|
||||
let (_dir, store) = fresh_store();
|
||||
store
|
||||
.set_folders_view_mode(FoldersViewMode::Tree)
|
||||
.expect("set mode");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_view_mode_updates_existing_row() {
|
||||
let (_dir, store) = fresh_store();
|
||||
|
||||
// Seed a row with the default save path so the UPDATE branch runs.
|
||||
let mut seeded = LibraryPreferences::default();
|
||||
seeded.tab_order = vec!["tracks".into(), "albums".into()];
|
||||
store.save_preferences(seeded).expect("save seed");
|
||||
|
||||
store
|
||||
.set_folders_view_mode(FoldersViewMode::Tree)
|
||||
.expect("set mode");
|
||||
let after = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(after.folders_view_mode, FoldersViewMode::Tree);
|
||||
// Sibling fields preserved.
|
||||
assert_eq!(
|
||||
after.tab_order,
|
||||
vec!["tracks".to_string(), "albums".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_preferences_persists_folders_view_mode() {
|
||||
let (_dir, store) = fresh_store();
|
||||
let mut prefs = LibraryPreferences::default();
|
||||
prefs.folders_view_mode = FoldersViewMode::Tree;
|
||||
store.save_preferences(prefs).expect("save");
|
||||
|
||||
let loaded = store.get_preferences().expect("load");
|
||||
assert_eq!(loaded.folders_view_mode, FoldersViewMode::Tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_returns_none_folders_tree_sidebar_width() {
|
||||
let (_dir, store) = fresh_store();
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_tree_sidebar_width_persists_on_empty_table() {
|
||||
let (_dir, store) = fresh_store();
|
||||
store
|
||||
.set_folders_tree_sidebar_width(512)
|
||||
.expect("set width");
|
||||
let prefs = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, Some(512));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_folders_tree_sidebar_width_updates_existing_row() {
|
||||
let (_dir, store) = fresh_store();
|
||||
|
||||
let mut seeded = LibraryPreferences::default();
|
||||
seeded.tab_order = vec!["tracks".into(), "folders".into()];
|
||||
store.save_preferences(seeded).expect("save seed");
|
||||
|
||||
store
|
||||
.set_folders_tree_sidebar_width(640)
|
||||
.expect("set width");
|
||||
let after = store.get_preferences().expect("get prefs");
|
||||
assert_eq!(after.folders_tree_sidebar_width, Some(640));
|
||||
assert_eq!(
|
||||
after.tab_order,
|
||||
vec!["tracks".to_string(), "folders".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_sidebar_width_defaults_to_none() {
|
||||
let legacy = r#"{
|
||||
"tab_order": ["tracks", "folders", "albums", "artists"],
|
||||
"hidden_tabs": [],
|
||||
"folders_view_mode": "tree"
|
||||
}"#;
|
||||
let prefs: LibraryPreferences =
|
||||
serde_json::from_str(legacy).expect("parse legacy json");
|
||||
assert_eq!(prefs.folders_tree_sidebar_width, None);
|
||||
}
|
||||
}
|
||||
@@ -1,364 +1,12 @@
|
||||
//! Playback preferences
|
||||
//!
|
||||
//! Stores user preferences for playback behavior (autoplay mode, etc.)
|
||||
//! Tauri commands for playback and playback-adjacent UI preferences.
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AutoplayMode {
|
||||
/// Continue playing within the source (album, playlist, etc.)
|
||||
#[serde(rename = "continue")]
|
||||
ContinueWithinSource,
|
||||
/// Play only the selected track, then stop
|
||||
#[serde(rename = "track_only")]
|
||||
PlayTrackOnly,
|
||||
/// Create infinite radio when queue ends (based on recent tracks)
|
||||
#[serde(rename = "infinite")]
|
||||
InfiniteRadio,
|
||||
}
|
||||
|
||||
impl Default for AutoplayMode {
|
||||
fn default() -> Self {
|
||||
Self::ContinueWithinSource
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoplayMode {
|
||||
fn to_db_value(&self) -> &'static str {
|
||||
match self {
|
||||
AutoplayMode::ContinueWithinSource => "continue",
|
||||
AutoplayMode::PlayTrackOnly => "track_only",
|
||||
AutoplayMode::InfiniteRadio => "infinite",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_db_value(s: &str) -> Self {
|
||||
match s {
|
||||
"track_only" => AutoplayMode::PlayTrackOnly,
|
||||
"infinite" => AutoplayMode::InfiniteRadio,
|
||||
_ => AutoplayMode::ContinueWithinSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybackPreferences {
|
||||
pub autoplay_mode: AutoplayMode,
|
||||
pub show_context_icon: bool,
|
||||
pub persist_session: bool,
|
||||
/// Sub-preference of `persist_session`. When true, restoring a
|
||||
/// session also seeks to `current_position_secs` of the saved
|
||||
/// track. When false (default), the saved track is shown paused at
|
||||
/// 0:00 and the user starts the next listen fresh — the common
|
||||
/// preference for "open app the next morning to a clean slate"
|
||||
/// (issue #317).
|
||||
pub resume_playback_position: bool,
|
||||
}
|
||||
|
||||
impl Default for PlaybackPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
autoplay_mode: AutoplayMode::ContinueWithinSource,
|
||||
show_context_icon: false,
|
||||
persist_session: false,
|
||||
resume_playback_position: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlaybackPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl PlaybackPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for playback preferences database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Step 1: Create table with old schema (for new installs) or do nothing if exists
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS playback_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
|
||||
|
||||
// Step 2: Check if show_context_icon column exists
|
||||
let column_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='show_context_icon'",
|
||||
[],
|
||||
|row| {
|
||||
let count: i32 = row.get(0)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
info!(
|
||||
"[PlaybackPrefs] Column show_context_icon exists: {}",
|
||||
column_exists
|
||||
);
|
||||
|
||||
// Step 3: Add column if it doesn't exist (migration for existing users)
|
||||
if !column_exists {
|
||||
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
|
||||
[]
|
||||
).map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
|
||||
info!("[PlaybackPrefs] Migration successful");
|
||||
}
|
||||
|
||||
// Step 3b: Check if persist_session column exists
|
||||
let persist_session_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='persist_session'",
|
||||
[],
|
||||
|row| {
|
||||
let count: i32 = row.get(0)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !persist_session_exists {
|
||||
info!("[PlaybackPrefs] Migrating: adding persist_session column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
|
||||
[]
|
||||
).map_err(|e| format!("Failed to add persist_session column: {}", e))?;
|
||||
info!("[PlaybackPrefs] persist_session migration successful");
|
||||
}
|
||||
|
||||
// Step 3c: Check if resume_playback_position column exists
|
||||
let resume_position_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='resume_playback_position'",
|
||||
[],
|
||||
|row| {
|
||||
let count: i32 = row.get(0)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !resume_position_exists {
|
||||
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
|
||||
conn.execute(
|
||||
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
|
||||
[]
|
||||
).map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
|
||||
info!("[PlaybackPrefs] resume_playback_position migration successful");
|
||||
}
|
||||
|
||||
// Step 4: Insert default row if it doesn't exist
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
|
||||
VALUES (1, 'continue', 0, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "playback_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "playback_preferences.db")
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let autoplay_str: String = row.get(0)?;
|
||||
let show_icon: i32 = row.get(1)?;
|
||||
let persist: i32 = row.get(2)?;
|
||||
let resume_pos: i32 = row.get(3)?;
|
||||
Ok(PlaybackPreferences {
|
||||
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
|
||||
show_context_icon: show_icon != 0,
|
||||
persist_session: persist != 0,
|
||||
resume_playback_position: resume_pos != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get playback preferences: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
|
||||
params![mode.to_db_value()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
|
||||
params![if show { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
|
||||
params![if persist { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set persist session: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
|
||||
params![if resume { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all playback preferences to their default values
|
||||
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
|
||||
let defaults = PlaybackPreferences::default();
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
|
||||
params![
|
||||
defaults.autoplay_mode.to_db_value(),
|
||||
if defaults.show_context_icon { 1 } else { 0 },
|
||||
if defaults.persist_session { 1 } else { 0 },
|
||||
if defaults.resume_playback_position { 1 } else { 0 },
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
|
||||
Ok(defaults)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state wrapper for thread-safe access
|
||||
pub struct PlaybackPreferencesState {
|
||||
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl PlaybackPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = PlaybackPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_preferences()
|
||||
}
|
||||
|
||||
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_autoplay_mode(mode)
|
||||
}
|
||||
|
||||
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_show_context_icon(show)
|
||||
}
|
||||
|
||||
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_persist_session(persist)
|
||||
}
|
||||
|
||||
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_resume_playback_position(resume)
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
pub use qbz_app::settings::playback::{
|
||||
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, PlaybackPreferencesStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_playback_preferences(
|
||||
state: tauri::State<PlaybackPreferencesState>,
|
||||
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||
) -> Result<PlaybackPreferences, String> {
|
||||
state.get_preferences()
|
||||
}
|
||||
@@ -366,7 +14,7 @@ pub fn get_playback_preferences(
|
||||
#[tauri::command]
|
||||
pub fn set_autoplay_mode(
|
||||
mode: String,
|
||||
state: tauri::State<PlaybackPreferencesState>,
|
||||
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||
) -> Result<(), String> {
|
||||
let autoplay_mode = match mode.as_str() {
|
||||
"continue" => AutoplayMode::ContinueWithinSource,
|
||||
@@ -380,7 +28,7 @@ pub fn set_autoplay_mode(
|
||||
#[tauri::command]
|
||||
pub fn set_show_context_icon(
|
||||
show: bool,
|
||||
state: tauri::State<PlaybackPreferencesState>,
|
||||
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||
) -> Result<(), String> {
|
||||
state.set_show_context_icon(show)
|
||||
}
|
||||
@@ -388,7 +36,7 @@ pub fn set_show_context_icon(
|
||||
#[tauri::command]
|
||||
pub fn set_persist_session(
|
||||
persist: bool,
|
||||
state: tauri::State<PlaybackPreferencesState>,
|
||||
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||
) -> Result<(), String> {
|
||||
state.set_persist_session(persist)
|
||||
}
|
||||
@@ -396,7 +44,7 @@ pub fn set_persist_session(
|
||||
#[tauri::command]
|
||||
pub fn set_resume_playback_position(
|
||||
resume: bool,
|
||||
state: tauri::State<PlaybackPreferencesState>,
|
||||
state: tauri::State<'_, PlaybackPreferencesState>,
|
||||
) -> Result<(), String> {
|
||||
state.set_resume_playback_position(resume)
|
||||
}
|
||||
@@ -1,532 +1,9 @@
|
||||
//! Remote control API settings
|
||||
//! Tauri-facing re-exports for remote control settings.
|
||||
//!
|
||||
//! Stores user preferences for the local REST/WebSocket server:
|
||||
//! - enabled: turn the API server on/off
|
||||
//! - port: TCP port for the API server
|
||||
//! - token: pairing token used by the remote control PWA
|
||||
//! Persistence lives in `qbz-app`. API server lifecycle, TLS, QR generation,
|
||||
//! CORS application, and live restart behavior remain host-owned.
|
||||
|
||||
use base64::Engine;
|
||||
use rand::RngExt;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RemoteControlSettings {
|
||||
pub enabled: bool,
|
||||
pub port: u16,
|
||||
pub secure: bool,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Default for RemoteControlSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: 8182,
|
||||
secure: true, // HTTPS by default for security
|
||||
token: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RemoteControlSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl RemoteControlSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for remote control settings database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS remote_control_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
port INTEGER NOT NULL DEFAULT 8182,
|
||||
secure INTEGER NOT NULL DEFAULT 1,
|
||||
token TEXT NOT NULL DEFAULT ''
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create remote control settings table: {}", e))?;
|
||||
|
||||
ensure_secure_column(&conn)?;
|
||||
|
||||
let token = generate_token();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO remote_control_settings (id, enabled, port, secure, token)
|
||||
VALUES (1, 0, 8182, 0, ?1)",
|
||||
params![token],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default remote control settings: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
|
||||
let mut settings = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT enabled, port, secure, token FROM remote_control_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let enabled: i32 = row.get(0)?;
|
||||
let port: i64 = row.get(1)?;
|
||||
let secure: i32 = row.get(2)?;
|
||||
let token: String = row.get(3)?;
|
||||
Ok(RemoteControlSettings {
|
||||
enabled: enabled != 0,
|
||||
port: port as u16,
|
||||
secure: secure != 0,
|
||||
token,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get remote control settings: {}", e))?;
|
||||
|
||||
if settings.token.is_empty() {
|
||||
settings.token = generate_token();
|
||||
self.set_token(&settings.token)?;
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![if enabled { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_port(&self, port: u16) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET port = ?1 WHERE id = 1",
|
||||
params![port as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control port: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET secure = ?1 WHERE id = 1",
|
||||
params![if secure { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control secure: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_token(&self, token: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE remote_control_settings SET token = ?1 WHERE id = 1",
|
||||
params![token],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set remote control token: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn regenerate_token(&self) -> Result<String, String> {
|
||||
let token = generate_token();
|
||||
self.set_token(&token)?;
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state wrapper for thread-safe access
|
||||
pub struct RemoteControlSettingsState {
|
||||
pub store: Arc<Mutex<Option<RemoteControlSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl RemoteControlSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = RemoteControlSettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_enabled(enabled)
|
||||
}
|
||||
|
||||
pub fn set_port(&self, port: u16) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_port(port)
|
||||
}
|
||||
|
||||
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_secure(secure)
|
||||
}
|
||||
|
||||
pub fn regenerate_token(&self) -> Result<String, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.regenerate_token()
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn ensure_secure_column(conn: &Connection) -> Result<(), String> {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(remote_control_settings)")
|
||||
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| format!("Schema read error: {}", e))?
|
||||
{
|
||||
let name: String = row
|
||||
.get(1)
|
||||
.map_err(|e| format!("Schema read error: {}", e))?;
|
||||
if name == "secure" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"ALTER TABLE remote_control_settings ADD COLUMN secure INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to migrate remote control settings: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Allowed Origins for CORS
|
||||
// ============================================================================
|
||||
|
||||
/// Default allowed origins for the PWA
|
||||
const DEFAULT_ALLOWED_ORIGINS: &[&str] = &[
|
||||
"vicrodh.github.io",
|
||||
"control.qbz.lol",
|
||||
"www.control.qbz.lol",
|
||||
];
|
||||
|
||||
/// Allowed origin entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AllowedOrigin {
|
||||
pub id: i64,
|
||||
pub origin: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// Store for allowed CORS origins
|
||||
pub struct AllowedOriginsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AllowedOriginsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
|
||||
|
||||
// Create allowed_origins table
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS allowed_origins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
origin TEXT NOT NULL UNIQUE,
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create allowed_origins table: {}", e))?;
|
||||
|
||||
// Insert default origins if table is empty
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM allowed_origins", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if count == 0 {
|
||||
for origin in DEFAULT_ALLOWED_ORIGINS {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
|
||||
params![origin],
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "remote_control_settings.db")
|
||||
}
|
||||
|
||||
/// Get all allowed origins
|
||||
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
|
||||
let mut stmt = self.conn
|
||||
.prepare("SELECT id, origin, is_default FROM allowed_origins ORDER BY is_default DESC, origin ASC")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let origins = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AllowedOrigin {
|
||||
id: row.get(0)?,
|
||||
origin: row.get(1)?,
|
||||
is_default: row.get::<_, i32>(2)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query origins: {}", e))?;
|
||||
|
||||
origins
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to collect origins: {}", e))
|
||||
}
|
||||
|
||||
/// Check if an origin is allowed
|
||||
pub fn is_origin_allowed(&self, origin: &str) -> bool {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM allowed_origins WHERE origin = ?1",
|
||||
params![origin],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Add a new allowed origin
|
||||
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
|
||||
// Normalize origin (lowercase, trim)
|
||||
let normalized = origin.trim().to_lowercase();
|
||||
|
||||
if normalized.is_empty() {
|
||||
return Err("Origin cannot be empty".to_string());
|
||||
}
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO allowed_origins (origin, is_default) VALUES (?1, 0)",
|
||||
params![normalized],
|
||||
)
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("UNIQUE constraint") {
|
||||
"Origin already exists".to_string()
|
||||
} else {
|
||||
format!("Failed to add origin: {}", e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let id = self.conn.last_insert_rowid();
|
||||
Ok(AllowedOrigin {
|
||||
id,
|
||||
origin: normalized,
|
||||
is_default: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove an allowed origin by ID
|
||||
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
|
||||
let affected = self
|
||||
.conn
|
||||
.execute("DELETE FROM allowed_origins WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to remove origin: {}", e))?;
|
||||
|
||||
if affected == 0 {
|
||||
return Err("Origin not found".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore default origins (adds missing defaults back)
|
||||
pub fn restore_defaults(&self) -> Result<(), String> {
|
||||
for origin in DEFAULT_ALLOWED_ORIGINS {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
|
||||
params![origin],
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state wrapper for thread-safe access to allowed origins
|
||||
pub struct AllowedOriginsState {
|
||||
pub store: Arc<Mutex<Option<AllowedOriginsStore>>>,
|
||||
}
|
||||
|
||||
impl AllowedOriginsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = AllowedOriginsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_origins()
|
||||
}
|
||||
|
||||
pub fn is_origin_allowed(&self, origin: &str) -> bool {
|
||||
self.store
|
||||
.lock()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|s| s.is_origin_allowed(origin))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.add_origin(origin)
|
||||
}
|
||||
|
||||
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.remove_origin(id)
|
||||
}
|
||||
|
||||
pub fn restore_defaults(&self) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {}", e))?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.restore_defaults()
|
||||
}
|
||||
}
|
||||
pub use qbz_app::settings::remote_control::{
|
||||
AllowedOrigin, AllowedOriginsState, AllowedOriginsStore, RemoteControlSettings,
|
||||
RemoteControlSettingsState, RemoteControlSettingsStore,
|
||||
};
|
||||
@@ -1,182 +1,4 @@
|
||||
//! Subscription validity tracking (for offline download compliance)
|
||||
//!
|
||||
//! Tracks when a user was first observed without a valid subscription. If the
|
||||
//! invalid state persists for more than a grace period, offline downloads are
|
||||
//! purged.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// How long we keep honoring offline access after the server first
|
||||
/// reports an invalid subscription.
|
||||
///
|
||||
/// Qobuz's own mobile app gives a 30-day offline grace window, so we
|
||||
/// match that — this is the correct posture for the compliance story
|
||||
/// with Qobuz (we're not weaker than them, but we're also not
|
||||
/// suspiciously more lenient). Shorter windows would punish users on
|
||||
/// flaky networks; longer windows would be a compliance red flag.
|
||||
///
|
||||
/// NOTE: the useful protection against "bulk-download and ghost"
|
||||
/// attacks comes from the CMAF-at-rest offline cache (v2 format), not
|
||||
/// from aggressive revalidation. This grace period is a belt on top of
|
||||
/// those suspenders, not the primary defense.
|
||||
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60; // 30 days — Qobuz mobile parity
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionState {
|
||||
pub invalid_since: Option<i64>,
|
||||
pub last_invalid_at: Option<i64>,
|
||||
pub last_valid_at: Option<i64>,
|
||||
pub last_checked_at: Option<i64>,
|
||||
pub downloads_purged_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for SubscriptionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
invalid_since: None,
|
||||
last_invalid_at: None,
|
||||
last_valid_at: None,
|
||||
last_checked_at: None,
|
||||
downloads_purged_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SubscriptionStateStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl SubscriptionStateStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to enable WAL for subscription state database: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS subscription_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
invalid_since INTEGER,
|
||||
last_invalid_at INTEGER,
|
||||
last_valid_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
downloads_purged_at INTEGER
|
||||
);
|
||||
INSERT OR IGNORE INTO subscription_state (id) VALUES (1);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create subscription state table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "subscription_state.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "subscription_state.db")
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> Result<SubscriptionState, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at
|
||||
FROM subscription_state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(SubscriptionState {
|
||||
invalid_since: row.get(0)?,
|
||||
last_invalid_at: row.get(1)?,
|
||||
last_valid_at: row.get(2)?,
|
||||
last_checked_at: row.get(3)?,
|
||||
downloads_purged_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read subscription state: {}", e))
|
||||
}
|
||||
|
||||
pub fn mark_valid(&self, now: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state
|
||||
SET invalid_since = NULL,
|
||||
last_valid_at = ?1,
|
||||
last_checked_at = ?1
|
||||
WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_invalid(&self, now: i64) -> Result<(), String> {
|
||||
// Only set invalid_since if it's not already set (first observation).
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state
|
||||
SET invalid_since = COALESCE(invalid_since, ?1),
|
||||
last_invalid_at = ?1,
|
||||
last_checked_at = ?1
|
||||
WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_offline_cache_purged(&self, now: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1",
|
||||
params![now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update purge timestamp: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_purge_offline_cache(&self, now: i64) -> Result<bool, String> {
|
||||
let state = self.get_state()?;
|
||||
let Some(invalid_since) = state.invalid_since else {
|
||||
return Ok(false);
|
||||
};
|
||||
if now - invalid_since < GRACE_PERIOD_SECS {
|
||||
return Ok(false);
|
||||
}
|
||||
// Purge at most once per invalid period.
|
||||
if let Some(purged_at) = state.downloads_purged_at {
|
||||
if purged_at >= invalid_since {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SubscriptionStateState = Arc<Mutex<Option<SubscriptionStateStore>>>;
|
||||
|
||||
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
|
||||
let store = SubscriptionStateStore::new()?;
|
||||
Ok(Arc::new(Mutex::new(Some(store))))
|
||||
}
|
||||
|
||||
pub fn create_empty_subscription_state() -> SubscriptionStateState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
pub use qbz_app::settings::subscription::{
|
||||
create_empty_subscription_state, create_subscription_state, SubscriptionState,
|
||||
SubscriptionStateState, SubscriptionStateStore,
|
||||
};
|
||||
@@ -1,308 +1,47 @@
|
||||
//! Tray icon settings
|
||||
//!
|
||||
//! Stores user preferences for system tray behavior:
|
||||
//! - enable_tray: Show tray icon (requires restart)
|
||||
//! - minimize_to_tray: Hide to tray when minimizing
|
||||
//! - close_to_tray: Hide to tray when closing window
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TraySettings {
|
||||
/// Show system tray icon (requires restart to take effect)
|
||||
pub enable_tray: bool,
|
||||
/// Hide window to tray when clicking minimize
|
||||
pub minimize_to_tray: bool,
|
||||
/// Hide window to tray instead of quitting when clicking close
|
||||
pub close_to_tray: bool,
|
||||
/// Tray icon variant override:
|
||||
/// - "auto" (default; follows system color scheme),
|
||||
/// - "mono-light" (light-coloured glyph for dark panels — useful
|
||||
/// on GNOME where the top bar is permanently dark),
|
||||
/// - "mono-dark" (dark-coloured glyph for light panels),
|
||||
/// - "color" (full colour vinyl logo).
|
||||
#[serde(default = "default_tray_icon_theme")]
|
||||
pub tray_icon_theme: String,
|
||||
}
|
||||
|
||||
fn default_tray_icon_theme() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
/// Coerce free-form values to the supported set. Anything outside the
|
||||
/// supported list falls back to "auto".
|
||||
///
|
||||
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
|
||||
/// relative to the labels users saw (picking "Light" got the dark
|
||||
/// glyph and vice-versa). They're remapped here to the value that
|
||||
/// matches the user's original intent.
|
||||
pub fn normalize_tray_icon_theme(input: &str) -> String {
|
||||
match input {
|
||||
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
|
||||
"light" => "mono-light".to_string(),
|
||||
"dark" => "mono-dark".to_string(),
|
||||
_ => "auto".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TraySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_tray: true,
|
||||
minimize_to_tray: false,
|
||||
close_to_tray: false,
|
||||
tray_icon_theme: default_tray_icon_theme(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TraySettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl TraySettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
|
||||
|
||||
// Create table
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tray_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enable_tray INTEGER NOT NULL DEFAULT 1,
|
||||
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
|
||||
close_to_tray INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
|
||||
|
||||
// Migration: add tray_icon_theme column if it doesn't exist (older DBs).
|
||||
let has_theme_column: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
|
||||
if has_theme_column == 0 {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
|
||||
)
|
||||
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
|
||||
}
|
||||
|
||||
// Insert default row if it doesn't exist
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
|
||||
VALUES (1, 1, 0, 0, 'auto')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
|
||||
|
||||
info!("[TraySettings] Database initialized");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "tray_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "tray_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<TraySettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let enable_tray: i32 = row.get(0)?;
|
||||
let minimize_to_tray: i32 = row.get(1)?;
|
||||
let close_to_tray: i32 = row.get(2)?;
|
||||
let tray_icon_theme: String = row.get(3)?;
|
||||
Ok(TraySettings {
|
||||
enable_tray: enable_tray != 0,
|
||||
minimize_to_tray: minimize_to_tray != 0,
|
||||
close_to_tray: close_to_tray != 0,
|
||||
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get tray settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
|
||||
params![if value { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
|
||||
let normalized = normalize_tray_icon_theme(value);
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
|
||||
params![normalized],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state wrapper for thread-safe access
|
||||
pub struct TraySettingsState {
|
||||
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
|
||||
}
|
||||
|
||||
impl TraySettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = TraySettingsStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = TraySettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<TraySettings, String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_enable_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_minimize_to_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_close_to_tray(value)
|
||||
}
|
||||
|
||||
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
|
||||
let guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock tray settings store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.set_tray_icon_theme(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tray_settings(state: tauri::State<TraySettingsState>) -> Result<TraySettings, String> {
|
||||
state.get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_enable_tray(value: bool, state: tauri::State<TraySettingsState>) -> Result<(), String> {
|
||||
info!(
|
||||
"[TraySettings] Setting enable_tray to {} (restart required)",
|
||||
value
|
||||
);
|
||||
state.set_enable_tray(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_minimize_to_tray(
|
||||
value: bool,
|
||||
state: tauri::State<TraySettingsState>,
|
||||
) -> Result<(), String> {
|
||||
info!("[TraySettings] Setting minimize_to_tray to {}", value);
|
||||
state.set_minimize_to_tray(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_close_to_tray(
|
||||
value: bool,
|
||||
state: tauri::State<TraySettingsState>,
|
||||
) -> Result<(), String> {
|
||||
info!("[TraySettings] Setting close_to_tray to {}", value);
|
||||
state.set_close_to_tray(value)
|
||||
}
|
||||
//! Tauri commands for tray settings.
|
||||
//!
|
||||
//! Persisted tray preferences live in `qbz-app`. Runtime tray creation,
|
||||
//! icon updates, window behavior, and emitted events remain Tauri-owned.
|
||||
|
||||
use log::info;
|
||||
|
||||
pub use qbz_app::settings::tray::{
|
||||
normalize_tray_icon_theme, TraySettings, TraySettingsState, TraySettingsStore,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tray_settings(
|
||||
state: tauri::State<'_, TraySettingsState>,
|
||||
) -> Result<TraySettings, String> {
|
||||
state.get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_enable_tray(
|
||||
value: bool,
|
||||
state: tauri::State<'_, TraySettingsState>,
|
||||
) -> Result<(), String> {
|
||||
info!(
|
||||
"[TraySettings] Setting enable_tray to {} (restart required)",
|
||||
value
|
||||
);
|
||||
state.set_enable_tray(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_minimize_to_tray(
|
||||
value: bool,
|
||||
state: tauri::State<'_, TraySettingsState>,
|
||||
) -> Result<(), String> {
|
||||
info!("[TraySettings] Setting minimize_to_tray to {}", value);
|
||||
state.set_minimize_to_tray(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_close_to_tray(
|
||||
value: bool,
|
||||
state: tauri::State<'_, TraySettingsState>,
|
||||
) -> Result<(), String> {
|
||||
info!("[TraySettings] Setting close_to_tray to {}", value);
|
||||
state.set_close_to_tray(value)
|
||||
}
|
||||
@@ -130,10 +130,11 @@ pub fn v2_discord_rpc_update(
|
||||
// Bridge the Flatpak sandbox to Discord's IPC socket if we're
|
||||
// running under Flatpak. No-op outside that context.
|
||||
prepare_flatpak_discord_socket_links();
|
||||
if let Ok(mut client) = DiscordIpcClient::new(DISCORD_APP_ID) {
|
||||
if client.connect().is_ok() {
|
||||
*guard = Some(client);
|
||||
}
|
||||
// discord-rich-presence 1.0+: DiscordIpcClient::new is infallible
|
||||
// (returns Self directly); failures show up at connect() time.
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||
if client.connect().is_ok() {
|
||||
*guard = Some(client);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Tauri adapter for host GPU detection.
|
||||
//!
|
||||
//! The portable detection and preference parsing live in `qbz-app`; Tauri keeps
|
||||
//! the existing import surface and applies the resulting env actions in `main.rs`.
|
||||
|
||||
pub use qbz_app::graphics_detection::*;
|
||||
@@ -25,6 +25,8 @@ pub fn main_window_built_transparent() -> bool {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod autoconfig_graphics;
|
||||
pub mod boot_watchdog;
|
||||
pub mod graphics_detection;
|
||||
|
||||
pub mod api;
|
||||
pub mod api_cache;
|
||||
@@ -540,6 +542,31 @@ fn should_use_main_window_transparency() -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hardware-acceleration gate. A transparent main window forces GTK
|
||||
// to re-paint the client-side decoration chrome (rounded corners +
|
||||
// subtle edge) on every frame. Under GPU compositing the cost is
|
||||
// imperceptible; under SW compositing it becomes the dominant frame-
|
||||
// budget cost — modals, the splash spinner, and anything else that
|
||||
// animates visibly drop frames. Force opaque whenever HW accel is
|
||||
// off, regardless of the user's "match system window chrome"
|
||||
// preference. The preference itself is preserved in the DB and
|
||||
// reactivates automatically the next time HW accel is on.
|
||||
//
|
||||
// Resolution mirrors main.rs: env var QBZ_HARDWARE_ACCEL (0/1)
|
||||
// overrides the DB; otherwise read the DB; otherwise assume on.
|
||||
let hw_accel_enabled = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
|
||||
Ok("0") => false,
|
||||
Ok("1") => true,
|
||||
_ => config::graphics_settings::GraphicsSettingsStore::new_readonly()
|
||||
.ok()
|
||||
.and_then(|s| s.get_settings().ok())
|
||||
.map(|gs| gs.hardware_acceleration)
|
||||
.unwrap_or(true),
|
||||
};
|
||||
if !hw_accel_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opt-in via persisted settings: "match system window chrome" wants
|
||||
// rounded corners, which need a transparent window so the webview
|
||||
// background stops painting white outside the radius.
|
||||
@@ -1219,6 +1246,31 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
let mut last_track_id: u64 = 0;
|
||||
|
||||
loop {
|
||||
// Surface backend init failures from the V2 player to
|
||||
// the frontend exactly once. record_stream_error stores
|
||||
// the user-readable message; take_stream_error_message
|
||||
// drains it so we never re-emit the same toast on
|
||||
// subsequent ticks. Only the V2 (qbz-player) path
|
||||
// populates this — the legacy player's init failures
|
||||
// continue to surface via existing channels.
|
||||
let init_error: Option<String> = v2_player_state
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(qbz_player::SharedState::take_stream_error_message);
|
||||
if let Some(message) = init_error {
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct AudioInitFailedPayload {
|
||||
message: String,
|
||||
}
|
||||
if let Err(e) = app_handle.emit(
|
||||
"audio:init-failed",
|
||||
AudioInitFailedPayload { message },
|
||||
) {
|
||||
log::warn!("Failed to emit audio:init-failed event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check V2 player state first (takes priority if active)
|
||||
// V2 player is accessed via async lock, but we only need a clone
|
||||
let v2_state_opt: Option<qbz_player::SharedState> =
|
||||
@@ -1606,6 +1658,13 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_set_gdk_scale,
|
||||
commands_v2::v2_set_gdk_dpi_scale,
|
||||
commands_v2::v2_set_gsk_renderer,
|
||||
commands_v2::v2_set_preferred_gpu,
|
||||
commands_v2::v2_set_nvidia_compat_mode,
|
||||
commands_v2::v2_get_graphics_recommendation,
|
||||
commands_v2::v2_enumerate_gpus,
|
||||
commands_v2::v2_mark_boot_succeeded,
|
||||
commands_v2::v2_get_crash_flags,
|
||||
commands_v2::v2_clear_crash_flag,
|
||||
commands_v2::v2_clear_cache,
|
||||
commands_v2::v2_clear_artist_cache,
|
||||
commands_v2::v2_get_vector_store_stats,
|
||||
@@ -1763,6 +1822,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_library_get_stats,
|
||||
commands_v2::v2_library_get_albums,
|
||||
commands_v2::v2_library_get_albums_metadata,
|
||||
commands_v2::v2_library_get_albums_page,
|
||||
commands_v2::v2_library_get_folders,
|
||||
commands_v2::v2_library_get_folders_with_metadata,
|
||||
commands_v2::v2_library_count_folder_tracks,
|
||||
|
||||
+157
-30
@@ -288,8 +288,23 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Developer settings: force_dmabuf override (from Settings > Developer Mode)
|
||||
let dev_force_dmabuf =
|
||||
// ---- Read remaining risky settings (no env vars set yet) ----
|
||||
let preferred_gpu_raw_intended = graphics_db
|
||||
.as_ref()
|
||||
.map(|s| s.preferred_gpu.clone())
|
||||
.unwrap_or_else(|| "auto".to_string());
|
||||
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
|
||||
// NVIDIA Wayland compatibility opt-in: when ON, sets
|
||||
// __NV_DISABLE_EXPLICIT_SYNC=1 to bypass libnvidia-egl-gbm's
|
||||
// buggy implicit-sync paths AND lifts the defensive
|
||||
// compositing-off shutdown that QBZ applies to NVIDIA-only
|
||||
// Wayland sessions.
|
||||
let nvidia_compat_mode = graphics_db
|
||||
.as_ref()
|
||||
.map(|s| s.nvidia_compat_mode)
|
||||
.unwrap_or(false);
|
||||
|
||||
let dev_force_dmabuf_intended =
|
||||
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => match store.get_settings() {
|
||||
Ok(settings) => settings.force_dmabuf,
|
||||
@@ -309,6 +324,104 @@ fn main() {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Boot watchdog: revert risky settings if previous boot crashed ----
|
||||
// If a prior launch wrote a pending marker that never got cleared,
|
||||
// those settings crashed WebKit before first paint. The watchdog
|
||||
// returns the resolved values to actually use this boot (usually
|
||||
// the intended ones, with revert flags set for any knob that
|
||||
// matched the prior crash). It also writes a fresh pending marker
|
||||
// for THIS boot; the frontend's first-paint hook clears it.
|
||||
let watchdog_resolution = qbz_nix_lib::boot_watchdog::before_webkit_init(
|
||||
qbz_nix_lib::boot_watchdog::BootAttempt {
|
||||
hardware_acceleration: hardware_accel,
|
||||
force_dmabuf: dev_force_dmabuf_intended,
|
||||
preferred_gpu: preferred_gpu_raw_intended.clone(),
|
||||
force_x11: force_x11_db,
|
||||
},
|
||||
);
|
||||
let mut hardware_accel = watchdog_resolution.hardware_acceleration;
|
||||
let mut dev_force_dmabuf = watchdog_resolution.force_dmabuf;
|
||||
let preferred_gpu_raw = watchdog_resolution.preferred_gpu.clone();
|
||||
for msg in &watchdog_resolution.recovery_messages {
|
||||
eprintln!("[QBZ] {}", msg);
|
||||
qbz_nix_lib::logging::log_startup(&format!("[QBZ] {}", msg));
|
||||
}
|
||||
|
||||
// Software GPU choice (preferred_gpu=software) implies CPU mode:
|
||||
// forcing Mesa llvmpipe AND keeping WebKit's GPU compositing on
|
||||
// would just round-trip software pixels through a "GPU" path
|
||||
// that has no GPU. Treat it as the nuclear "no HW" option and
|
||||
// mirror it into hardware_acceleration so the WEBKIT_DISABLE_*
|
||||
// env vars below also fire.
|
||||
if preferred_gpu_raw == "software" {
|
||||
if hardware_accel || dev_force_dmabuf {
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] preferred_gpu=software → forcing hardware_acceleration=off + DMA-BUF=off (full CPU mode)",
|
||||
);
|
||||
}
|
||||
hardware_accel = false;
|
||||
dev_force_dmabuf = false;
|
||||
}
|
||||
|
||||
// NVIDIA Wayland compatibility mode: opt-in toggle that says
|
||||
// "I have libnvidia-egl-gbm installed, try the new path".
|
||||
// Sets __NV_DISABLE_EXPLICIT_SYNC=1 to bypass the buggy implicit
|
||||
// sync code that produces "Failed to create GBM buffer of size
|
||||
// NxN: Invalid argument" under WebKit + NVIDIA + Wayland on
|
||||
// driver 550+. Set unconditionally when the toggle is ON — the
|
||||
// env var is a no-op when NVIDIA isn't in use.
|
||||
if nvidia_compat_mode {
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] NVIDIA compatibility mode ON: setting __NV_DISABLE_EXPLICIT_SYNC=1",
|
||||
);
|
||||
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
|
||||
}
|
||||
|
||||
// ---- Apply preferred_gpu env vars (using watchdog-resolved value) ----
|
||||
let preferred_gpu_choice =
|
||||
qbz_nix_lib::graphics_detection::PreferredGpu::parse(&preferred_gpu_raw);
|
||||
if !matches!(
|
||||
preferred_gpu_choice,
|
||||
qbz_nix_lib::graphics_detection::PreferredGpu::Auto
|
||||
) {
|
||||
let detected = qbz_nix_lib::graphics_detection::enumerate_gpus();
|
||||
let env_pairs = qbz_nix_lib::graphics_detection::env_vars_for_preferred_gpu(
|
||||
&preferred_gpu_choice,
|
||||
&detected,
|
||||
);
|
||||
if env_pairs.is_empty() {
|
||||
qbz_nix_lib::logging::log_startup(&format!(
|
||||
"[QBZ] preferred_gpu={:?} could not be resolved (no matching detected GPU); falling back to system default",
|
||||
preferred_gpu_raw
|
||||
));
|
||||
} else {
|
||||
qbz_nix_lib::logging::log_startup(&format!(
|
||||
"[QBZ] preferred_gpu={:?}: applying GPU pin env vars",
|
||||
preferred_gpu_raw
|
||||
));
|
||||
for action in &env_pairs {
|
||||
match action {
|
||||
qbz_nix_lib::graphics_detection::EnvAction::Set(name, value) => {
|
||||
qbz_nix_lib::logging::log_startup(&format!(
|
||||
"[QBZ] set {}={}",
|
||||
name, value
|
||||
));
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
qbz_nix_lib::graphics_detection::EnvAction::Unset(name) => {
|
||||
qbz_nix_lib::logging::log_startup(&format!(
|
||||
"[QBZ] unset {}",
|
||||
name
|
||||
));
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Apply dev_force_dmabuf env var (using watchdog-resolved value) ----
|
||||
if dev_force_dmabuf {
|
||||
std::env::set_var("QBZ_FORCE_DMABUF", "1");
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
@@ -321,8 +434,8 @@ fn main() {
|
||||
let force_dmabuf = std::env::var("QBZ_FORCE_DMABUF").as_deref() == Ok("1");
|
||||
let disable_dmabuf = std::env::var("QBZ_DISABLE_DMABUF").as_deref() == Ok("1");
|
||||
|
||||
// Force X11: persistent setting from DB, env var overrides (crash recovery)
|
||||
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
|
||||
// Force X11: persistent setting from DB (read above for watchdog),
|
||||
// env var overrides (crash recovery).
|
||||
let force_x11 = match std::env::var("QBZ_FORCE_X11").as_deref() {
|
||||
Ok("1") => {
|
||||
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_FORCE_X11=1");
|
||||
@@ -453,18 +566,32 @@ fn main() {
|
||||
//
|
||||
// QBZ_HARDWARE_ACCEL=0 is the nuclear option: disables everything.
|
||||
// QBZ_HARDWARE_ACCEL=1 (explicit) bypasses ALL safety measures.
|
||||
// Default (no env var): v1.1.9 targeted mitigations only.
|
||||
// Default (no env var): GPU compositing on, DMA-BUF off (opt-in).
|
||||
//
|
||||
// v1.1.9 defaults:
|
||||
// - Wayland: COMPOSITING off + DMABUF off (prevents EGL/protocol errors)
|
||||
// - X11 + NVIDIA: DMABUF off only (prevents Error 71)
|
||||
// - X11 + non-NVIDIA: nothing disabled (full GPU acceleration)
|
||||
// Policy (since 1.2.13):
|
||||
// - GPU compositing: opt-out — on by default. User can disable in
|
||||
// Settings if their stack is broken. The CPU-mode lite path
|
||||
// covers the disabled case.
|
||||
// - DMA-BUF renderer: opt-in — off by default. The user has to
|
||||
// flip Settings > Developer > force_dmabuf to turn it on.
|
||||
// Rationale: DMA-BUF is the most fragile WebKitGTK feature on
|
||||
// Linux — NVIDIA+Wayland breaks reliably (EGL Error 71),
|
||||
// hybrid Intel+NVIDIA can flicker, and the failure mode is
|
||||
// catastrophic (black window, missing textures) rather than
|
||||
// degraded. The perf gain when it works is marginal (~5-10%
|
||||
// memory bandwidth on heavy compositing) and not worth the
|
||||
// rendering risk for a music app.
|
||||
// - Wayland+NVIDIA-only compositing: still gated off automatically
|
||||
// because the EGL protocol errors corrupt the whole window,
|
||||
// not just texture sharing. Separate axis from DMA-BUF.
|
||||
//
|
||||
// Override hierarchy (highest to lowest):
|
||||
// 1. QBZ_HARDWARE_ACCEL=0 → disable everything
|
||||
// 2. QBZ_HARDWARE_ACCEL=1 → enable everything (bypass all safety)
|
||||
// 3. QBZ_FORCE_DMABUF=1 / QBZ_DISABLE_DMABUF=1 → fine-grained DMA-BUF
|
||||
// 4. Auto-detection (Wayland/NVIDIA)
|
||||
// 2. QBZ_HARDWARE_ACCEL=1 → enable everything (bypass all safety,
|
||||
// including the DMA-BUF opt-in default)
|
||||
// 3. QBZ_FORCE_DMABUF=1 / QBZ_DISABLE_DMABUF=1 → explicit override
|
||||
// 4. force_dmabuf DB toggle → opt-in DMA-BUF
|
||||
// 5. Default → DMA-BUF off, compositing on (except Wayland+NV-only)
|
||||
if !hardware_accel {
|
||||
// Nuclear opt-out: disable all GPU compositing and DMA-BUF
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
@@ -476,17 +603,27 @@ fn main() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
} else if std::env::var("QBZ_HARDWARE_ACCEL").as_deref() == Ok("1") {
|
||||
// Explicit full GPU: skip ALL safety measures
|
||||
// Explicit full GPU: skip ALL safety measures, including the
|
||||
// DMA-BUF opt-in default. Power-user escape hatch.
|
||||
qbz_nix_lib::logging::log_startup("[QBZ] Full GPU mode: all WebKit safety bypassed");
|
||||
} else {
|
||||
// Default path: v1.1.9 targeted mitigations
|
||||
// Default path: opt-in DMA-BUF, opt-out compositing
|
||||
|
||||
// --- Compositing mode ---
|
||||
// NVIDIA-only on Wayland has protocol errors with compositing.
|
||||
// Hybrid Intel+NVIDIA and AMD systems can handle compositing fine
|
||||
// (WebKit uses the iGPU, not the dGPU).
|
||||
// If user forced DMA-BUF on, they want full GPU — skip compositing disable too.
|
||||
if is_wayland && !force_x11 && has_nvidia && !has_amd && !has_intel && !force_dmabuf {
|
||||
// NVIDIA Wayland compat mode also bypasses this safety —
|
||||
// the user has the fix in place and wants the full path.
|
||||
if is_wayland
|
||||
&& !force_x11
|
||||
&& has_nvidia
|
||||
&& !has_amd
|
||||
&& !has_intel
|
||||
&& !force_dmabuf
|
||||
&& !nvidia_compat_mode
|
||||
{
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] Wayland+NVIDIA-only: compositing mode disabled (prevents protocol errors)",
|
||||
@@ -495,31 +632,21 @@ fn main() {
|
||||
qbz_nix_lib::logging::log_startup("[QBZ] Wayland: compositing mode enabled");
|
||||
}
|
||||
|
||||
// --- DMA-BUF renderer control ---
|
||||
// --- DMA-BUF renderer control (opt-in) ---
|
||||
if force_dmabuf {
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] User override: DMA-BUF renderer forced ON (QBZ_FORCE_DMABUF=1)",
|
||||
"[QBZ] User opt-in: DMA-BUF renderer ON (Settings > Developer > force_dmabuf)",
|
||||
);
|
||||
} else if disable_dmabuf {
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] User override: DMA-BUF renderer forced OFF (QBZ_DISABLE_DMABUF=1)",
|
||||
"[QBZ] Env override: DMA-BUF renderer OFF (QBZ_DISABLE_DMABUF=1)",
|
||||
);
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
} else if is_wayland && !force_x11 && has_nvidia && !has_amd && !has_intel {
|
||||
// NVIDIA-only on Wayland: disable DMA-BUF (Error 71 protocol error)
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] Wayland+NVIDIA-only: DMA-BUF renderer disabled (prevents Error 71)",
|
||||
);
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
} else if has_nvidia && !has_intel && !has_amd && !is_wayland {
|
||||
// X11 + NVIDIA: disable DMA-BUF only (keeps full compositing)
|
||||
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA on X11: DMA-BUF renderer disabled");
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
} else {
|
||||
// AMD/Intel on Wayland or X11: full GPU acceleration
|
||||
qbz_nix_lib::logging::log_startup(
|
||||
"[QBZ] Using default WebKit renderer (full hardware acceleration)",
|
||||
"[QBZ] DMA-BUF renderer off by default (opt-in via Settings > Developer)",
|
||||
);
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
-18
@@ -86,6 +86,17 @@ enum AudioCommand {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
duration_secs: u64,
|
||||
/// Resume offset in seconds. 0 = start from beginning. >0 = the
|
||||
/// audio thread waits until enough bytes are buffered to cover
|
||||
/// (position + headroom) seconds, then decodes-and-discards
|
||||
/// samples up to `start_position_secs` before feeding the engine.
|
||||
/// Avoids the play-then-seek race where Seek would silently
|
||||
/// bail because `current_audio_data` is unset for streaming.
|
||||
start_position_secs: u64,
|
||||
/// Total content length in bytes. Used together with
|
||||
/// `duration_secs` to estimate bytes-per-second when computing
|
||||
/// how much buffer is needed for `start_position_secs > 0`.
|
||||
content_length: u64,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
@@ -1619,13 +1630,16 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
} => {
|
||||
log::info!(
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
*pause_suspend_deadline = None;
|
||||
|
||||
@@ -1839,12 +1853,45 @@ impl Player {
|
||||
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
|
||||
apply_engine_volume(&stream_opt, &engine, volume);
|
||||
|
||||
// Wait for minimum buffer before starting playback
|
||||
// Wait for minimum buffer before starting playback.
|
||||
// When start_position_secs > 0 (session resume),
|
||||
// also wait for enough buffer to cover the resume
|
||||
// offset plus an 8s headroom — the eager pre-skip
|
||||
// below will decode-and-discard up to the offset,
|
||||
// and we need bytes in the buffer to feed it
|
||||
// without blocking the audio device.
|
||||
log::info!("Streaming: waiting for initial buffer...");
|
||||
let start_wait = Instant::now();
|
||||
let max_wait = Duration::from_secs(30);
|
||||
let max_wait = Duration::from_secs(60);
|
||||
|
||||
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
|
||||
let bytes_per_sec_estimate: u64 = if duration_secs > 0
|
||||
&& content_length > 0
|
||||
{
|
||||
content_length / duration_secs
|
||||
} else {
|
||||
// Conservative fallback: ~200 KB/s ≈ 16/44 FLAC.
|
||||
200_000
|
||||
};
|
||||
let resume_buffer_target: u64 = if start_position_secs > 0 {
|
||||
bytes_per_sec_estimate
|
||||
.saturating_mul(start_position_secs.saturating_add(8))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let buffer_sufficient = |src: &Arc<BufferedMediaSource>| -> bool {
|
||||
if !src.has_min_buffer() {
|
||||
return false;
|
||||
}
|
||||
if resume_buffer_target == 0 {
|
||||
return true;
|
||||
}
|
||||
(src.buffer_size() as u64) >= resume_buffer_target
|
||||
};
|
||||
|
||||
while !buffer_sufficient(&source)
|
||||
&& start_wait.elapsed() < max_wait
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
@@ -1852,11 +1899,20 @@ impl Player {
|
||||
log::error!("Streaming: timeout waiting for initial buffer");
|
||||
return;
|
||||
}
|
||||
if resume_buffer_target > 0
|
||||
&& (source.buffer_size() as u64) < resume_buffer_target
|
||||
{
|
||||
log::warn!(
|
||||
"Streaming: timed out waiting for resume buffer (got {} bytes, wanted {}); pre-skip may underrun briefly",
|
||||
source.buffer_size(),
|
||||
resume_buffer_target
|
||||
);
|
||||
}
|
||||
|
||||
let buffer_wait_ms = start_wait.elapsed().as_millis();
|
||||
log::info!(
|
||||
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
|
||||
buffer_wait_ms
|
||||
"Streaming: buffer ready in {}ms ({} bytes, target {}), creating incremental decoder...",
|
||||
buffer_wait_ms, source.buffer_size(), resume_buffer_target
|
||||
);
|
||||
|
||||
// Create incremental streaming source - this starts playback IMMEDIATELY
|
||||
@@ -1934,8 +1990,41 @@ impl Player {
|
||||
thread_state.set_normalization_gain(normalization);
|
||||
|
||||
// Box the incremental source to match the expected type
|
||||
let source_to_play: Box<dyn Source<Item = f32> + Send> =
|
||||
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
|
||||
Box::new(incremental_source);
|
||||
|
||||
// Eager pre-skip for session resume. We decode and
|
||||
// discard samples here so that the engine's first
|
||||
// pull doesn't have to do the work synchronously,
|
||||
// which would underrun the audio device for
|
||||
// multi-second offsets. The buffer wait above
|
||||
// guarantees there's enough downloaded data to
|
||||
// feed this loop without blocking on bytes.
|
||||
if start_position_secs > 0 {
|
||||
let target_samples: u64 = (start_position_secs)
|
||||
.saturating_mul(actual_sr as u64)
|
||||
.saturating_mul(actual_ch as u64);
|
||||
let skip_start = Instant::now();
|
||||
let mut skipped: u64 = 0;
|
||||
while skipped < target_samples {
|
||||
if source_to_play.next().is_none() {
|
||||
log::warn!(
|
||||
"Resume: source ended before reaching {}s (pre-skipped {} samples)",
|
||||
start_position_secs,
|
||||
skipped
|
||||
);
|
||||
break;
|
||||
}
|
||||
skipped += 1;
|
||||
}
|
||||
log::info!(
|
||||
"Resume: pre-skipped {} samples ({}s) in {}ms",
|
||||
skipped,
|
||||
start_position_secs,
|
||||
skip_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap source with diagnostic, normalization, and visualizer
|
||||
let source_to_play = wrap_source(
|
||||
source_to_play,
|
||||
@@ -1950,16 +2039,19 @@ impl Player {
|
||||
}
|
||||
|
||||
thread_state.is_playing.store(true, Ordering::SeqCst);
|
||||
thread_state.position.store(0, Ordering::SeqCst);
|
||||
thread_state
|
||||
.position
|
||||
.store(start_position_secs, Ordering::SeqCst);
|
||||
thread_state
|
||||
.current_track_id
|
||||
.store(track_id, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
thread_state.start_playback_timer(start_position_secs);
|
||||
|
||||
*current_engine = Some(engine);
|
||||
log::info!(
|
||||
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
|
||||
start_wait.elapsed().as_millis()
|
||||
"Audio thread: streaming playback STARTED in {}ms at {}s (incremental decode active)",
|
||||
start_wait.elapsed().as_millis(),
|
||||
start_position_secs
|
||||
);
|
||||
}
|
||||
AudioCommand::Pause => {
|
||||
@@ -2689,7 +2781,10 @@ impl Player {
|
||||
}
|
||||
|
||||
/// Play from streaming source (starts playback before full download)
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
/// Returns the BufferWriter so caller can push data as it downloads.
|
||||
/// `start_position_secs` > 0 turns this into a session-resume play:
|
||||
/// the audio thread waits for enough buffer to cover the offset and
|
||||
/// pre-skips decode output up to that point before engaging audio.
|
||||
pub fn play_streaming(
|
||||
&self,
|
||||
track_id: u64,
|
||||
@@ -2698,14 +2793,16 @@ impl Player {
|
||||
content_length: u64,
|
||||
buffer_seconds: u8,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
|
||||
// Use StreamingConfig::from_seconds for proper buffer sizing
|
||||
@@ -2721,6 +2818,8 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
@@ -2732,7 +2831,8 @@ impl Player {
|
||||
}
|
||||
|
||||
/// Play from streaming source with dynamic buffer based on measured speed
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
/// Returns the BufferWriter so caller can push data as it downloads.
|
||||
/// `start_position_secs` > 0 signals session resume (see `play_streaming`).
|
||||
pub fn play_streaming_dynamic(
|
||||
&self,
|
||||
track_id: u64,
|
||||
@@ -2742,16 +2842,18 @@ impl Player {
|
||||
content_length: u64,
|
||||
speed_mbps: f64,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
|
||||
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s, start={}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
content_length as f64 / (1024.0 * 1024.0),
|
||||
speed_mbps,
|
||||
duration_secs
|
||||
duration_secs,
|
||||
start_position_secs
|
||||
);
|
||||
|
||||
// Update shared state with actual stream quality
|
||||
@@ -2770,6 +2872,8 @@ impl Player {
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
content_length,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
|
||||
@@ -131,6 +131,7 @@ async fn stream_remote_track_into_player(
|
||||
stream_info.content_length,
|
||||
stream_info.speed_mbps,
|
||||
duration_secs,
|
||||
0, // QConnect tracks always start from 0; resume is local-only
|
||||
)
|
||||
.map_err(|err| format!("start streaming remote track {track_id}: {err}"))?;
|
||||
|
||||
|
||||
+5
-404
@@ -1,360 +1,10 @@
|
||||
//! Runtime Session Contract
|
||||
//!
|
||||
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
|
||||
//!
|
||||
//! Key concepts:
|
||||
//! - Single bootstrap entrypoint
|
||||
//! - Canonical state machine (Uninitialized -> Ready)
|
||||
//! - Typed errors (no string matching)
|
||||
//! - Command gating in backend
|
||||
//! - Lifecycle events
|
||||
//! Tauri runtime state wrapper.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use qbz_app::runtime::{
|
||||
CommandRequirement, DegradedReason, RuntimeError, RuntimeEvent, RuntimeManager, RuntimeState,
|
||||
RuntimeStatus,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Canonical runtime states
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "data")]
|
||||
pub enum RuntimeState {
|
||||
/// Initial state - nothing initialized
|
||||
Uninitialized,
|
||||
/// Client initialized but no authentication
|
||||
InitializedNoAuth,
|
||||
/// Authenticated but per-user session not activated (transitional)
|
||||
AuthenticatedNoUserSession { user_id: u64 },
|
||||
/// Fully ready - all systems operational
|
||||
Ready { user_id: u64 },
|
||||
/// Degraded state - something is broken
|
||||
Degraded { reason: DegradedReason },
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self::Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons for degraded state
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "message")]
|
||||
pub enum DegradedReason {
|
||||
/// Bundle token extraction failed
|
||||
BundleExtractionFailed(String),
|
||||
/// CoreBridge initialization failed
|
||||
CoreBridgeInitFailed(String),
|
||||
/// Network connectivity issues
|
||||
NetworkError(String),
|
||||
/// Database/storage issues
|
||||
StorageError(String),
|
||||
/// Session activation failed (per-user stores not initialized)
|
||||
SessionActivationFailed(String),
|
||||
}
|
||||
|
||||
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeStatus {
|
||||
/// Current state
|
||||
pub state: RuntimeState,
|
||||
/// User ID if authenticated (None if not logged in)
|
||||
pub user_id: Option<u64>,
|
||||
/// Whether the API client is initialized (bundle tokens extracted)
|
||||
pub client_initialized: bool,
|
||||
/// Whether legacy auth is active
|
||||
pub legacy_auth: bool,
|
||||
/// Whether CoreBridge/V2 auth is active
|
||||
pub corebridge_auth: bool,
|
||||
/// Whether per-user session is activated
|
||||
pub session_activated: bool,
|
||||
/// Degraded reason if state is Degraded
|
||||
pub degraded_reason: Option<DegradedReason>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RuntimeState::Uninitialized,
|
||||
user_id: None,
|
||||
client_initialized: false,
|
||||
legacy_auth: false,
|
||||
corebridge_auth: false,
|
||||
session_activated: false,
|
||||
degraded_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed runtime errors - no string matching in clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "details")]
|
||||
pub enum RuntimeError {
|
||||
/// Runtime not initialized - call runtime_bootstrap first
|
||||
RuntimeNotInitialized,
|
||||
/// Authentication required for this operation
|
||||
AuthRequired,
|
||||
/// Per-user session not activated - call activate_user_session
|
||||
UserSessionNotActivated,
|
||||
/// CoreBridge/V2 auth missing - V2 commands won't work
|
||||
CoreBridgeAuthMissing,
|
||||
/// Runtime is in degraded state
|
||||
RuntimeDegraded(DegradedReason),
|
||||
/// Invalid user ID (e.g., 0)
|
||||
InvalidUserId,
|
||||
/// Bootstrap already in progress
|
||||
BootstrapInProgress,
|
||||
/// V2 CoreBridge authentication failed
|
||||
V2AuthFailed(String),
|
||||
/// V2 CoreBridge not initialized
|
||||
V2NotInitialized,
|
||||
/// Manual offline mode is on and the requested track is not available
|
||||
/// in any local cache. Emitted instead of silently streaming from the
|
||||
/// network (see issue #279).
|
||||
TrackNotAvailableOffline,
|
||||
/// Internal error
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
|
||||
Self::AuthRequired => write!(f, "Authentication required"),
|
||||
Self::UserSessionNotActivated => write!(f, "User session not activated"),
|
||||
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
|
||||
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
|
||||
Self::InvalidUserId => write!(f, "Invalid user ID"),
|
||||
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
|
||||
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
|
||||
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
|
||||
Self::TrackNotAvailableOffline => {
|
||||
write!(f, "Track not available in offline cache while in offline mode")
|
||||
}
|
||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
/// Command prerequisites - what each command requires
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandRequirement {
|
||||
/// No requirements (public endpoints)
|
||||
None,
|
||||
/// Requires client to be initialized (bundle tokens)
|
||||
RequiresClientInit,
|
||||
/// Requires authentication (logged in)
|
||||
RequiresAuth,
|
||||
/// Requires per-user session to be activated
|
||||
RequiresUserSession,
|
||||
/// Requires CoreBridge/V2 auth (for V2 commands)
|
||||
RequiresCoreBridgeAuth,
|
||||
}
|
||||
|
||||
/// Runtime state manager - thread-safe, holds canonical state
|
||||
pub struct RuntimeManager {
|
||||
state: Arc<RwLock<RuntimeStatus>>,
|
||||
bootstrap_in_progress: Arc<RwLock<bool>>,
|
||||
/// Tracks which Mixtape/Collection the current queue was built from.
|
||||
/// Set by v2_enqueue_collection (replace mode), cleared by any
|
||||
/// non-Mixtape queue replacement (set_queue / clear_queue).
|
||||
/// Append-style ops (add/add_next/bulk) preserve this value.
|
||||
/// Persistence is in-memory only; session_queue_state table will
|
||||
/// add the source_collection_id column when it lands.
|
||||
queue_source_collection_id: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl RuntimeManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(RuntimeStatus::default())),
|
||||
bootstrap_in_progress: Arc::new(RwLock::new(false)),
|
||||
queue_source_collection_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) which collection the current queue was built from.
|
||||
pub async fn set_queue_source_collection(&self, id: Option<String>) {
|
||||
*self.queue_source_collection_id.write().await = id;
|
||||
}
|
||||
|
||||
/// Return the collection ID that seeded the current queue, if any.
|
||||
pub async fn get_queue_source_collection(&self) -> Option<String> {
|
||||
self.queue_source_collection_id.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get current runtime status
|
||||
pub async fn get_status(&self) -> RuntimeStatus {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update runtime state
|
||||
pub async fn set_state(&self, new_state: RuntimeState) {
|
||||
let mut status = self.state.write().await;
|
||||
status.state = new_state.clone();
|
||||
|
||||
// Update derived fields based on state
|
||||
match &new_state {
|
||||
RuntimeState::Uninitialized => {
|
||||
status.client_initialized = false;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::InitializedNoAuth => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::AuthenticatedNoUserSession { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.session_activated = false;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Ready { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.corebridge_auth = true;
|
||||
status.session_activated = true;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Degraded { reason } => {
|
||||
status.degraded_reason = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Runtime] State changed to: {:?}", new_state);
|
||||
}
|
||||
|
||||
/// Mark client as initialized
|
||||
pub async fn set_client_initialized(&self, initialized: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.client_initialized = initialized;
|
||||
if initialized && status.state == RuntimeState::Uninitialized {
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark legacy auth status
|
||||
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
|
||||
let mut status = self.state.write().await;
|
||||
status.legacy_auth = auth;
|
||||
if auth {
|
||||
if let Some(uid) = user_id {
|
||||
status.user_id = Some(uid);
|
||||
if !status.session_activated {
|
||||
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.user_id = None;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark CoreBridge auth status
|
||||
pub async fn set_corebridge_auth(&self, auth: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.corebridge_auth = auth;
|
||||
}
|
||||
|
||||
/// Mark session as activated
|
||||
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
|
||||
let mut status = self.state.write().await;
|
||||
status.session_activated = activated;
|
||||
if activated && status.legacy_auth && status.corebridge_auth {
|
||||
status.state = RuntimeState::Ready { user_id };
|
||||
status.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if bootstrap is in progress
|
||||
pub async fn is_bootstrap_in_progress(&self) -> bool {
|
||||
*self.bootstrap_in_progress.read().await
|
||||
}
|
||||
|
||||
/// Set bootstrap in progress flag
|
||||
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
|
||||
*self.bootstrap_in_progress.write().await = in_progress;
|
||||
}
|
||||
|
||||
/// Validate command requirements against current state
|
||||
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
|
||||
let status = self.state.read().await;
|
||||
|
||||
match req {
|
||||
CommandRequirement::None => Ok(()),
|
||||
CommandRequirement::RequiresClientInit => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresUserSession => {
|
||||
// RequiresUserSession only checks session_activated, not legacy_auth.
|
||||
// This allows both:
|
||||
// - Online sessions: session_activated=true via activate_session()
|
||||
// - Offline sessions: session_activated=true via activate_offline_session()
|
||||
//
|
||||
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
|
||||
if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresCoreBridgeAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else if !status.corebridge_auth {
|
||||
Err(RuntimeError::CoreBridgeAuthMissing)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if in degraded state
|
||||
pub async fn is_degraded(&self) -> bool {
|
||||
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
|
||||
}
|
||||
|
||||
/// Set degraded state
|
||||
pub async fn set_degraded(&self, reason: DegradedReason) {
|
||||
self.set_state(RuntimeState::Degraded { reason }).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri state wrapper
|
||||
pub struct RuntimeManagerState(pub Arc<RuntimeManager>);
|
||||
@@ -374,52 +24,3 @@ impl Default for RuntimeManagerState {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle Events ====================
|
||||
|
||||
/// Events emitted during runtime lifecycle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum RuntimeEvent {
|
||||
/// Runtime initialized (client ready)
|
||||
RuntimeInitialized,
|
||||
/// Authentication state changed
|
||||
AuthChanged {
|
||||
logged_in: bool,
|
||||
user_id: Option<u64>,
|
||||
},
|
||||
/// Per-user session activated
|
||||
UserSessionActivated { user_id: u64 },
|
||||
/// Per-user session deactivated
|
||||
UserSessionDeactivated,
|
||||
/// CoreBridge auth failed
|
||||
CoreBridgeAuthFailed { error: String },
|
||||
/// Runtime entered degraded state
|
||||
RuntimeDegraded { reason: DegradedReason },
|
||||
/// Runtime fully ready
|
||||
RuntimeReady { user_id: u64 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mixtape_context_set_clear() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
// Initially None
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
|
||||
// Set Some
|
||||
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
|
||||
assert_eq!(
|
||||
mgr.get_queue_source_collection().await,
|
||||
Some("col-abc".to_string())
|
||||
);
|
||||
|
||||
// Clear to None
|
||||
mgr.set_queue_source_collection(None).await;
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ pub struct PersistedQueueTrack {
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parental_warning: bool,
|
||||
/// Opaque id of the Mixtape/Collection item that produced this track.
|
||||
/// Persisted so a Mixtape-sourced queue restored from disk keeps the
|
||||
/// item association the live queue would have (album_id is the safe
|
||||
/// fallback for non-Mixtape paths; None reads fine downstream).
|
||||
#[serde(default)]
|
||||
pub source_item_id_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Represents the full persisted session state
|
||||
@@ -195,6 +201,30 @@ impl SessionStore {
|
||||
);
|
||||
}
|
||||
|
||||
// streamable / parental_warning / source_item_id_hint were hardcoded
|
||||
// to defaults on load (streamable=true, parental_warning=false,
|
||||
// source_item_id_hint=None) because the columns didn't exist.
|
||||
// Persisting them round-trips the live QueueTrack faithfully so
|
||||
// a restored queue matches what the user had open before close.
|
||||
let has_streamable: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'streamable'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0;
|
||||
|
||||
if !has_streamable {
|
||||
let _ = conn.execute_batch(
|
||||
"
|
||||
ALTER TABLE queue_tracks ADD COLUMN streamable INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE queue_tracks ADD COLUMN parental_warning INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE queue_tracks ADD COLUMN source_item_id_hint TEXT;
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
// Add last_view, view_context_id, view_context_type columns to player_state
|
||||
let has_last_view: bool = conn
|
||||
.query_row(
|
||||
@@ -239,8 +269,8 @@ impl SessionStore {
|
||||
// Insert queue tracks
|
||||
for (pos, track) in session.queue_tracks.iter().enumerate() {
|
||||
if let Err(e) = self.conn.execute(
|
||||
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
pos as i64,
|
||||
track.id as i64,
|
||||
@@ -256,6 +286,9 @@ impl SessionStore {
|
||||
track.album_id,
|
||||
track.artist_id.map(|v| v as i64),
|
||||
track.source,
|
||||
track.streamable as i64,
|
||||
track.parental_warning as i64,
|
||||
track.source_item_id_hint,
|
||||
],
|
||||
) {
|
||||
let _ = self.conn.execute("ROLLBACK", []);
|
||||
@@ -327,7 +360,7 @@ impl SessionStore {
|
||||
|
||||
// Load queue tracks
|
||||
let mut stmt = self.conn
|
||||
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source FROM queue_tracks ORDER BY position")
|
||||
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint FROM queue_tracks ORDER BY position")
|
||||
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
|
||||
|
||||
let tracks: Vec<PersistedQueueTrack> = stmt
|
||||
@@ -345,9 +378,10 @@ impl SessionStore {
|
||||
is_local: row.get::<_, i64>(9).unwrap_or(0) != 0,
|
||||
album_id: row.get(10)?,
|
||||
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
|
||||
streamable: true, // Default to true for persisted tracks
|
||||
source: row.get(12)?,
|
||||
parental_warning: false, // Not persisted in DB
|
||||
streamable: row.get::<_, i64>(13).unwrap_or(1) != 0,
|
||||
parental_warning: row.get::<_, i64>(14).unwrap_or(0) != 0,
|
||||
source_item_id_hint: row.get(15)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
|
||||
|
||||
+4
-130
@@ -1,132 +1,6 @@
|
||||
//! Per-user data path management
|
||||
//! Tauri adapter for per-user data path management.
|
||||
//!
|
||||
//! Each Qobuz user gets their own subdirectory under the app's data/cache paths.
|
||||
//! This module provides the central path provider that all per-user state modules
|
||||
//! use to determine where to store their databases.
|
||||
//! The portable path provider lives in `qbz-app`; this module keeps the existing
|
||||
//! Tauri import surface unchanged.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Central path provider for per-user data isolation.
|
||||
///
|
||||
/// Holds the current user_id and provides methods to get
|
||||
/// user-scoped data and cache directories.
|
||||
pub struct UserDataPaths {
|
||||
user_id: RwLock<Option<u64>>,
|
||||
}
|
||||
|
||||
impl UserDataPaths {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
user_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current user after login
|
||||
pub fn set_user(&self, user_id: u64) {
|
||||
*self
|
||||
.user_id
|
||||
.write()
|
||||
.expect("UserDataPaths write lock poisoned") = Some(user_id);
|
||||
log::info!("UserDataPaths: active user set");
|
||||
}
|
||||
|
||||
/// Clear the current user on logout
|
||||
pub fn clear_user(&self) {
|
||||
*self
|
||||
.user_id
|
||||
.write()
|
||||
.expect("UserDataPaths write lock poisoned") = None;
|
||||
log::info!("UserDataPaths: active user cleared");
|
||||
}
|
||||
|
||||
/// Get the current user ID, if set
|
||||
pub fn current_user_id(&self) -> Option<u64> {
|
||||
*self
|
||||
.user_id
|
||||
.read()
|
||||
.expect("UserDataPaths read lock poisoned")
|
||||
}
|
||||
|
||||
/// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/
|
||||
pub fn user_data_dir(&self) -> Result<PathBuf, String> {
|
||||
let uid = self
|
||||
.user_id
|
||||
.read()
|
||||
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
|
||||
.ok_or("No active user - please log in")?;
|
||||
|
||||
let base = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string());
|
||||
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/
|
||||
pub fn user_cache_dir(&self) -> Result<PathBuf, String> {
|
||||
let uid = self
|
||||
.user_id
|
||||
.read()
|
||||
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
|
||||
.ok_or("No active user - please log in")?;
|
||||
|
||||
let base = dirs::cache_dir()
|
||||
.ok_or("Could not determine cache directory")?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string());
|
||||
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Get the global (non-user-scoped) data directory: ~/.local/share/qbz/
|
||||
pub fn global_data_dir() -> Result<PathBuf, String> {
|
||||
dirs::data_dir()
|
||||
.ok_or_else(|| "Could not determine data directory".to_string())
|
||||
.map(|d| d.join("qbz"))
|
||||
}
|
||||
|
||||
/// Get the global (non-user-scoped) cache directory: ~/.cache/qbz/
|
||||
pub fn global_cache_dir() -> Result<PathBuf, String> {
|
||||
dirs::cache_dir()
|
||||
.ok_or_else(|| "Could not determine cache directory".to_string())
|
||||
.map(|d| d.join("qbz"))
|
||||
}
|
||||
|
||||
/// Save the last active user_id to a flat-path file so the session
|
||||
/// can be restored on next app launch (when "remember me" is active).
|
||||
pub fn save_last_user_id(user_id: u64) -> Result<(), String> {
|
||||
let path = Self::last_user_id_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create global data directory: {}", e))?;
|
||||
}
|
||||
std::fs::write(&path, user_id.to_string())
|
||||
.map_err(|e| format!("Failed to save last user id: {}", e))?;
|
||||
log::info!("Saved last_user_id marker");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the last active user_id (returns None if file doesn't exist or is invalid).
|
||||
pub fn load_last_user_id() -> Option<u64> {
|
||||
let path = Self::last_user_id_path().ok()?;
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
contents.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Clear the last user_id file (called on explicit logout).
|
||||
pub fn clear_last_user_id() {
|
||||
if let Ok(path) = Self::last_user_id_path() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
log::info!("Cleared last_user_id file");
|
||||
}
|
||||
}
|
||||
|
||||
fn last_user_id_path() -> Result<PathBuf, String> {
|
||||
let dir = Self::global_data_dir()?;
|
||||
Ok(dir.join("last_user_id"))
|
||||
}
|
||||
}
|
||||
pub use qbz_app::user_data::UserDataPaths;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "QBZ",
|
||||
"version": "1.2.12",
|
||||
"version": "1.2.13",
|
||||
"identifier": "com.blitzfc.qbz",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+34
@@ -1547,6 +1547,40 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
/* App-wide non-selection. Music app text (titles, artists, headers) is
|
||||
not meant to be selected — and selectable text breaks window drag
|
||||
regions, since native text selection preempts the drag gesture.
|
||||
Exceptions below opt specific elements back into selectability. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Text-input controls always get native selection back. */
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']) {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Semantic literal-text elements stay selectable everywhere. Covers the
|
||||
HiFi Wizard command/path blocks, recovery commands, environment-variable
|
||||
hints, log viewers, keyboard-shortcut chips, and any other place where
|
||||
users need to copy text verbatim from settings, diagnostics, or modals. */
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Escape hatch: add class="selectable" to any element whose text users
|
||||
should be able to copy (error messages, tokens, release notes, etc.). */
|
||||
.selectable,
|
||||
.selectable * {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
|
||||
@@ -9,14 +9,22 @@
|
||||
*/
|
||||
|
||||
import { getCachedImageUrl, getResolvedIfCached } from '$lib/services/imageCacheService';
|
||||
import { isHardwareAccelEnabled } from '$lib/runtime/graphicsState';
|
||||
|
||||
export function cachedSrc(node: HTMLImageElement, url: string | undefined) {
|
||||
let currentUrl = url;
|
||||
|
||||
// Force own compositing layer to prevent WebKitGTK 2.50+ texture
|
||||
// eviction during repaints (causes placeholder flash on hover/scroll)
|
||||
node.style.willChange = 'transform';
|
||||
node.style.transform = 'translateZ(0)';
|
||||
// WebKitGTK 2.50+ evicts <img> textures from its compositor cache during
|
||||
// rapid repaints (hover, scroll) which manifests as a flash of the
|
||||
// placeholder background. Forcing the image into its own compositor
|
||||
// layer prevents the eviction. We only apply this under hardware
|
||||
// acceleration: under software compositing each "layer" is just an
|
||||
// extra CPU framebuffer, which makes the cure worse than the disease
|
||||
// for big card grids (confirmed 2026-05-12 perf session). See ADR-004.
|
||||
if (isHardwareAccelEnabled()) {
|
||||
node.style.willChange = 'transform';
|
||||
node.style.transform = 'translateZ(0)';
|
||||
}
|
||||
|
||||
// Sync cache hit? Set the src immediately, no opacity dance. This is
|
||||
// the common path when preloadImages has already primed the map
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getNextZoomLevel } from '$lib/utils/zoom';
|
||||
import { getUserItem } from '$lib/utils/userStorage';
|
||||
import { getZoom, setZoom } from '$lib/stores/zoomStore';
|
||||
import { restoreAutoThemeVars } from '$lib/stores/autoThemeStore';
|
||||
import { bootstrapGraphicsState, isHardwareAccelEnabled } from '$lib/runtime/graphicsState';
|
||||
import { applyCpuModeDefaultsIfNeeded } from '$lib/stores/immersivePanelsStore';
|
||||
|
||||
// ============ Theme Management ============
|
||||
|
||||
@@ -75,10 +77,59 @@ function handleZoomWheel(event: WheelEvent): void {
|
||||
void applyZoomLevel(nextZoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom-on-Ctrl-wheel handling.
|
||||
*
|
||||
* Previously this registered a non-passive, capture-phase `wheel` listener
|
||||
* on `window` for the lifetime of the app. The non-passive flag (required
|
||||
* so `preventDefault` actually stops the WebView's native Ctrl+wheel zoom)
|
||||
* also tells the browser "wait for my handler before applying scroll" —
|
||||
* which kills the native fast-path for *every* wheel event in the app,
|
||||
* not just zoom-modified ones. The user perceived this as choppy mouse-wheel
|
||||
* scrolling vs perfectly-smooth scrollbar-drag scrolling.
|
||||
*
|
||||
* New approach: the wheel listener only exists while Ctrl/Meta is held.
|
||||
* Keydown registers it, keyup (or window blur) unregisters it. Net:
|
||||
* - 99% of normal scrolling: no wheel listener at all, browser fast-path
|
||||
* - During zoom (Ctrl held): non-passive listener active, preventDefault
|
||||
* still blocks native zoom and our `applyZoomLevel` runs
|
||||
*/
|
||||
const ZOOM_WHEEL_OPTIONS: AddEventListenerOptions = { passive: false, capture: true };
|
||||
let zoomWheelActive = false;
|
||||
|
||||
function registerZoomWheel(): void {
|
||||
if (zoomWheelActive) return;
|
||||
zoomWheelActive = true;
|
||||
window.addEventListener('wheel', handleZoomWheel, ZOOM_WHEEL_OPTIONS);
|
||||
}
|
||||
|
||||
function unregisterZoomWheel(): void {
|
||||
if (!zoomWheelActive) return;
|
||||
zoomWheelActive = false;
|
||||
window.removeEventListener('wheel', handleZoomWheel, ZOOM_WHEEL_OPTIONS);
|
||||
}
|
||||
|
||||
function handleZoomKeyDown(e: KeyboardEvent): void {
|
||||
if (e.ctrlKey || e.metaKey) registerZoomWheel();
|
||||
}
|
||||
|
||||
function handleZoomKeyUp(e: KeyboardEvent): void {
|
||||
// After any keyup, if neither modifier is held anymore, drop the listener.
|
||||
if (!e.ctrlKey && !e.metaKey) unregisterZoomWheel();
|
||||
}
|
||||
|
||||
export function setupZoomControls(): () => void {
|
||||
const options: AddEventListenerOptions = { passive: false, capture: true };
|
||||
window.addEventListener('wheel', handleZoomWheel, options);
|
||||
return () => window.removeEventListener('wheel', handleZoomWheel, options);
|
||||
window.addEventListener('keydown', handleZoomKeyDown);
|
||||
window.addEventListener('keyup', handleZoomKeyUp);
|
||||
// Releases the listener if the user alt-tabs with Ctrl held — otherwise
|
||||
// the keyup never fires and we stay in the slow path.
|
||||
window.addEventListener('blur', unregisterZoomWheel);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleZoomKeyDown);
|
||||
window.removeEventListener('keyup', handleZoomKeyUp);
|
||||
window.removeEventListener('blur', unregisterZoomWheel);
|
||||
unregisterZoomWheel();
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Last.fm Session ============
|
||||
@@ -146,6 +197,17 @@ export function bootstrapApp(): BootstrapResult {
|
||||
loadToastsPreference();
|
||||
loadSystemNotificationsPreference();
|
||||
|
||||
// Prime the runtime graphics-state cache so hot paths like `cachedSrc`
|
||||
// can read it sync. Fire-and-forget; falls back to "HW accel ON"
|
||||
// defaults if it errors out (see ADR-004). Once the cache is loaded,
|
||||
// re-apply CPU-mode defaults to the immersive panels store — the
|
||||
// store's module-load init may have run before this resolved and
|
||||
// defaulted to GPU mode, which would leave heavy panels enabled by
|
||||
// mistake on first launch for CPU-bound users.
|
||||
void bootstrapGraphicsState().then(() => {
|
||||
applyCpuModeDefaultsIfNeeded(!isHardwareAccelEnabled());
|
||||
});
|
||||
|
||||
// Setup mouse navigation
|
||||
const cleanupMouse = setupMouseNavigation();
|
||||
const cleanupZoom = setupZoomControls();
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-backdrop" onclick={onClose} role="presentation">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal selectable" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="app-branding">
|
||||
@@ -99,7 +99,7 @@
|
||||
<span class="label">{$t('about.buildInfo.version')}</span>
|
||||
<span class="value">{appVersion}</span>
|
||||
<span class="label">{$t('about.buildInfo.codename')}</span>
|
||||
<span class="value codename">Exclusive Hardening</span>
|
||||
<span class="value codename">400+ stars</span>
|
||||
<span class="label">{$t('about.buildInfo.license')}</span>
|
||||
<span class="value">MIT</span>
|
||||
<span class="label">{$t('about.buildInfo.platform')}</span>
|
||||
|
||||
@@ -1,814 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { Play, Disc3, Heart, Check } from 'lucide-svelte';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import AlbumMenu from './AlbumMenu.svelte';
|
||||
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
|
||||
import {
|
||||
subscribe as subscribeAlbumFavorites,
|
||||
isAlbumFavorite,
|
||||
isAlbumToggling,
|
||||
loadAlbumFavorites,
|
||||
toggleAlbumFavorite
|
||||
} from '$lib/stores/albumFavoritesStore';
|
||||
import { resolveAlbumCover } from '$lib/stores/customAlbumCoverStore';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import SourceBadge from './SourceBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
albumId?: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre?: string;
|
||||
releaseDate?: string;
|
||||
quality?: string;
|
||||
size?: 'standard' | 'large';
|
||||
searchId?: string;
|
||||
onclick?: () => void;
|
||||
onPlay?: () => void;
|
||||
onPlayNext?: () => void;
|
||||
onPlayLater?: () => void;
|
||||
onAddAlbumToPlaylist?: () => void;
|
||||
onShareQobuz?: () => void;
|
||||
onShareSonglink?: () => void;
|
||||
onDownload?: () => void;
|
||||
showFavorite?: boolean;
|
||||
favoriteEnabled?: boolean;
|
||||
showGenre?: boolean;
|
||||
isAlbumFullyDownloaded?: boolean;
|
||||
onOpenContainingFolder?: () => void;
|
||||
onReDownloadAlbum?: () => void;
|
||||
downloadStateVersion?: number;
|
||||
/** Source badge for local library: 'user' | 'qobuz_download' | 'qobuz_purchase' | 'plex' */
|
||||
sourceBadge?: 'user' | 'qobuz_download' | 'qobuz_purchase' | 'plex';
|
||||
artistId?: number;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
/** Editorial ribbon: Qobuz award (id 88 = Qobuzissime,
|
||||
* id 151 = Album of the Week) or a press accolade
|
||||
* (Pitchfork BNM, Rolling Stone 5★, etc.). `label` is the
|
||||
* display string the card renders. */
|
||||
ribbon?: { kind: 'qobuzissime' | 'albumOfTheWeek' | 'press'; label: string };
|
||||
/** Whether this album is from the local library (determines source field in AddToMixtapeItem) */
|
||||
isLocal?: boolean;
|
||||
/** Year for AddToMixtapeItem (optional, parsed from releaseDate if not provided) */
|
||||
year?: number;
|
||||
/** Track count for AddToMixtapeItem */
|
||||
trackCount?: number;
|
||||
/** Multi-select mode: shows a persistent corner checkbox and routes
|
||||
* clicks through onToggleSelect instead of onclick. */
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (e: MouseEvent | KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
albumId,
|
||||
artwork,
|
||||
title,
|
||||
artist,
|
||||
genre,
|
||||
releaseDate,
|
||||
quality,
|
||||
size = 'standard',
|
||||
searchId,
|
||||
onclick,
|
||||
onPlay,
|
||||
onPlayNext,
|
||||
onPlayLater,
|
||||
onAddAlbumToPlaylist,
|
||||
onShareQobuz,
|
||||
onShareSonglink,
|
||||
onDownload,
|
||||
showFavorite,
|
||||
favoriteEnabled,
|
||||
showGenre = true,
|
||||
isAlbumFullyDownloaded = false,
|
||||
onOpenContainingFolder,
|
||||
onReDownloadAlbum,
|
||||
downloadStateVersion,
|
||||
sourceBadge,
|
||||
artistId,
|
||||
onArtistClick,
|
||||
ribbon,
|
||||
isLocal,
|
||||
year,
|
||||
trackCount,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
onToggleSelect
|
||||
}: Props = $props();
|
||||
|
||||
const isDownloaded = $derived.by(() => {
|
||||
void downloadStateVersion;
|
||||
return isAlbumFullyDownloaded;
|
||||
});
|
||||
|
||||
let imageError = $state(false);
|
||||
const cardSize = $derived(size === 'large' ? 210 : 190);
|
||||
let titleRef: HTMLDivElement | null = $state(null);
|
||||
let titleTextRef: HTMLSpanElement | null = $state(null);
|
||||
let titleOverflow = $state(0);
|
||||
let artistRef: HTMLElement | null = $state(null);
|
||||
let artistTextRef: HTMLSpanElement | null = $state(null);
|
||||
let artistOverflow = $state(0);
|
||||
const titleOffset = $derived(titleOverflow > 0 ? `-${titleOverflow + 16}px` : '0px');
|
||||
const artistOffset = $derived(artistOverflow > 0 ? `-${artistOverflow + 16}px` : '0px');
|
||||
const tickerSpeed = 40;
|
||||
const titleDuration = $derived(titleOverflow > 0 ? `${(titleOverflow + 16) / tickerSpeed}s` : '0s');
|
||||
const artistDuration = $derived(artistOverflow > 0 ? `${(artistOverflow + 16) / tickerSpeed}s` : '0s');
|
||||
|
||||
let favoriteFromStore = $state(false);
|
||||
let isToggling = $state(false);
|
||||
const isFavorite = $derived(albumId ? favoriteFromStore : false);
|
||||
const hasMenu = $derived(!!(onPlayNext || onPlayLater || onShareQobuz || onShareSonglink || onDownload || albumId));
|
||||
const showFavoriteButton = $derived(showFavorite ?? !!albumId);
|
||||
const favoriteAvailable = $derived(favoriteEnabled ?? !!albumId);
|
||||
const hasOverlay = $derived(!!(showFavoriteButton || onPlay || hasMenu));
|
||||
let menuOpen = $state(false);
|
||||
let contextMenuOpen = $state(false);
|
||||
let contextMenuPos = $state<{ x: number; y: number } | null>(null);
|
||||
|
||||
function handleImageError() {
|
||||
imageError = true;
|
||||
}
|
||||
|
||||
function formatReleaseDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString($locale ? $locale : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
const formattedDate = $derived(formatReleaseDate(releaseDate));
|
||||
|
||||
async function handleToggleFavorite(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (!albumId || !favoriteAvailable) return;
|
||||
await toggleAlbumFavorite(albumId);
|
||||
}
|
||||
|
||||
function handlePlay(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
onPlay?.();
|
||||
}
|
||||
|
||||
function handleArtistClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (artistId && onArtistClick) {
|
||||
onArtistClick(artistId);
|
||||
}
|
||||
}
|
||||
|
||||
const artistClickable = $derived(!!(artistId && onArtistClick));
|
||||
|
||||
function handleAddToMixtape() {
|
||||
if (!albumId) return;
|
||||
const source = isLocal || !!sourceBadge ? 'local' : 'qobuz';
|
||||
const parsedYear = year ?? (releaseDate ? new Date(releaseDate).getFullYear() : undefined);
|
||||
openAddToMixtape({
|
||||
item_type: 'album',
|
||||
source,
|
||||
source_item_id: albumId,
|
||||
title,
|
||||
subtitle: artist,
|
||||
artwork_url: artwork,
|
||||
year: parsedYear && !isNaN(parsedYear) ? parsedYear : undefined,
|
||||
track_count: trackCount,
|
||||
});
|
||||
}
|
||||
|
||||
function isOverlayAction(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return !!target.closest('.action-buttons') || !!target.closest('.artist-link');
|
||||
}
|
||||
|
||||
function handleCardClick(event: MouseEvent) {
|
||||
if (isOverlayAction(event.target)) return;
|
||||
// Multi-select mode: click toggles selection instead of opening the card.
|
||||
if (selectable) {
|
||||
onToggleSelect?.(event);
|
||||
return;
|
||||
}
|
||||
onclick?.();
|
||||
}
|
||||
|
||||
function updateOverflow() {
|
||||
if (titleRef && titleTextRef) {
|
||||
const overflow = titleTextRef.scrollWidth - titleRef.clientWidth;
|
||||
titleOverflow = overflow > 0 ? overflow : 0;
|
||||
}
|
||||
if (artistRef && artistTextRef) {
|
||||
const overflow = artistTextRef.scrollWidth - artistRef.clientWidth;
|
||||
artistOverflow = overflow > 0 ? overflow : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Track if overflow has been measured (only measure on first hover for performance)
|
||||
let overflowMeasured = false;
|
||||
|
||||
function measureOverflowOnce() {
|
||||
if (!overflowMeasured) {
|
||||
updateOverflow();
|
||||
overflowMeasured = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Don't create ResizeObserver per-card for performance in large libraries
|
||||
// Overflow is measured on first hover instead
|
||||
|
||||
if (albumId) {
|
||||
void loadAlbumFavorites();
|
||||
favoriteFromStore = isAlbumFavorite(albumId);
|
||||
isToggling = isAlbumToggling(albumId);
|
||||
const unsubscribe = subscribeAlbumFavorites(() => {
|
||||
favoriteFromStore = isAlbumFavorite(albumId);
|
||||
isToggling = isAlbumToggling(albumId);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset measurement when title/artist changes
|
||||
$effect(() => {
|
||||
title;
|
||||
artist;
|
||||
overflowMeasured = false;
|
||||
titleOverflow = 0;
|
||||
artistOverflow = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="album-card"
|
||||
class:is-selectable={selectable}
|
||||
class:is-selected={selectable && selected}
|
||||
style="width: {cardSize}px"
|
||||
data-search-id={searchId}
|
||||
onclick={handleCardClick}
|
||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); menuOpen = true; contextMenuOpen = true; contextMenuPos = { x: e.clientX, y: e.clientY }; }}
|
||||
onmouseenter={measureOverflowOnce}
|
||||
onfocus={measureOverflowOnce}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (selectable) onToggleSelect?.(e);
|
||||
else onclick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Artwork Container -->
|
||||
<div
|
||||
class="artwork-container"
|
||||
style="width: {cardSize}px; height: {cardSize}px"
|
||||
>
|
||||
<!-- Placeholder always rendered as background layer (visible while image loads) -->
|
||||
<div class="artwork-placeholder">
|
||||
<Disc3 size={48} />
|
||||
</div>
|
||||
|
||||
<!-- Image overlays placeholder when loaded -->
|
||||
{#if !imageError && artwork}
|
||||
<img class="artwork-image" use:cachedSrc={albumId ? resolveAlbumCover(albumId, artwork) : artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
|
||||
{/if}
|
||||
|
||||
{#if selectable}
|
||||
<div class="select-checkbox" aria-hidden="true">
|
||||
{#if selected}<Check size={14} />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Overlay -->
|
||||
{#if hasOverlay}
|
||||
<div class="action-overlay" class:menu-open={menuOpen}>
|
||||
{#if showGenre && (genre || formattedDate)}
|
||||
<div class="overlay-info">
|
||||
{#if genre}<span class="overlay-genre">{genre}</span>{/if}
|
||||
{#if formattedDate}
|
||||
<span class="overlay-date">{formattedDate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="action-buttons">
|
||||
{#if showFavoriteButton}
|
||||
<button
|
||||
class="overlay-btn overlay-btn--minor"
|
||||
class:is-active={isFavorite}
|
||||
class:is-toggling={isToggling}
|
||||
class:disabled={!favoriteAvailable}
|
||||
type="button"
|
||||
aria-disabled={!favoriteAvailable || isToggling}
|
||||
disabled={!favoriteAvailable || isToggling}
|
||||
onclick={handleToggleFavorite}
|
||||
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
|
||||
>
|
||||
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer to keep Play button centered -->
|
||||
<div class="overlay-btn--spacer"></div>
|
||||
{/if}
|
||||
{#if onPlay}
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title={$t('actions.play')}>
|
||||
<Play size={18} fill="white" color="white" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasMenu}
|
||||
<div class="overlay-menu">
|
||||
<AlbumMenu
|
||||
onPlayNext={onPlayNext}
|
||||
onPlayLater={onPlayLater}
|
||||
onAddToPlaylist={onAddAlbumToPlaylist}
|
||||
onAddToMixtape={albumId ? handleAddToMixtape : undefined}
|
||||
onShareQobuz={onShareQobuz}
|
||||
onShareSonglink={onShareSonglink}
|
||||
onDownload={onDownload}
|
||||
isAlbumFullyDownloaded={isDownloaded}
|
||||
onOpenContainingFolder={onOpenContainingFolder}
|
||||
onReDownloadAlbum={onReDownloadAlbum}
|
||||
externalOpen={contextMenuOpen}
|
||||
contextMenuPosition={contextMenuPos}
|
||||
onOpenChange={(open) => { menuOpen = open; if (!open) { contextMenuOpen = false; contextMenuPos = null; } }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editorial ribbon (Album of the Week / Qobuzissime / Press).
|
||||
Only the last award in album.awards is shown on the card; the
|
||||
full stack lives in AlbumView's sidebar. -->
|
||||
{#if ribbon}
|
||||
<div class="editorial-ribbon"
|
||||
class:ribbon-aotw={ribbon.kind === 'albumOfTheWeek'}
|
||||
class:ribbon-qbz={ribbon.kind === 'qobuzissime'}
|
||||
class:ribbon-press={ribbon.kind === 'press'}
|
||||
title={ribbon.label}>
|
||||
{ribbon.label}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Source Badge (Local Library only) -->
|
||||
{#if sourceBadge}
|
||||
<div class="source-badge-slot">
|
||||
<SourceBadge value={sourceBadge} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Text Info -->
|
||||
<div class="info">
|
||||
<div
|
||||
class="title"
|
||||
class:scrollable={titleOverflow > 0}
|
||||
style="--ticker-offset: {titleOffset}; --ticker-duration: {titleDuration};"
|
||||
bind:this={titleRef}
|
||||
>
|
||||
<span class="title-text" bind:this={titleTextRef}>{title}</span>
|
||||
</div>
|
||||
{#if artistClickable}
|
||||
<button
|
||||
class="artist artist-link"
|
||||
class:scrollable={artistOverflow > 0}
|
||||
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
|
||||
bind:this={artistRef}
|
||||
onclick={handleArtistClick}
|
||||
type="button"
|
||||
>
|
||||
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="artist"
|
||||
class:scrollable={artistOverflow > 0}
|
||||
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
|
||||
bind:this={artistRef}
|
||||
>
|
||||
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if quality}
|
||||
<div class="quality-badge">{quality}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.album-card {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Multi-select corner checkbox (top-left of the artwork). Always rendered
|
||||
when selectable=true; filled accent when selected, outlined-only when not. */
|
||||
.select-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.album-card.is-selected .select-checkbox {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.album-card.is-selected {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
/* Disable the drag-cursor feel when in select-mode — tap = toggle. */
|
||||
.album-card.is-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.artwork-container .artwork-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
z-index: 1;
|
||||
transition: opacity 0.15s ease-in;
|
||||
}
|
||||
|
||||
.artwork-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-muted);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.quality-badge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-family: 'LINE Seed JP', var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 100;
|
||||
color: var(--alpha-85);
|
||||
background: var(--alpha-10);
|
||||
border: 1px solid var(--alpha-15);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global([data-theme="light"]) .quality-badge {
|
||||
color: rgba(40, 42, 54, 0.85) !important;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(40, 42, 54, 0.95) !important;
|
||||
}
|
||||
|
||||
/* Source badge for Local Library albums */
|
||||
/* Editorial ribbon — sits at bottom-left of the artwork. z-index
|
||||
below the action-overlay (z-index 2) so it hides on hover, as
|
||||
requested. */
|
||||
.editorial-ribbon {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
padding: 3px 10px 3px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100% - 12px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.editorial-ribbon.ribbon-aotw {
|
||||
border-left-color: #eab308;
|
||||
}
|
||||
|
||||
.editorial-ribbon.ribbon-qbz {
|
||||
border-left-color: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Press accolades get a solid gold ribbon with dark, readable text —
|
||||
distinct from the Qobuz-branded variants (which stay dark with a
|
||||
coloured left border). */
|
||||
.editorial-ribbon.ribbon-press {
|
||||
background: linear-gradient(135deg, #f5c042 0%, #d49511 100%);
|
||||
color: #1f1407;
|
||||
border-left: none;
|
||||
padding-left: 10px;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.source-badge-slot {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.action-overlay {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
background: rgba(10, 10, 10, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
border-radius: inherit;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-card:hover .action-overlay,
|
||||
.action-overlay:focus-within,
|
||||
.action-overlay.menu-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 75%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.album-card:hover .action-buttons,
|
||||
.action-overlay:focus-within .action-buttons,
|
||||
.action-overlay.menu-open .action-buttons {
|
||||
animation: slide-in-down 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slide-in-down {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% - 12px));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
/* Use box-shadow instead of border for smoother anti-aliasing */
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.overlay-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-primary), 0 0 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.overlay-btn.is-active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.overlay-btn.disabled,
|
||||
.overlay-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.overlay-btn.is-toggling {
|
||||
cursor: wait;
|
||||
animation: favorite-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes favorite-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overlay-btn--minor {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.overlay-btn--spacer {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.overlay-info {
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.album-card:hover .overlay-info,
|
||||
.action-overlay:focus-within .overlay-info,
|
||||
.action-overlay.menu-open .overlay-info {
|
||||
animation: slide-in-up 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slide-in-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-genre {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.overlay-date {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
:global(.album-card .album-menu) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.album-card .album-menu .menu-trigger) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
/* Use box-shadow instead of border for smoother anti-aliasing */
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
transition: background-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
:global(.album-card .album-menu .menu-trigger:hover) {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-primary), 0 0 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.title.scrollable {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.album-card:hover .title.scrollable .title-text {
|
||||
animation: title-ticker var(--ticker-duration) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes title-ticker {
|
||||
0%, 20% { transform: translateX(0); }
|
||||
70%, 80% { transform: translateX(var(--ticker-offset)); }
|
||||
90%, 100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist.artist-link {
|
||||
display: block;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artist.artist-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.artist.scrollable {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.artist-text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.album-card:hover .artist.scrollable .artist-text,
|
||||
.artist.artist-link.scrollable:hover .artist-text {
|
||||
animation: title-ticker var(--ticker-duration) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
@@ -173,7 +173,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={onClose} role="presentation">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="credits-modal" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="credits-modal selectable" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<LoaderCircle size={32} class="spinner" />
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="booklet-content" bind:this={containerEl}>
|
||||
<div class="booklet-content selectable" bind:this={containerEl}>
|
||||
{#if isLoading}
|
||||
<div class="booklet-loading">
|
||||
<div class="spinner"></div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import ModalLite from '$lib/discovery-v2/ModalLite.svelte';
|
||||
|
||||
// First-time warning shown to users running without hardware acceleration
|
||||
// when they enter immersive mode. Five known-heavy panels are already
|
||||
// disabled by default in CPU mode; this modal tells the user why so they
|
||||
// know where to look if they want to override or fix their graphics
|
||||
// configuration.
|
||||
//
|
||||
// Triggered by the parent (+page.svelte) when immersive opens, HW accel
|
||||
// is off, and the user hasn't dismissed the warning before.
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: (dontShowAgain: boolean) => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
let dontShowAgain = $state(true);
|
||||
|
||||
function handleAcknowledge() {
|
||||
onClose(dontShowAgain);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onClose(dontShowAgain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalLite
|
||||
{isOpen}
|
||||
onClose={handleClose}
|
||||
title={$t('settings.appearance.immersivePanels.warningModal.title')}
|
||||
maxWidth="520px"
|
||||
footerAlign="end"
|
||||
>
|
||||
<p class="warning-body">{$t('settings.appearance.immersivePanels.warningModal.body')}</p>
|
||||
<p class="warning-hint">{$t('settings.appearance.immersivePanels.warningModal.hint')}</p>
|
||||
|
||||
<label class="dont-show-row">
|
||||
<input type="checkbox" bind:checked={dontShowAgain} />
|
||||
<span>{$t('settings.appearance.immersivePanels.warningModal.dontShowAgain')}</span>
|
||||
</label>
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn-primary" onclick={handleAcknowledge}>
|
||||
{$t('settings.appearance.immersivePanels.warningModal.acknowledge')}
|
||||
</button>
|
||||
{/snippet}
|
||||
</ModalLite>
|
||||
|
||||
<style>
|
||||
.warning-body {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning-hint {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
background: var(--alpha-05, rgba(255, 255, 255, 0.04));
|
||||
border-left: 3px solid var(--warning, #f59e0b);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, var(--text-muted));
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dont-show-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, var(--text-muted));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dont-show-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent-primary, #7c3aed);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-primary, #7c3aed);
|
||||
color: var(--btn-primary-text, white);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -490,7 +490,6 @@
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.option {
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
runtimeIsVm: boolean;
|
||||
runtimeHwAccelEnabled: boolean;
|
||||
runtimeForceX11Active: boolean;
|
||||
runtimeGpuName: string;
|
||||
runtimeDesktopEnvironment: string;
|
||||
devForceDmabuf: boolean;
|
||||
envWebkitDisableDmabuf: string | null;
|
||||
envWebkitDisableCompositing: string | null;
|
||||
@@ -306,9 +308,11 @@
|
||||
{ label: 'GDK Scale', saved: str(diag.gfxGdkScale), runtime: '—', status: 'info' },
|
||||
{ label: 'GDK DPI Scale', saved: str(diag.gfxGdkDpiScale), runtime: '—', status: 'info' },
|
||||
{ label: 'Compositing Mode', saved: '—', runtime: compositing, status: 'info' },
|
||||
{ label: 'GPU', saved: '—', runtime: diag.runtimeGpuName || 'Unknown', status: 'info' },
|
||||
{ label: 'GPU: NVIDIA', saved: '—', runtime: diag.runtimeHasNvidia ? 'Detected' : 'No', status: 'info' },
|
||||
{ label: 'GPU: Intel', saved: '—', runtime: diag.runtimeHasIntel ? 'Detected' : 'No', status: 'info' },
|
||||
{ label: 'GPU: AMD', saved: '—', runtime: diag.runtimeHasAmd ? 'Detected' : 'No', status: 'info' },
|
||||
{ label: 'Desktop Environment', saved: '—', runtime: diag.runtimeDesktopEnvironment || 'Unknown', status: 'info' },
|
||||
{ label: 'Wayland', saved: '—', runtime: diag.runtimeIsWayland ? 'Yes' : 'No (X11)', status: 'info' },
|
||||
{ label: 'VM', saved: '—', runtime: diag.runtimeIsVm ? 'Yes' : 'No', status: 'info' },
|
||||
{ label: 'Using Fallback', saved: '—', runtime: bool(diag.runtimeUsingFallback), status: diag.runtimeUsingFallback ? 'mismatch' : 'info' },
|
||||
@@ -461,7 +465,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="diagnostics-panel">
|
||||
<div class="diagnostics-panel selectable">
|
||||
<button class="section-toggle panel-toggle" onclick={togglePanel}>
|
||||
{#if panelOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
<h4 class="subsection-title" style="margin:0">{$t('settings.developer.diagnostics.title')}</h4>
|
||||
|
||||
@@ -243,7 +243,6 @@
|
||||
.playlist-card {
|
||||
width: 140px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
import { GripVertical } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { FavoritesPreferences } from '../types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
import { Heart, Star, Music, Folder, Disc, Library, Headphones, Upload, Eye, EyeOff } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PlaylistFolder } from '$lib/stores/playlistFoldersStore';
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
context?: GenreFilterContext;
|
||||
variant?: ButtonVariant;
|
||||
align?: DropdownAlign;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
let { onFilterChange, context = 'home', variant = 'default', align = 'left' }: Props = $props();
|
||||
let { onFilterChange, context = 'home', variant = 'default', align = 'left', isOpen = $bindable(false) }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let buttonEl = $state<HTMLButtonElement | null>(null);
|
||||
let hasFilter = $state(false);
|
||||
let selectedGenreName = $state<string | null>(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ChevronUp, ChevronDown, RotateCcw } from 'lucide-svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
import {
|
||||
getSettings,
|
||||
subscribe,
|
||||
|
||||
@@ -189,12 +189,6 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
cursor: grab;
|
||||
/* Never select text inside carousels: drag-to-scroll would otherwise
|
||||
highlight titles/artists in the first few pixels before the drag
|
||||
threshold kicks in. Text is still selectable in the dedicated
|
||||
album/artist/playlist views. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.scroll-container.dragging {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
class="modal selectable"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-modal-title"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
import { GripVertical, Eye, EyeOff } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { LibraryPreferences } from '../types';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Link2 } from 'lucide-svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
|
||||
interface ResolvedLink {
|
||||
type: 'OpenAlbum' | 'OpenTrack' | 'OpenArtist' | 'OpenPlaylist';
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import TrackRow from './TrackRow.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import { formatTrackTitle } from '$lib/utils/trackTitle';
|
||||
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
|
||||
|
||||
interface LocalTrack {
|
||||
id: number;
|
||||
@@ -118,6 +119,20 @@
|
||||
getFullArtworkUrl: (path?: string) => string;
|
||||
buildAlbumSections: (tracks: LocalTrack[]) => AlbumSection[];
|
||||
normalizeArtistName: (name: string) => string;
|
||||
/**
|
||||
* One of the parent's selection contexts (tracks tab, albums tab,
|
||||
* folder tree) is currently active. When this flips to `true` while
|
||||
* this view has its own local selection mode on, the local
|
||||
* selection is cleared — light-switch mutex across the parent/child
|
||||
* boundary so two selections can never be live at the same time.
|
||||
*/
|
||||
parentSelectionActive?: boolean;
|
||||
/**
|
||||
* Called when the user toggles ON this view's local selection mode.
|
||||
* Parent uses it to clear its own selection contexts so the
|
||||
* footer's BulkActionBar reflects a single source of truth.
|
||||
*/
|
||||
onSelectionEntered?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -147,6 +162,8 @@
|
||||
getFullArtworkUrl,
|
||||
buildAlbumSections,
|
||||
normalizeArtistName,
|
||||
parentSelectionActive = false,
|
||||
onSelectionEntered,
|
||||
}: Props = $props();
|
||||
|
||||
let trackSearchQuery = $state('');
|
||||
@@ -191,20 +208,102 @@
|
||||
|
||||
function toggleSelectionMode() {
|
||||
selectionMode = !selectionMode;
|
||||
if (!selectionMode) selectedTrackIds = new Set();
|
||||
if (!selectionMode) {
|
||||
selectedTrackIds = new Set();
|
||||
selectionAnchorId = null;
|
||||
} else {
|
||||
// Notify parent so it can clear its own selection contexts —
|
||||
// light-switch mutex with the parent (tracks-tab / albums-tab /
|
||||
// folder-tree). The parent's `clearOtherSelectionContexts`
|
||||
// handles the cross-context teardown.
|
||||
onSelectionEntered?.();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrackSelection(trackId: number) {
|
||||
// Inbound half of the cross-component mutex: when the parent enters
|
||||
// any of its own selection contexts, our local selection turns off.
|
||||
// No infinite loop because the toggle above only fires
|
||||
// `onSelectionEntered` on the ENTRY transition (false -> true).
|
||||
$effect(() => {
|
||||
if (parentSelectionActive && selectionMode) {
|
||||
selectionMode = false;
|
||||
selectedTrackIds = new Set();
|
||||
selectionAnchorId = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Anchor for shift-click range selection. Set by every plain
|
||||
// (non-shift) click and consumed by the next shift-click. Reset
|
||||
// whenever selection mode is exited so a future re-entry starts
|
||||
// from a fresh anchor.
|
||||
let selectionAnchorId: number | null = null;
|
||||
|
||||
/**
|
||||
* Toggle a single track in the selection, with optional shift-range
|
||||
* extension. When `shift` is true and an anchor exists, every track
|
||||
* between the anchor and `trackId` (inclusive, in current visual
|
||||
* order) is added to the selection — matches the tracks-tab behaviour
|
||||
* in `LocalLibraryView`. Without shift, this is a plain toggle and
|
||||
* the clicked track becomes the new anchor.
|
||||
*/
|
||||
function toggleTrackSelection(trackId: number, shift: boolean = false) {
|
||||
if (shift && selectionAnchorId !== null && selectionAnchorId !== trackId) {
|
||||
const ids = tracks.map((trk) => trk.id);
|
||||
const lastIndex = ids.indexOf(selectionAnchorId);
|
||||
const currentIndex = ids.indexOf(trackId);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
selectedTrackIds = applyShiftRange({
|
||||
current: selectedTrackIds,
|
||||
ids,
|
||||
lastIndex,
|
||||
currentIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const next = new Set(selectedTrackIds);
|
||||
if (next.has(trackId)) next.delete(trackId);
|
||||
else next.add(trackId);
|
||||
selectedTrackIds = next;
|
||||
selectionAnchorId = trackId;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedTrackIds = new Set();
|
||||
selectionAnchorId = null;
|
||||
}
|
||||
|
||||
// Tri-state for the "Select All" checkbox at the top of the track
|
||||
// list — 'none' / 'partial' / 'all' based on how many of the
|
||||
// currently visible album tracks are in the selection set.
|
||||
const selectAllState = $derived(
|
||||
tracks.length === 0 ? 'none' as const
|
||||
: selectedTrackIds.size === 0 ? 'none' as const
|
||||
: tracks.every((trk) => selectedTrackIds.has(trk.id)) ? 'all' as const
|
||||
: 'partial' as const
|
||||
);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectAllState === 'all') {
|
||||
selectedTrackIds = new Set();
|
||||
} else {
|
||||
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+A while selection mode is active selects every visible
|
||||
// track. No-op when focus is in a text input.
|
||||
$effect(() => {
|
||||
if (!selectionMode) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!isSelectAllShortcut(e)) return;
|
||||
e.preventDefault();
|
||||
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
/** Resolve the selected IDs back to the full LocalTrack objects so we
|
||||
* can split local vs Plex rows for the playlist add path. */
|
||||
function selectedTracks(): LocalTrack[] {
|
||||
@@ -381,6 +480,17 @@
|
||||
>
|
||||
<SquareCheckBig size={18} />
|
||||
</button>
|
||||
{#if selectionMode}
|
||||
<label class="folder-album-select-all" title={$t('actions.selectAll')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectAllState === 'all'}
|
||||
indeterminate={selectAllState === 'partial'}
|
||||
onchange={toggleSelectAll}
|
||||
/>
|
||||
<span>{$t('actions.selectAll')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn-circle"
|
||||
@@ -504,7 +614,7 @@
|
||||
hideFavorite={true}
|
||||
selectable={selectionMode}
|
||||
selected={selectedTrackIds.has(track.id)}
|
||||
onToggleSelect={() => toggleTrackSelection(track.id)}
|
||||
onToggleSelect={(e) => toggleTrackSelection(track.id, e.shiftKey)}
|
||||
onArtistClick={track.artist && track.artist !== album.artist && onArtistClick
|
||||
? () => onArtistClick?.(track.artist)
|
||||
: undefined}
|
||||
@@ -912,4 +1022,26 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Select-all checkbox shown next to the selection-mode toggle in the
|
||||
folder album header. Matches the visual rhythm of the other inline
|
||||
toolbar items — small, sits beside the round action buttons rather
|
||||
than competing with them. */
|
||||
.folder-album-select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.folder-album-select-all input[type='checkbox'] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -326,7 +326,6 @@
|
||||
min-height: 22px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
color: var(--text-primary);
|
||||
transition: background 100ms ease;
|
||||
/* Extend to natural content width when names are long; fill the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import Modal from '$lib/discovery-v2/ModalLite.svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user