diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 962243f93..f5bf99b82 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5397,7 +5397,7 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "qbz" -version = "0.1.0" +version = "1.2.15" dependencies = [ "arboard", "async-trait", @@ -5424,6 +5424,7 @@ dependencies = [ "qbz-i18n", "qbz-integrations", "qbz-library", + "qbz-log", "qbz-lyrics", "qbz-media-controls", "qbz-mixtape", @@ -5456,7 +5457,7 @@ dependencies = [ [[package]] name = "qbz-app" -version = "0.1.0" +version = "1.2.15" dependencies = [ "base64", "chrono", @@ -5479,7 +5480,7 @@ dependencies = [ [[package]] name = "qbz-audio" -version = "0.1.0" +version = "1.2.15" dependencies = [ "alsa", "alsa-sys", @@ -5535,7 +5536,7 @@ dependencies = [ [[package]] name = "qbz-cmaf" -version = "0.1.0" +version = "1.2.15" dependencies = [ "aes", "base64", @@ -5550,7 +5551,7 @@ dependencies = [ [[package]] name = "qbz-core" -version = "0.1.0" +version = "1.2.15" dependencies = [ "anyhow", "async-trait", @@ -5571,7 +5572,7 @@ dependencies = [ [[package]] name = "qbz-credentials" -version = "0.1.0" +version = "1.2.15" dependencies = [ "aes-gcm", "ashpd 0.13.11", @@ -5588,7 +5589,7 @@ dependencies = [ [[package]] name = "qbz-dac-wizard" -version = "0.1.0" +version = "1.2.15" dependencies = [ "qbz-audio", "qbz-i18n", @@ -5599,7 +5600,7 @@ dependencies = [ [[package]] name = "qbz-external-reco" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "futures-util", @@ -5650,6 +5651,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "qbz-log" +version = "1.2.15" +dependencies = [ + "chrono", + "dirs 5.0.1", + "env_logger", + "log", + "regex", +] + [[package]] name = "qbz-lyrics" version = "0.1.0" @@ -5670,7 +5682,7 @@ dependencies = [ [[package]] name = "qbz-media-controls" -version = "0.1.0" +version = "1.2.15" dependencies = [ "ashpd 0.13.11", "async-channel", @@ -5706,7 +5718,7 @@ dependencies = [ [[package]] name = "qbz-models" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "chrono", @@ -5720,7 +5732,7 @@ dependencies = [ [[package]] name = "qbz-music-link" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "log", @@ -5735,7 +5747,7 @@ dependencies = [ [[package]] name = "qbz-offline-cache" -version = "0.1.0" +version = "1.2.15" dependencies = [ "dirs 6.0.0", "futures-util", @@ -5756,7 +5768,7 @@ dependencies = [ [[package]] name = "qbz-player" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "futures-util", @@ -5793,7 +5805,7 @@ dependencies = [ [[package]] name = "qbz-plex" -version = "0.1.0" +version = "1.2.15" dependencies = [ "dirs 6.0.0", "open", @@ -5804,7 +5816,7 @@ dependencies = [ [[package]] name = "qbz-qobuz" -version = "0.1.0" +version = "1.2.15" dependencies = [ "anyhow", "base64", @@ -5826,7 +5838,7 @@ dependencies = [ [[package]] name = "qbz-radio" -version = "0.1.0" +version = "1.2.15" dependencies = [ "dirs 5.0.1", "log", @@ -5837,7 +5849,7 @@ dependencies = [ [[package]] name = "qbz-reco" -version = "0.1.0" +version = "1.2.15" dependencies = [ "log", "qbz-integrations", @@ -5851,7 +5863,7 @@ dependencies = [ [[package]] name = "qbz-secrets" -version = "0.1.0" +version = "1.2.15" dependencies = [ "aes-gcm", "base64", @@ -5868,7 +5880,7 @@ dependencies = [ [[package]] name = "qbz-slint-common" -version = "0.1.0" +version = "1.2.15" dependencies = [ "qbz-ui", "slint", @@ -5876,7 +5888,7 @@ dependencies = [ [[package]] name = "qbz-text-utils" -version = "0.1.0" +version = "1.2.15" dependencies = [ "chrono", "qbz-i18n", @@ -5884,14 +5896,14 @@ dependencies = [ [[package]] name = "qbz-theme" -version = "0.1.0" +version = "1.2.15" dependencies = [ "serde", ] [[package]] name = "qbz-ui" -version = "0.1.0" +version = "1.2.15" dependencies = [ "slint", "slint-build", @@ -5899,7 +5911,7 @@ dependencies = [ [[package]] name = "qconnect-app" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "log", @@ -5918,7 +5930,7 @@ dependencies = [ [[package]] name = "qconnect-core" -version = "0.1.0" +version = "1.2.15" dependencies = [ "qbz-models", "serde", @@ -5927,7 +5939,7 @@ dependencies = [ [[package]] name = "qconnect-protocol" -version = "0.1.0" +version = "1.2.15" dependencies = [ "log", "prost", @@ -5940,7 +5952,7 @@ dependencies = [ [[package]] name = "qconnect-transport-ws" -version = "0.1.0" +version = "1.2.15" dependencies = [ "async-trait", "futures-util", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index e0c6abff1..085c90e92 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -36,10 +36,11 @@ members = [ "qbz-music-link", "qbz-reco", "qbz-external-reco", + "qbz-log", ] [workspace.package] -version = "0.1.0" +version = "1.2.15" edition = "2021" license = "MIT" repository = "https://github.com/vicrodh/qbz" diff --git a/crates/qbz-app/src/diagnostics.rs b/crates/qbz-app/src/diagnostics.rs new file mode 100644 index 000000000..30f5de09a --- /dev/null +++ b/crates/qbz-app/src/diagnostics.rs @@ -0,0 +1,356 @@ +//! Frontend-agnostic diagnostics snapshot builders. +//! +//! This module is a faithful COPY of the Tauri diagnostics backend +//! (`src-tauri/src/commands_v2/diagnostics.rs`) so the Slint `qbz` bin can +//! produce byte-identical `RuntimeDiagnostics` + `SystemInfo` snapshots +//! headlessly. The Tauri source stays untouched as the reference copy. +//! +//! Everything here is pure: std + `/proc` + `/sys` + `/etc/os-release` + +//! `/proc/self/maps`, plus three settings structs passed in by the caller. +//! No `tauri::` types, no `crate::runtime::RuntimeError`. Both builders are +//! infallible and return their struct directly. +//! +//! The `#[serde(rename_all = "camelCase")]` on both structs is load-bearing: +//! the exported JSON keys (and the existing Svelte TS interface) depend on it, +//! so the shared struct keeps the same derive + rename for a byte-identical +//! export. + +// ==================== Runtime Diagnostics ==================== + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeDiagnostics { + // Audio: saved settings + pub audio_output_device: Option, + pub audio_backend_type: Option, + pub audio_exclusive_mode: bool, + pub audio_dac_passthrough: bool, + pub audio_preferred_sample_rate: Option, + pub audio_alsa_plugin: Option, + pub audio_alsa_hardware_volume: bool, + pub audio_normalization_enabled: bool, + pub audio_normalization_target_lufs: f32, + pub audio_gapless_enabled: bool, + pub audio_pw_force_bitperfect: bool, + pub audio_stream_buffer_seconds: u8, + pub audio_streaming_only: bool, + + // Graphics: saved settings + pub gfx_hardware_acceleration: bool, + pub gfx_force_x11: bool, + pub gfx_gdk_scale: Option, + pub gfx_gdk_dpi_scale: Option, + pub gfx_gsk_renderer: Option, + + // Graphics: runtime (what actually applied at startup) + pub runtime_using_fallback: bool, + pub runtime_is_wayland: bool, + pub runtime_has_nvidia: bool, + pub runtime_has_amd: bool, + pub runtime_has_intel: bool, + 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, + + // Environment variables (what WebKit actually sees) + pub env_webkit_disable_dmabuf: Option, + pub env_webkit_disable_compositing: Option, + pub env_gdk_backend: Option, + pub env_gsk_renderer: Option, + pub env_libgl_always_software: Option, + pub env_wayland_display: Option, + pub env_xdg_session_type: Option, + + // App info + pub app_version: String, +} + +/// Graphics runtime state the caller feeds in. +/// +/// Mirrors the Tauri startup atomics (`get_graphics_startup_status()`), so the +/// builder stays framework-agnostic. The Tauri command maps its atomics into +/// this; the Slint bin computes it fresh via [`detect_graphics_runtime`]. +pub struct GraphicsRuntime { + pub using_fallback: bool, + pub is_wayland: bool, + pub has_nvidia: bool, + pub has_amd: bool, + pub has_intel: bool, + pub is_vm: bool, + pub hardware_accel_enabled: bool, + pub force_x11_active: bool, +} + +/// Inputs for [`runtime_diagnostics`]. The caller reads the three settings +/// structs from their stores, builds a [`GraphicsRuntime`], and passes a real +/// `app_version` string. +pub struct DiagnosticsInputs<'a> { + pub audio: &'a qbz_audio::settings::AudioSettings, + pub graphics: &'a crate::settings::graphics::GraphicsSettings, + pub developer: &'a crate::settings::developer::DeveloperSettings, + pub gfx: GraphicsRuntime, + pub app_version: &'a str, +} + +/// Build the runtime diagnostics snapshot. Infallible. +/// +/// Faithful port of `v2_get_runtime_diagnostics` (Tauri) reading from the +/// passed-in structs instead of `tauri::State`. The two `format!("{:?}", ..)` +/// Debug conversions for `audio_backend_type` and `audio_alsa_plugin` are kept +/// so the exported strings match Tauri exactly (stable enum variant names). +pub fn runtime_diagnostics(i: &DiagnosticsInputs<'_>) -> RuntimeDiagnostics { + let audio = i.audio; + let gfx = i.graphics; + let dev = i.developer; + + let env_var = |name: &str| std::env::var(name).ok(); + + RuntimeDiagnostics { + audio_output_device: audio.output_device.clone(), + audio_backend_type: audio.backend_type.map(|b| format!("{:?}", b)), + audio_exclusive_mode: audio.exclusive_mode, + audio_dac_passthrough: audio.dac_passthrough, + audio_preferred_sample_rate: audio.preferred_sample_rate, + audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)), + audio_alsa_hardware_volume: audio.alsa_hardware_volume, + audio_normalization_enabled: audio.normalization_enabled, + audio_normalization_target_lufs: audio.normalization_target_lufs, + audio_gapless_enabled: audio.gapless_enabled, + audio_pw_force_bitperfect: audio.pw_force_bitperfect, + audio_stream_buffer_seconds: audio.stream_buffer_seconds, + audio_streaming_only: audio.streaming_only, + + gfx_hardware_acceleration: gfx.hardware_acceleration, + gfx_force_x11: gfx.force_x11, + gfx_gdk_scale: gfx.gdk_scale.clone(), + gfx_gdk_dpi_scale: gfx.gdk_dpi_scale.clone(), + gfx_gsk_renderer: gfx.gsk_renderer.clone(), + + runtime_using_fallback: i.gfx.using_fallback, + runtime_is_wayland: i.gfx.is_wayland, + runtime_has_nvidia: i.gfx.has_nvidia, + runtime_has_amd: i.gfx.has_amd, + runtime_has_intel: i.gfx.has_intel, + runtime_is_vm: i.gfx.is_vm, + runtime_hw_accel_enabled: i.gfx.hardware_accel_enabled, + runtime_force_x11_active: i.gfx.force_x11_active, + runtime_gpu_name: crate::graphics_autoconfig::detect_gpu_name( + i.gfx.has_nvidia, + i.gfx.has_amd, + i.gfx.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, + + env_webkit_disable_dmabuf: env_var("WEBKIT_DISABLE_DMABUF_RENDERER"), + env_webkit_disable_compositing: env_var("WEBKIT_DISABLE_COMPOSITING_MODE"), + env_gdk_backend: env_var("GDK_BACKEND"), + env_gsk_renderer: env_var("GSK_RENDERER"), + env_libgl_always_software: env_var("LIBGL_ALWAYS_SOFTWARE"), + env_wayland_display: env_var("WAYLAND_DISPLAY"), + env_xdg_session_type: env_var("XDG_SESSION_TYPE"), + + app_version: i.app_version.to_string(), + } +} + +/// Compute a [`GraphicsRuntime`] for the headless/Slint path. +/// +/// Runs [`crate::graphics_autoconfig::detect_environment`] (pure /proc + /sys + +/// env detection) and maps it. `hardware_accel_enabled` reflects the saved +/// graphics setting; `force_x11_active` is `false` (the Slint bin has no +/// force-x11 path — it renders via winit/wgpu, not GDK); `using_fallback` is +/// passed in by the caller (e.g. set when the graphics store failed to open). +pub fn detect_graphics_runtime( + saved: &crate::settings::graphics::GraphicsSettings, + using_fallback: bool, +) -> GraphicsRuntime { + let env = crate::graphics_autoconfig::detect_environment(); + GraphicsRuntime { + using_fallback, + is_wayland: env.display_server == "Wayland", + has_nvidia: env.gpu_nvidia, + has_amd: env.gpu_amd, + has_intel: env.gpu_intel, + is_vm: env.is_vm, + hardware_accel_enabled: saved.hardware_acceleration, + force_x11_active: false, + } +} + +// ==================== System Info ==================== + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemInfo { + pub os: String, + pub arch: String, + pub kernel_version: Option, + pub distro_id: Option, + pub distro_version_id: Option, + pub distro_pretty_name: Option, + pub install_method: String, + pub flatpak_runtime: Option, + pub flatpak_runtime_version: Option, + pub webkit2gtk_version: Option, + pub gtk_version: Option, + pub glibc_version: Option, + pub alsa_version: Option, + pub pipewire_version: Option, + pub pulseaudio_version: Option, +} + +/// Parse /etc/os-release (or the Flatpak host equivalent) into key/value pairs. +fn read_os_release() -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + // Try Flatpak-exposed host file first, then the normal path. + let candidates = ["/run/host/os-release", "/etc/os-release"]; + for path in candidates { + if let Ok(text) = std::fs::read_to_string(path) { + for line in text.lines() { + if let Some(idx) = line.find('=') { + let key = line[..idx].trim().to_string(); + let mut value = line[idx + 1..].trim().to_string(); + if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 { + value = value[1..value.len() - 1].to_string(); + } + map.insert(key, value); + } + } + if !map.is_empty() { + return map; + } + } + } + map +} + +fn detect_kernel_version() -> Option { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_string()) +} + +fn detect_install_method() -> (String, Option, Option) { + // (method, flatpak_runtime, flatpak_runtime_version) + if std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists() { + let mut runtime = None; + let mut runtime_version = None; + if let Ok(text) = std::fs::read_to_string("/.flatpak-info") { + for line in text.lines() { + if let Some(value) = line.strip_prefix("runtime=") { + let v = value.trim(); + if let Some(slash) = v.rfind('/') { + runtime = Some(v[..slash].to_string()); + runtime_version = Some(v[slash + 1..].to_string()); + } else { + runtime = Some(v.to_string()); + } + } + } + } + return ("flatpak".to_string(), runtime, runtime_version); + } + if std::env::var("SNAP").is_ok() { + return ("snap".to_string(), None, None); + } + if std::env::var("APPIMAGE").is_ok() { + return ("appimage".to_string(), None, None); + } + if cfg!(debug_assertions) { + return ("dev".to_string(), None, None); + } + ("native".to_string(), None, None) +} + +/// Extract the best-available version string from the filename of a shared +/// library loaded by the current process. Looks for patterns like +/// `libfoo.so.0.15.7` → `0.15.7`, or `libfoo.so.2` → `2`. +/// Returns `None` if the library isn't mapped. +fn detect_loaded_lib_version(lib_name_stem: &str) -> Option { + let maps = std::fs::read_to_string("/proc/self/maps").ok()?; + let mut best: Option = None; + for line in maps.lines() { + // Last column is the path (may contain spaces, very rare). + let path = line.splitn(6, ' ').nth(5).unwrap_or("").trim(); + if path.is_empty() { + continue; + } + let filename = std::path::Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if !filename.starts_with(lib_name_stem) { + continue; + } + // filename example: "libwebkit2gtk-4.1.so.0.15.7" + // Strip the "lib_name_stem.so" prefix and leading dot. + let tail = match filename.split_once(".so") { + Some((_, rest)) => rest.trim_start_matches('.'), + None => continue, + }; + if tail.is_empty() { + continue; + } + // Resolve symlink target if possible — often the real file carries + // a fuller version than the SONAME alias. + if let Ok(real) = std::fs::canonicalize(path) { + if let Some(real_name) = real.file_name().and_then(|s| s.to_str()) { + if let Some((_, rest)) = real_name.split_once(".so") { + let real_tail = rest.trim_start_matches('.'); + if !real_tail.is_empty() { + best = Some(real_tail.to_string()); + continue; + } + } + } + } + best.get_or_insert_with(|| tail.to_string()); + } + best +} + +/// Build the system info snapshot. Pure + infallible. +/// +/// Faithful port of `v2_get_system_info` (Tauri) minus the Ok-wrap. +pub fn system_info() -> SystemInfo { + let os = std::env::consts::OS.to_string(); + let arch = std::env::consts::ARCH.to_string(); + let (install_method, flatpak_runtime, flatpak_runtime_version) = detect_install_method(); + let osr = read_os_release(); + + SystemInfo { + os, + arch, + kernel_version: detect_kernel_version(), + distro_id: osr.get("ID").cloned(), + distro_version_id: osr.get("VERSION_ID").cloned(), + distro_pretty_name: osr + .get("PRETTY_NAME") + .cloned() + .or_else(|| osr.get("NAME").cloned()), + install_method, + flatpak_runtime, + flatpak_runtime_version, + // Runtime shared-library versions, parsed from /proc/self/maps. + webkit2gtk_version: detect_loaded_lib_version("libwebkit2gtk-4.1"), + gtk_version: detect_loaded_lib_version("libgtk-3") + .or_else(|| detect_loaded_lib_version("libgtk-4")), + glibc_version: detect_loaded_lib_version("libc"), + alsa_version: detect_loaded_lib_version("libasound"), + pipewire_version: detect_loaded_lib_version("libpipewire-0.3"), + pulseaudio_version: detect_loaded_lib_version("libpulse"), + } +} diff --git a/crates/qbz-app/src/lib.rs b/crates/qbz-app/src/lib.rs index df1bd0ab6..887f534f8 100644 --- a/crates/qbz-app/src/lib.rs +++ b/crates/qbz-app/src/lib.rs @@ -1,4 +1,5 @@ pub mod boot_watchdog; +pub mod diagnostics; pub mod offline_mode; pub mod graphics_autoconfig; pub mod graphics_detection; diff --git a/crates/qbz-app/src/settings/discover_prefs.rs b/crates/qbz-app/src/settings/discover_prefs.rs index 1229d27c0..31c72f75f 100644 --- a/crates/qbz-app/src/settings/discover_prefs.rs +++ b/crates/qbz-app/src/settings/discover_prefs.rs @@ -159,6 +159,10 @@ pub struct DiscoverPrefs { pub for_you: Vec, /// Opt-out: show the external "Recommendations" tab in Discover. Default on. pub show_recommendations: bool, + /// Recommendations results-cache window, in hours. One of {24,36,48,72}; + /// drives how long the built reco rows are served from cache before a + /// rebuild. Default 48. + pub reco_cache_ttl_hours: i64, } /// The exact `DEFAULT_PREFS` from `sectionPrefs.ts`. @@ -207,6 +211,7 @@ pub fn default_prefs() -> DiscoverPrefs { pref(ArtistSpotlight, true), ], show_recommendations: true, + reco_cache_ttl_hours: 48, } } @@ -299,6 +304,7 @@ impl DiscoverPrefs { "editorPicks": arr(&self.editor_picks), "forYou": arr(&self.for_you), "showRecommendations": self.show_recommendations, + "recoCacheTtlHours": self.reco_cache_ttl_hours, }) } @@ -319,6 +325,8 @@ impl DiscoverPrefs { for_you: defaults.for_you, // Legacy V1 (home-only array) predates the flag -> default on. show_recommendations: true, + // Legacy V1 predates the cache-window setting -> default 48h. + reco_cache_ttl_hours: 48, } } else if value.is_object() { let get = |key: &str| value.get(key).and_then(|v| v.as_array()); @@ -331,6 +339,12 @@ impl DiscoverPrefs { .get("showRecommendations") .and_then(|v| v.as_bool()) .unwrap_or(true), + // Validate against the offered set; unknown / missing -> 48h. + reco_cache_ttl_hours: value + .get("recoCacheTtlHours") + .and_then(|v| v.as_i64()) + .filter(|h| [24, 36, 48, 72].contains(h)) + .unwrap_or(48), } } else { defaults diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 31ff466de..e8449cf62 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -767,6 +767,32 @@ impl QbzCore { queue.current() } + /// Set the "stop after this song" marker on a queue track id. Replaces any + /// previous marker (single marker). Silent no-op if the id is not in the queue. + /// Intentionally does NOT emit `CoreEvent::QueueUpdated` — the marker is a UI + /// intent the frontend reflects via its own queue snapshot, and emitting here + /// risks QConnect echo loops. + pub async fn set_stop_after(&self, track_id: u64) { + self.queue.write().await.set_stop_after(track_id); + } + + /// Clear the "stop after" marker (user cancellation). + pub async fn clear_stop_after(&self) { + self.queue.write().await.clear_stop_after(); + } + + /// Read the current "stop after" marker, if any. + pub async fn get_stop_after(&self) -> Option { + self.queue.read().await.get_stop_after() + } + + /// One-shot consume: if `finished_track_id` matches the marker, clear it and + /// return true (the auto-advance driver then halts instead of advancing). + /// Only the natural end-of-track path may call this — never a manual skip. + pub async fn consume_stop_after_if(&self, finished_track_id: u64) -> bool { + self.queue.write().await.consume_stop_after_if(finished_track_id) + } + /// Reconcile the queue pointer to the track the audio engine is actually /// playing. A gapless hand-off advances inside the player without going /// through `next_track`, so the core pointer can lag the live track and diff --git a/crates/qbz-external-reco/src/cache.rs b/crates/qbz-external-reco/src/cache.rs index b44b75913..04dbf4302 100644 --- a/crates/qbz-external-reco/src/cache.rs +++ b/crates/qbz-external-reco/src/cache.rs @@ -14,10 +14,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; const FOUND_TTL_SECS: i64 = 30 * 24 * 60 * 60; /// TTL for negative (not-on-Qobuz) entries — 7 days. const MISS_TTL_SECS: i64 = 7 * 24 * 60 * 60; -/// TTL for the cached BUILT result rows — 48 hours (fast opens + rotation: the -/// tab paints instantly from cache within the window, and rebuilds every 48h so -/// the content is never "eternally the same"). -const RESULTS_TTL_SECS: i64 = 48 * 60 * 60; +/// Default TTL for the cached BUILT result rows — 48 hours (fast opens + +/// rotation: the tab paints instantly from cache within the window, and rebuilds +/// every 48h so the content is never "eternally the same"). The effective TTL is +/// caller-configurable (Recommendations cache-window setting); this is the +/// fallback when no preference is supplied. +pub const DEFAULT_RESULTS_TTL_SECS: i64 = 48 * 60 * 60; /// TTL for a RESOLVED weekly playlist, keyed by its ListenBrainz playlist mbid — /// 9 days (a week + slack). ListenBrainz regenerates Weekly Exploration / Weekly /// Jams every Monday with a NEW mbid, so a new week is a natural cache miss while @@ -131,8 +133,8 @@ impl RecoCache { } /// Get the cached BUILT result rows (JSON of `ExternalCarousels`) for `key`, - /// IF still within the 48h TTL. `None` -> the caller must rebuild. - pub fn get_results(&self, key: &str) -> Option { + /// IF still within `ttl_secs`. `None` -> the caller must rebuild. + pub fn get_results(&self, key: &str, ttl_secs: i64) -> Option { let row: Option<(String, i64)> = self .conn .query_row( @@ -143,7 +145,7 @@ impl RecoCache { .optional() .unwrap_or(None); match row { - Some((data, built_at)) if Self::now() - built_at <= RESULTS_TTL_SECS => Some(data), + Some((data, built_at)) if Self::now() - built_at <= ttl_secs => Some(data), _ => None, } } @@ -156,6 +158,14 @@ impl RecoCache { ); } + /// Drop the cached BUILT result rows for `key` (force-refresh). The next + /// build re-populates via `put_results`. + pub fn clear_results(&self, key: &str) { + let _ = self + .conn + .execute("DELETE FROM reco_results WHERE key = ?", params![key]); + } + /// Get a RESOLVED weekly playlist (JSON `Vec`) by its exact /// `"{source_patch}:{playlist_mbid}"` key, IF still within the 9d TTL. The /// mbid changes weekly, so a fresh week misses here and triggers a rebuild; diff --git a/crates/qbz-log/Cargo.toml b/crates/qbz-log/Cargo.toml new file mode 100644 index 000000000..435913f83 --- /dev/null +++ b/crates/qbz-log/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "qbz-log" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Frontend-agnostic logging core for qbz: TeeLogger (stderr+ring+file), write-time secret redaction, and a GitHub-ready diagnostics bundle." + +[dependencies] +log = { workspace = true } +regex = { workspace = true } +chrono = { workspace = true } +env_logger = "0.11" +dirs = "5" diff --git a/crates/qbz-log/src/bundle.rs b/crates/qbz-log/src/bundle.rs new file mode 100644 index 000000000..3ac985252 --- /dev/null +++ b/crates/qbz-log/src/bundle.rs @@ -0,0 +1,114 @@ +//! GitHub-ready diagnostics bundle formatter. +//! +//! Produces a collapsible `
` markdown block (a bold field header + a fenced +//! ```log block of the last N lines) suitable for pasting straight into a GitHub issue. +//! Lines are redacted defensively even though the ring already holds redacted text. + +use crate::line::LogLine; +use crate::redact; + +/// Borrowed diagnostic fields gathered by the caller (the `qbz` bin) for the header. +pub struct DiagFields<'a> { + pub app_version: &'a str, + pub os: &'a str, + pub arch: &'a str, + pub desktop: &'a str, + pub session: &'a str, + pub audio_backend: &'a str, + pub locale: &'a str, + pub log_level: &'a str, +} + +/// Format a GitHub-ready diagnostics bundle: a `
` wrapper, a bold field header, +/// and a fenced ```log block of the last `max_lines.min(last_lines.len())` lines. +pub fn format_diagnostics_bundle( + f: &DiagFields, + last_lines: &[LogLine], + max_lines: usize, +) -> String { + let n = max_lines.min(last_lines.len()); + let start = last_lines.len() - n; + let tail = &last_lines[start..]; + + let mut out = String::new(); + out.push_str("
qbz diagnostics\n\n"); + out.push_str(&format!("**App:** qbz v{}\n", f.app_version)); + out.push_str(&format!( + "**OS:** {} {} **Desktop:** {} ({})\n", + f.os, f.arch, f.desktop, f.session + )); + out.push_str(&format!("**Audio backend:** {}\n", f.audio_backend)); + out.push_str(&format!( + "**Locale:** {} **Log level:** {}\n", + f.locale, f.log_level + )); + out.push_str("\n```log\n"); + for line in tail { + out.push_str(&format!( + "{} {} {} {}\n", + line.format_ts(), + line.level_str(), + line.target, + // Defensive: the ring is already redacted, but never trust the input here. + redact::redact(&line.message) + )); + } + out.push_str("```\n"); + out.push_str("
\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Level; + + fn fields() -> DiagFields<'static> { + DiagFields { + app_version: "1.2.3", + os: "linux", + arch: "x86_64", + desktop: "KDE", + session: "wayland", + audio_backend: "pipewire", + locale: "en", + log_level: "info", + } + } + + #[test] + fn bundle_shape_and_redaction() { + let lines = vec![LogLine { + ts: 1_700_000_000_000, + level: Level::Info, + target: "qbz::net".into(), + message: "GET /track user_auth_token=SEKRET_TOKEN_123".into(), + }]; + let out = format_diagnostics_bundle(&fields(), &lines, 200); + + assert!(out.contains("qbz v"), "missing version header: {out}"); + assert!(out.contains("```log"), "missing fenced log block: {out}"); + assert!(out.contains("
"), "missing details wrapper: {out}"); + assert!( + !out.contains("SEKRET_TOKEN_123"), + "secret leaked into bundle: {out}" + ); + } + + #[test] + fn caps_to_max_lines() { + let lines: Vec = (0..10) + .map(|i| LogLine { + ts: i, + level: Level::Debug, + target: "t".into(), + message: format!("line {i}"), + }) + .collect(); + let out = format_diagnostics_bundle(&fields(), &lines, 3); + // Only the last 3 lines (7, 8, 9) should appear. + assert!(out.contains("line 9")); + assert!(out.contains("line 7")); + assert!(!out.contains("line 6")); + } +} diff --git a/crates/qbz-log/src/install.rs b/crates/qbz-log/src/install.rs new file mode 100644 index 000000000..897cde387 --- /dev/null +++ b/crates/qbz-log/src/install.rs @@ -0,0 +1,62 @@ +//! One-shot logger installation + the on-disk file sink (open / rotate). + +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use crate::tee::TeeLogger; + +static INSTALLED: AtomicBool = AtomicBool::new(false); + +/// Install the [`TeeLogger`] as the global `log` logger. +/// +/// Builds the inner `env_logger` logger from `RUST_LOG` (falling back to `default_level`), +/// opens/rotates the on-disk file, then sets the boxed logger + max level. Idempotent: +/// a second call is a guarded no-op (it neither rotates the file again nor panics). +pub fn install(default_level: &str) { + // True one-shot guard: avoid re-rotating the log file or fighting an already-set logger. + if INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + + let inner = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(default_level), + ) + .build(); + let level = inner.filter(); + let file = open_log_file(); + + // Ignore the Err if a logger was somehow already set elsewhere. + if log::set_boxed_logger(Box::new(TeeLogger { inner, file })).is_ok() { + log::set_max_level(level); + } +} + +/// Runtime log-level toggle (e.g. info <-> debug) with no restart. +pub fn set_level(level: log::LevelFilter) { + log::set_max_level(level); +} + +/// Path to the current-run log file (`~/.local/share/qbz/logs/qbz.log`), if a data dir exists. +pub fn log_file_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("logs").join("qbz.log")) +} + +/// Open the log file for this run, rotating any previous one to `qbz.log.prev`. +/// Returns `None` (file sink disabled, gracefully) on any filesystem error. +fn open_log_file() -> Option>> { + let path = log_file_path()?; + let dir = path.parent()?; + std::fs::create_dir_all(dir).ok()?; + + if path.exists() { + let prev = dir.join("qbz.log.prev"); + // Best-effort rotation; a failure here must not disable logging. + let _ = std::fs::rename(&path, &prev); + } + + let file = File::create(&path).ok()?; + Some(Mutex::new(BufWriter::new(file))) +} diff --git a/crates/qbz-log/src/lib.rs b/crates/qbz-log/src/lib.rs new file mode 100644 index 000000000..75561d512 --- /dev/null +++ b/crates/qbz-log/src/lib.rs @@ -0,0 +1,24 @@ +//! `qbz-log` — frontend-agnostic logging core for the qbz desktop client. +//! +//! It owns a composite [`log::Log`] implementation ([`tee::TeeLogger`]) that wraps +//! `env_logger`'s built `Logger` and fans every record out to three sinks: +//! 1. **stderr** (delegated to the inner `env_logger` logger, format unchanged), +//! 2. a bounded **in-memory ring** ([`ring`], cap [`ring::RING_CAP`]), and +//! 3. an **on-disk file** (`~/.local/share/qbz/logs/qbz.log`, prev-rotated at startup). +//! +//! Secret **redaction** ([`redact`]) is applied once at the single write choke point, +//! so every downstream consumer (ring, file, clipboard, paste upload) gets clean text. +//! +//! This crate is network-free and UI-free: no `reqwest`, no `tokio`, no `slint`. + +pub mod bundle; +pub mod install; +pub mod line; +pub mod redact; +pub mod ring; +pub mod tee; + +pub use bundle::{format_diagnostics_bundle, DiagFields}; +pub use install::{install, set_level}; +pub use line::LogLine; +pub use redact::{redact, register_secret}; diff --git a/crates/qbz-log/src/line.rs b/crates/qbz-log/src/line.rs new file mode 100644 index 000000000..80d716d25 --- /dev/null +++ b/crates/qbz-log/src/line.rs @@ -0,0 +1,41 @@ +//! The ring/file element: a single captured log record. + +use log::Level; + +/// One captured log line. `message` is **already redacted** by the time it lands +/// in the ring or the file (redaction happens at the single write choke point in +/// [`crate::tee::TeeLogger`]). +#[derive(Clone)] +pub struct LogLine { + /// Capture timestamp, epoch milliseconds (local-clock derived). + pub ts: i64, + /// Severity. + pub level: Level, + /// `log` target (usually the module path). + pub target: String, + /// Redacted message text. + pub message: String, +} + +impl LogLine { + /// Fixed-width uppercase level label (`ERROR`/`WARN`/`INFO`/`DEBUG`/`TRACE`). + pub fn level_str(&self) -> &'static str { + match self.level { + Level::Error => "ERROR", + Level::Warn => "WARN", + Level::Info => "INFO", + Level::Debug => "DEBUG", + Level::Trace => "TRACE", + } + } + + /// Human-readable local timestamp (`YYYY-MM-DD HH:MM:SS.mmm`) from the epoch ms. + /// Falls back to the raw epoch value if the timestamp is somehow out of range. + pub fn format_ts(&self) -> String { + use chrono::{Local, TimeZone}; + match Local.timestamp_millis_opt(self.ts).single() { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string(), + None => self.ts.to_string(), + } + } +} diff --git a/crates/qbz-log/src/redact.rs b/crates/qbz-log/src/redact.rs new file mode 100644 index 000000000..7dff22648 --- /dev/null +++ b/crates/qbz-log/src/redact.rs @@ -0,0 +1,144 @@ +//! Write-time secret redaction — the single most important safety layer in this crate. +//! +//! Two layers, applied in this order by [`redact`]: +//! 1. **Literal live-secret layer:** exact-string replacement of values registered via +//! [`register_secret`] right after login (the real `user_auth_token`, `app_secret`, …). +//! Catches tokens logged without a labeled key, which the regexes can't anticipate. +//! 2. **Regex layer:** a fixed set of labeled-key patterns (auth tokens, request_sig, +//! app_secret, password, bearer/authorization, access/refresh tokens, URL `token=`). +//! A cheap `.contains` pre-check short-circuits lines with no candidate substring. +//! +//! Every match collapses the secret VALUE to `***REDACTED***` while preserving the +//! labeled key prefix (capture group 1) so the line stays debuggable. + +use std::sync::{OnceLock, RwLock}; + +use regex::Regex; + +const REPLACEMENT: &str = "***REDACTED***"; +/// Live secret values shorter than this are ignored (too generic to scrub safely). +const MIN_SECRET_LEN: usize = 6; + +/// Compiled redaction patterns. Group 1 captures the labeled-key prefix that is kept; +/// the trailing value is what gets replaced. Patterns are case-insensitive. +fn patterns() -> &'static Vec { + static PATTERNS: OnceLock> = OnceLock::new(); + PATTERNS.get_or_init(|| { + [ + // Qobuz user auth token (labeled key, JSON/query/assignment forms) + r#"(?i)(user_auth_token["':=\s]+)[A-Za-z0-9._\-]+"#, + // …and the request header form + r#"(?i)(x-user-auth-token:\s*)\S+"#, + // request_sig MD5 hex (labeled key form, hex >= 8) + r#"(?i)(request_sig["':=\s]+)[a-f0-9]{8,}"#, + // request_sig as a bare URL query param + r#"(?i)(request_sig=)[a-f0-9]+"#, + // app secret (app_secret / appsecret) + r#"(?i)(app_?secret["':=\s]+)[A-Za-z0-9]+"#, + // password + r#"(?i)(password["':=\s]+)[^\s"',&]+"#, + // authorization: Bearer + r#"(?i)(authorization:\s*bearer\s+)\S+"#, + // bare bearer token + r#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, + // OAuth access/refresh tokens (labeled key form) + r#"(?i)((access|refresh)_token["':=\s]+)[A-Za-z0-9._\-]+"#, + // generic URL token param + r#"(?i)(token=)[^&\s"']+"#, + ] + .iter() + .filter_map(|p| Regex::new(p).ok()) + .collect() + }) +} + +fn secrets() -> &'static RwLock> { + static SECRETS: OnceLock>> = OnceLock::new(); + SECRETS.get_or_init(|| RwLock::new(Vec::new())) +} + +/// Register a live secret value so the literal layer scrubs it everywhere, even when +/// logged without a labeled key. Empty / very short values (< [`MIN_SECRET_LEN`]) are +/// ignored, and duplicates are skipped. +pub fn register_secret(value: String) { + if value.len() < MIN_SECRET_LEN { + return; + } + if let Ok(mut guard) = secrets().write() { + if !guard.iter().any(|s| s == &value) { + guard.push(value); + } + } +} + +/// Cheap pre-check: does the line contain any substring that one of the regexes could +/// match? Avoids running the whole pattern set on the overwhelming majority of lines. +fn has_redaction_candidate(lower: &str) -> bool { + const NEEDLES: [&str; 6] = ["token", "secret", "password", "bearer", "auth", "sig"]; + NEEDLES.iter().any(|n| lower.contains(n)) +} + +/// Redact secrets from a single log line. Literal live-secret layer first, then regex. +pub fn redact(line: &str) -> String { + let mut out = line.to_string(); + + // Layer 1 — literal live secrets. + if let Ok(guard) = secrets().read() { + for secret in guard.iter() { + if out.contains(secret.as_str()) { + out = out.replace(secret.as_str(), REPLACEMENT); + } + } + } + + // Layer 2 — labeled-key regexes (guarded by a cheap substring pre-check). + let lower = out.to_ascii_lowercase(); + if has_redaction_candidate(&lower) { + for re in patterns() { + if re.is_match(&out) { + out = re + .replace_all(&out, format!("${{1}}{REPLACEMENT}").as_str()) + .into_owned(); + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_all_known_shapes() { + for s in [ + "GET /track?request_sig=ab12cd34ef56 user_auth_token=SEKRET_TOKEN_123", + "X-User-Auth-Token: SEKRET_TOKEN_123", + r#"{"app_secret":"abc123def","password":"hunter2"}"#, + "authorization: Bearer eyJ.aaa.bbb", + ] { + let r = redact(s); + assert!(!r.contains("SEKRET_TOKEN_123"), "leaked auth token: {r}"); + assert!(!r.contains("ab12cd34ef56"), "leaked request_sig: {r}"); + assert!(!r.contains("abc123def"), "leaked app_secret: {r}"); + assert!(!r.contains("hunter2"), "leaked password: {r}"); + assert!(!r.contains("eyJ.aaa.bbb"), "leaked bearer token: {r}"); + } + } + + #[test] + fn literal_registry_scrubs_unlabeled_value() { + register_secret("LIVE_TOKEN_xyz".into()); + let r = redact("blah LIVE_TOKEN_xyz blah"); + assert!(!r.contains("LIVE_TOKEN_xyz"), "literal secret survived: {r}"); + assert!(r.contains(REPLACEMENT), "no redaction marker: {r}"); + } + + #[test] + fn short_secret_is_ignored() { + register_secret("abc".into()); // < MIN_SECRET_LEN -> not registered + let r = redact("value abc here"); + assert!(r.contains("abc"), "short value should not be scrubbed: {r}"); + } +} diff --git a/crates/qbz-log/src/ring.rs b/crates/qbz-log/src/ring.rs new file mode 100644 index 000000000..8e4ca1917 --- /dev/null +++ b/crates/qbz-log/src/ring.rs @@ -0,0 +1,75 @@ +//! Bounded in-memory ring buffer of the most recent [`LogLine`]s. +//! +//! FIFO with a hard cap of [`RING_CAP`]: at capacity the oldest line is dropped. +//! The critical section is kept tiny (push/pop only — never held across I/O) so the +//! audio/tokio threads that log never block meaningfully. Lock poisoning is tolerated +//! (`into_inner`) because a single corrupt line must never wedge logging globally. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::line::LogLine; + +/// Maximum number of lines retained in the ring. +pub const RING_CAP: usize = 5000; + +static RING: OnceLock>>> = OnceLock::new(); + +fn ring() -> &'static Arc>> { + RING.get_or_init(|| Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAP)))) +} + +/// Append a line, dropping the oldest if the ring is full. O(1). +pub fn push(line: LogLine) { + let r = ring(); + let mut guard = r.lock().unwrap_or_else(|p| p.into_inner()); + if guard.len() == RING_CAP { + guard.pop_front(); + } + guard.push_back(line); +} + +/// Clone the current contents (oldest first) for the viewer/bundle. Clones under lock. +pub fn snapshot() -> Vec { + let r = ring(); + let guard = r.lock().unwrap_or_else(|p| p.into_inner()); + guard.iter().cloned().collect() +} + +/// Empty the ring (the "Clear" action). Does not touch the on-disk file. +pub fn clear() { + let r = ring(); + let mut guard = r.lock().unwrap_or_else(|p| p.into_inner()); + guard.clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Level; + + fn mk(i: i64) -> LogLine { + LogLine { + ts: i, + level: Level::Info, + target: "t".into(), + message: format!("line {i}"), + } + } + + #[test] + fn ring_caps_and_is_fifo() { + clear(); + let total = RING_CAP as i64 + 10; + for i in 0..total { + push(mk(i)); + } + let snap = snapshot(); + // Capacity is enforced. + assert_eq!(snap.len(), RING_CAP); + // Oldest 10 (ts 0..10) were dropped, so the first retained line is ts 10. + assert_eq!(snap.first().unwrap().ts, 10); + // Newest line is last. + assert_eq!(snap.last().unwrap().ts, total - 1); + } +} diff --git a/crates/qbz-log/src/tee.rs b/crates/qbz-log/src/tee.rs new file mode 100644 index 000000000..d7069e8a4 --- /dev/null +++ b/crates/qbz-log/src/tee.rs @@ -0,0 +1,72 @@ +//! The composite [`log::Log`] that fans every record to stderr, the ring, and the file. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::sync::Mutex; + +use log::{Log, Metadata, Record}; + +use crate::line::LogLine; +use crate::{redact, ring}; + +/// Wraps `env_logger`'s built `Logger` and tees every record to the in-memory ring and +/// (optionally) the on-disk log file, with secret redaction applied once at this single +/// write choke point. stderr output is preserved verbatim by delegating to the inner +/// logger after the tee. +pub struct TeeLogger { + pub(crate) inner: env_logger::Logger, + pub(crate) file: Option>>, +} + +fn now_epoch_ms() -> i64 { + chrono::Local::now().timestamp_millis() +} + +impl Log for TeeLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + self.inner.enabled(metadata) + } + + fn log(&self, record: &Record) { + // Honor the inner logger's filter so the ring matches what stderr would show. + if !self.inner.enabled(record.metadata()) { + return; + } + + // Redact ONCE; every downstream sink gets the cleaned text. + let msg = redact::redact(&record.args().to_string()); + let line = LogLine { + ts: now_epoch_ms(), + level: record.level(), + target: record.target().to_owned(), + message: msg.clone(), + }; + + ring::push(line.clone()); + + if let Some(file) = &self.file { + if let Ok(mut writer) = file.lock() { + let _ = writeln!( + writer, + "{} {:5} {} {}", + line.format_ts(), + line.level_str(), + line.target, + msg + ); + } + } + + // Preserve the original env_logger stderr output verbatim. + self.inner.log(record); + } + + fn flush(&self) { + self.inner.flush(); + if let Some(file) = &self.file { + if let Ok(mut writer) = file.lock() { + let _ = writer.flush(); + } + } + } +} diff --git a/crates/qbz-text-utils/src/lib.rs b/crates/qbz-text-utils/src/lib.rs index b23903d2d..708d3eb46 100644 --- a/crates/qbz-text-utils/src/lib.rs +++ b/crates/qbz-text-utils/src/lib.rs @@ -1,3 +1,6 @@ //! Frontend-agnostic text/date utilities extracted from the qbz binary. pub mod dates; +pub mod sleep; pub mod strip_html; + +pub use sleep::format_sleep_remaining; diff --git a/crates/qbz-text-utils/src/sleep.rs b/crates/qbz-text-utils/src/sleep.rs new file mode 100644 index 000000000..f0154b540 --- /dev/null +++ b/crates/qbz-text-utils/src/sleep.rs @@ -0,0 +1,70 @@ +//! Sleep-timer countdown formatting (1:1 with the Tauri `formatSleepTimerRemaining`). + +/// Format remaining seconds like the Tauri sleep timer. +/// +/// - `h > 0` -> `"{h}h {m}m"` +/// - `m > 0` -> `"{m}m {s}s"` only when under 5 minutes and there are stray seconds, else `"{m}m"` +/// - else -> `"{s}s"` +/// - `<= 0` -> `"0s"` +/// +/// Seconds are shown only under 5 minutes, matching `sleepTimerStore.ts`. +pub fn format_sleep_remaining(secs: i64) -> String { + if secs <= 0 { + return "0s".to_string(); + } + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + if s > 0 && m < 5 { + format!("{m}m {s}s") + } else { + format!("{m}m") + } + } else { + format!("{s}s") + } +} + +#[cfg(test)] +mod tests { + use super::format_sleep_remaining as f; + + #[test] + fn under_a_minute() { + assert_eq!(f(45), "45s"); + assert_eq!(f(1), "1s"); + } + + #[test] + fn at_or_below_zero() { + assert_eq!(f(0), "0s"); + assert_eq!(f(-3), "0s"); + } + + #[test] + fn under_five_min_shows_secs() { + assert_eq!(f(125), "2m 5s"); + assert_eq!(f(61), "1m 1s"); + } + + #[test] + fn exact_minute_no_stray_secs() { + assert_eq!(f(120), "2m"); + } + + #[test] + fn five_min_or_more_minutes_only() { + assert_eq!(f(305), "5m"); + assert_eq!(f(300), "5m"); + assert_eq!(f(599), "9m"); + } + + #[test] + fn hours() { + assert_eq!(f(3725), "1h 2m"); + assert_eq!(f(3600), "1h 0m"); + } +} diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 664a0773c..3051aeac1 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4131,3 +4131,177 @@ msgstr[1] "{} Titel zur Warteschlange hinzugefügt" msgid "Unfollow on Qobuz" msgstr "Auf Qobuz nicht mehr folgen" + +msgid "Stops in" +msgstr "Stoppt in" + +msgid "Cancel Timer" +msgstr "Timer abbrechen" + +msgid "Stop playback after:" +msgstr "Wiedergabe stoppen nach:" + +msgid "30 min" +msgstr "30 Min." + +msgid "1 hr" +msgstr "1 Std." + +msgid "2 hr" +msgstr "2 Std." + +msgid "3 hr" +msgstr "3 Std." + +msgid "5 hr" +msgstr "5 Std." + +msgid "Custom…" +msgstr "Benutzerdefiniert…" + +msgid "min" +msgstr "Min." + +msgid "Set Timer" +msgstr "Timer setzen" + +msgid "Cancel stop after this" +msgstr "Stopp danach abbrechen" + +msgid "LOGS" +msgstr "PROTOKOLLE" + +msgid "Application logs" +msgstr "Anwendungsprotokolle" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Zeige das In-App-Protokoll an, kopiere es (Geheimnisse zensiert) oder lade es hoch, um es in einem GitHub-Issue zu teilen." + +msgid "Open log viewer" +msgstr "Protokoll-Viewer öffnen" + +msgid "Setting" +msgstr "Einstellung" + +msgid "Saved" +msgstr "Gespeichert" + +msgid "Runtime" +msgstr "Laufzeit" + +msgid "Diagnostics" +msgstr "Diagnose" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Vergleich von gespeicherter und aktiver Konfiguration." + +msgid "Exported" +msgstr "Exportiert" + +msgid "Export to clipboard" +msgstr "In die Zwischenablage exportieren" + +msgid "Cast Discovery" +msgstr "Cast-Suche" + +msgid "Scanning…" +msgstr "Suche läuft…" + +msgid "Scan for devices" +msgstr "Nach Geräten suchen" + +msgid "Graphics" +msgstr "Grafik" + +msgid "Environment" +msgstr "Umgebung" + +msgid "Logs" +msgstr "Protokolle" + +msgid "All levels" +msgstr "Alle Ebenen" + +msgid "Error" +msgstr "Fehler" + +msgid "Warn" +msgstr "Warnung" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Debug" + +msgid "Trace" +msgstr "Trace" + +msgid "Search logs…" +msgstr "Protokolle durchsuchen…" + +msgid "Auto-tail" +msgstr "Auto-Tail" + +msgid "Time" +msgstr "Zeit" + +msgid "Level" +msgstr "Ebene" + +msgid "Target" +msgstr "Modul" + +msgid "Message" +msgstr "Nachricht" + +msgid "No log entries." +msgstr "Keine Protokolleinträge." + +msgid "Copied" +msgstr "Kopiert" + +msgid "Copy all" +msgstr "Alles kopieren" + +msgid "Copy diagnostics bundle" +msgstr "Diagnosepaket kopieren" + +msgid "Uploading…" +msgstr "Wird hochgeladen…" + +msgid "Upload (public)" +msgstr "Hochladen (öffentlich)" + +msgid "Open log file" +msgstr "Protokolldatei öffnen" + +msgid "Report an issue" +msgstr "Problem melden" + +msgid "Go to logs" +msgstr "Zu den Protokollen" + +msgid "Create issue report" +msgstr "Problembericht erstellen" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Um die Behebung von Bugs und Problemen zu erleichtern, empfehlen wir, die Anwendungsprotokolle zu teilen. Wir haben uns sehr bemüht, die übermittelten Protokolle zu maskieren, damit keine sensiblen Daten enthalten sind. Der Vorgang ist manuell, niemals automatisch, damit du prüfen kannst, was geteilt wird." + +msgid "Cache window" +msgstr "Cache-Fenster" + +msgid "24 hours" +msgstr "24 Stunden" + +msgid "36 hours" +msgstr "36 Stunden" + +msgid "48 hours" +msgstr "48 Stunden" + +msgid "72 hours" +msgstr "72 Stunden" + +msgid "Refresh now" +msgstr "Jetzt aktualisieren" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 467e089f1..328276e03 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4114,3 +4114,177 @@ msgstr[1] "Se agregaron {} pistas a la cola" msgid "Unfollow on Qobuz" msgstr "Dejar de seguir en Qobuz" + +msgid "Stops in" +msgstr "Se detiene en" + +msgid "Cancel Timer" +msgstr "Cancelar temporizador" + +msgid "Stop playback after:" +msgstr "Detener la reproducción después de:" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personalizado…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Activar temporizador" + +msgid "Cancel stop after this" +msgstr "Cancelar detener después de esto" + +msgid "LOGS" +msgstr "REGISTROS" + +msgid "Application logs" +msgstr "Registros de la aplicación" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Consulta el registro de la app, cópialo (con los secretos ocultos) o súbelo para compartirlo en un issue de GitHub." + +msgid "Open log viewer" +msgstr "Abrir el visor de registros" + +msgid "Setting" +msgstr "Ajuste" + +msgid "Saved" +msgstr "Guardado" + +msgid "Runtime" +msgstr "En ejecución" + +msgid "Diagnostics" +msgstr "Diagnóstico" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparación entre la configuración guardada y la activa." + +msgid "Exported" +msgstr "Exportado" + +msgid "Export to clipboard" +msgstr "Exportar al portapapeles" + +msgid "Cast Discovery" +msgstr "Búsqueda de dispositivos Cast" + +msgid "Scanning…" +msgstr "Buscando…" + +msgid "Scan for devices" +msgstr "Buscar dispositivos" + +msgid "Graphics" +msgstr "Gráficos" + +msgid "Environment" +msgstr "Entorno" + +msgid "Logs" +msgstr "Registros" + +msgid "All levels" +msgstr "Todos los niveles" + +msgid "Error" +msgstr "Error" + +msgid "Warn" +msgstr "Aviso" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Depuración" + +msgid "Trace" +msgstr "Rastreo" + +msgid "Search logs…" +msgstr "Buscar en los registros…" + +msgid "Auto-tail" +msgstr "Auto-seguir" + +msgid "Time" +msgstr "Hora" + +msgid "Level" +msgstr "Nivel" + +msgid "Target" +msgstr "Módulo" + +msgid "Message" +msgstr "Mensaje" + +msgid "No log entries." +msgstr "No hay registros." + +msgid "Copied" +msgstr "Copiado" + +msgid "Copy all" +msgstr "Copiar todo" + +msgid "Copy diagnostics bundle" +msgstr "Copiar el paquete de diagnóstico" + +msgid "Uploading…" +msgstr "Subiendo…" + +msgid "Upload (public)" +msgstr "Subir (público)" + +msgid "Open log file" +msgstr "Abrir el archivo de registro" + +msgid "Report an issue" +msgstr "Reportar un problema" + +msgid "Go to logs" +msgstr "Ir a los logs" + +msgid "Create issue report" +msgstr "Crear reporte" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Para hacer más sencilla la resolución de bugs y problemas, es recomendable compartir los logs de la aplicación. Nos hemos encargado de ser muy cuidadosos y enmascarar los logs que se envían para no enviar ningún dato sensible; el proceso es manual, nada automático, para que tú puedas verificar lo que se comparte." + +msgid "Cache window" +msgstr "Ventana de cache" + +msgid "24 hours" +msgstr "24 horas" + +msgid "36 hours" +msgstr "36 horas" + +msgid "48 hours" +msgstr "48 horas" + +msgid "72 hours" +msgstr "72 horas" + +msgid "Refresh now" +msgstr "Actualizar ahora" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index c40f2ce6e..c870a5637 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4130,3 +4130,177 @@ msgstr[1] "{} titres ajoutés à la file d'attente" msgid "Unfollow on Qobuz" msgstr "Ne plus suivre sur Qobuz" + +msgid "Stops in" +msgstr "S'arrête dans" + +msgid "Cancel Timer" +msgstr "Annuler la minuterie" + +msgid "Stop playback after:" +msgstr "Arrêter la lecture après :" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personnalisé…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Définir la minuterie" + +msgid "Cancel stop after this" +msgstr "Annuler l'arrêt après ce titre" + +msgid "LOGS" +msgstr "JOURNAUX" + +msgid "Application logs" +msgstr "Journaux de l'application" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Consultez le journal de l'application, copiez-le (secrets masqués) ou téléversez-le pour le partager dans une issue GitHub." + +msgid "Open log viewer" +msgstr "Ouvrir le visualiseur de journaux" + +msgid "Setting" +msgstr "Paramètre" + +msgid "Saved" +msgstr "Enregistré" + +msgid "Runtime" +msgstr "Exécution" + +msgid "Diagnostics" +msgstr "Diagnostic" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparaison entre la configuration enregistrée et active." + +msgid "Exported" +msgstr "Exporté" + +msgid "Export to clipboard" +msgstr "Exporter vers le presse-papiers" + +msgid "Cast Discovery" +msgstr "Découverte Cast" + +msgid "Scanning…" +msgstr "Recherche…" + +msgid "Scan for devices" +msgstr "Rechercher des appareils" + +msgid "Graphics" +msgstr "Graphismes" + +msgid "Environment" +msgstr "Environnement" + +msgid "Logs" +msgstr "Journaux" + +msgid "All levels" +msgstr "Tous les niveaux" + +msgid "Error" +msgstr "Erreur" + +msgid "Warn" +msgstr "Avert." + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Débogage" + +msgid "Trace" +msgstr "Trace" + +msgid "Search logs…" +msgstr "Rechercher dans les journaux…" + +msgid "Auto-tail" +msgstr "Suivi auto" + +msgid "Time" +msgstr "Heure" + +msgid "Level" +msgstr "Niveau" + +msgid "Target" +msgstr "Module" + +msgid "Message" +msgstr "Message" + +msgid "No log entries." +msgstr "Aucune entrée de journal." + +msgid "Copied" +msgstr "Copié" + +msgid "Copy all" +msgstr "Tout copier" + +msgid "Copy diagnostics bundle" +msgstr "Copier le rapport de diagnostic" + +msgid "Uploading…" +msgstr "Envoi…" + +msgid "Upload (public)" +msgstr "Téléverser (public)" + +msgid "Open log file" +msgstr "Ouvrir le fichier journal" + +msgid "Report an issue" +msgstr "Signaler un problème" + +msgid "Go to logs" +msgstr "Aller aux journaux" + +msgid "Create issue report" +msgstr "Créer un rapport" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Pour faciliter la résolution des bugs et des problèmes, il est recommandé de partager les journaux de l'application. Nous avons pris soin de masquer les journaux envoyés afin qu'aucune donnée sensible ne soit incluse. Le processus est manuel, jamais automatique, pour que vous puissiez vérifier ce qui est partagé." + +msgid "Cache window" +msgstr "Fenêtre de cache" + +msgid "24 hours" +msgstr "24 heures" + +msgid "36 hours" +msgstr "36 heures" + +msgid "48 hours" +msgstr "48 heures" + +msgid "72 hours" +msgstr "72 heures" + +msgid "Refresh now" +msgstr "Actualiser maintenant" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index e215db288..09c727560 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4103,3 +4103,177 @@ msgstr[1] "{} faixas adicionadas à fila" msgid "Unfollow on Qobuz" msgstr "Deixar de seguir no Qobuz" + +msgid "Stops in" +msgstr "Para em" + +msgid "Cancel Timer" +msgstr "Cancelar temporizador" + +msgid "Stop playback after:" +msgstr "Parar a reprodução após:" + +msgid "30 min" +msgstr "30 min" + +msgid "1 hr" +msgstr "1 h" + +msgid "2 hr" +msgstr "2 h" + +msgid "3 hr" +msgstr "3 h" + +msgid "5 hr" +msgstr "5 h" + +msgid "Custom…" +msgstr "Personalizado…" + +msgid "min" +msgstr "min" + +msgid "Set Timer" +msgstr "Definir temporizador" + +msgid "Cancel stop after this" +msgstr "Cancelar parar depois desta" + +msgid "LOGS" +msgstr "REGISTOS" + +msgid "Application logs" +msgstr "Registos da aplicação" + +msgid "View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue." +msgstr "Veja o registo da aplicação, copie-o (segredos ocultados) ou carregue-o para partilhar num issue do GitHub." + +msgid "Open log viewer" +msgstr "Abrir o visualizador de registos" + +msgid "Setting" +msgstr "Definição" + +msgid "Saved" +msgstr "Guardado" + +msgid "Runtime" +msgstr "Em execução" + +msgid "Diagnostics" +msgstr "Diagnóstico" + +msgid "Runtime vs saved configuration snapshot." +msgstr "Comparação entre a configuração guardada e a ativa." + +msgid "Exported" +msgstr "Exportado" + +msgid "Export to clipboard" +msgstr "Exportar para a área de transferência" + +msgid "Cast Discovery" +msgstr "Deteção de Cast" + +msgid "Scanning…" +msgstr "A procurar…" + +msgid "Scan for devices" +msgstr "Procurar dispositivos" + +msgid "Graphics" +msgstr "Gráficos" + +msgid "Environment" +msgstr "Ambiente" + +msgid "Logs" +msgstr "Registos" + +msgid "All levels" +msgstr "Todos os níveis" + +msgid "Error" +msgstr "Erro" + +msgid "Warn" +msgstr "Aviso" + +msgid "Info" +msgstr "Info" + +msgid "Debug" +msgstr "Depuração" + +msgid "Trace" +msgstr "Rastreio" + +msgid "Search logs…" +msgstr "Procurar nos registos…" + +msgid "Auto-tail" +msgstr "Seguir automático" + +msgid "Time" +msgstr "Hora" + +msgid "Level" +msgstr "Nível" + +msgid "Target" +msgstr "Módulo" + +msgid "Message" +msgstr "Mensagem" + +msgid "No log entries." +msgstr "Sem registos." + +msgid "Copied" +msgstr "Copiado" + +msgid "Copy all" +msgstr "Copiar tudo" + +msgid "Copy diagnostics bundle" +msgstr "Copiar o pacote de diagnóstico" + +msgid "Uploading…" +msgstr "A enviar…" + +msgid "Upload (public)" +msgstr "Carregar (público)" + +msgid "Open log file" +msgstr "Abrir o ficheiro de registo" + +msgid "Report an issue" +msgstr "Reportar um problema" + +msgid "Go to logs" +msgstr "Ir para os logs" + +msgid "Create issue report" +msgstr "Criar relato" + +msgid "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared." +msgstr "Para facilitar a resolução de bugs e problemas, é recomendado compartilhar os logs da aplicação. Tivemos o cuidado de mascarar os logs enviados para que nenhum dado sensível seja incluído. O processo é manual, nunca automático, para que você possa verificar o que é compartilhado." + +msgid "Cache window" +msgstr "Janela de cache" + +msgid "24 hours" +msgstr "24 horas" + +msgid "36 hours" +msgstr "36 horas" + +msgid "48 hours" +msgstr "48 horas" + +msgid "72 hours" +msgstr "72 horas" + +msgid "Refresh now" +msgstr "Atualizar agora" diff --git a/crates/qbz-ui/ui/app.slint b/crates/qbz-ui/ui/app.slint index 6bf55d920..aa6bff8ac 100644 --- a/crates/qbz-ui/ui/app.slint +++ b/crates/qbz-ui/ui/app.slint @@ -26,7 +26,7 @@ export { Theme, ThemeColors } from "foundation/semantic-colors.slint"; export { ThemeState } from "foundation/theme-state.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState } from "state.slint"; +export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-ui/ui/discover/ExternalRecoView.slint b/crates/qbz-ui/ui/discover/ExternalRecoView.slint index c5961f90e..4524ed01f 100644 --- a/crates/qbz-ui/ui/discover/ExternalRecoView.slint +++ b/crates/qbz-ui/ui/discover/ExternalRecoView.slint @@ -11,11 +11,13 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; -import { ExternalRecoState } from "../state.slint"; +import { ExternalRecoState, ExternalRecoActions } from "../state.slint"; import { Carousel } from "Carousel.slint"; import { SlimCarousel } from "SlimCarousel.slint"; import { ArtistCarousel } from "ArtistCarousel.slint"; import { HomeSkeleton, SkeletonCarouselRow, SkeletonSlimRow } from "HomeSkeleton.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; export component ExternalRecoView inherits VerticalLayout { callback open-album(string); @@ -50,6 +52,34 @@ export component ExternalRecoView inherits VerticalLayout { spacing: 40px; alignment: start; + // Cache-window controls — pick how long the built recommendations are served + // from cache before a rebuild, and force an immediate rebuild ("Refresh now"). + HorizontalLayout { + alignment: end; + spacing: 8px; + Text { + text: @tr("Cache window"); + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + } + QbzSelect { + sm: true; + menu-width: 140px; + options: [@tr("24 hours"), @tr("36 hours"), @tr("48 hours"), @tr("72 hours")]; + current-index: ExternalRecoState.cache-ttl-index; + selected(i) => { + ExternalRecoActions.set-cache-ttl(i); + } + } + SecondaryButton { + label: @tr("Refresh now"); + clicked => { + ExternalRecoActions.refresh-now(); + } + } + } + // Initial full-page skeleton — only until the per-row pending flags are set // (or content arrives), after which the individual row skeletons take over. if ExternalRecoState.loading && !root.any-content && !root.any-pending: HomeSkeleton { } diff --git a/crates/qbz-ui/ui/settings/AppearanceSettings.slint b/crates/qbz-ui/ui/settings/AppearanceSettings.slint index c2ddee514..2437f1c11 100644 --- a/crates/qbz-ui/ui/settings/AppearanceSettings.slint +++ b/crates/qbz-ui/ui/settings/AppearanceSettings.slint @@ -591,6 +591,7 @@ export component AppearanceSettings inherits VerticalLayout { checked: AppearanceState.show-volume-steppers; toggled(v) => { AppearanceState.show-volume-steppers = v; + AppearanceState.appearance-bool("show-volume-steppers", v); } } } diff --git a/crates/qbz-ui/ui/settings/DeveloperSettings.slint b/crates/qbz-ui/ui/settings/DeveloperSettings.slint index 8e82365bd..1892433e0 100644 --- a/crates/qbz-ui/ui/settings/DeveloperSettings.slint +++ b/crates/qbz-ui/ui/settings/DeveloperSettings.slint @@ -6,8 +6,9 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { QconnectDevState } from "../state.slint"; +import { QconnectDevState, LogViewerState } from "../state.slint"; import { SettingRow } from "SettingRow.slint"; +import { DiagnosticsPanel } from "DiagnosticsPanel.slint"; component GroupHeader inherits Text { color: Theme.text-muted; @@ -58,4 +59,19 @@ export component DeveloperSettings inherits VerticalLayout { } } } + + GroupHeader { text: @tr("LOGS"); } + + SettingRow { + label: @tr("Application logs"); + description: @tr("View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue."); + SecondaryButton { + label: @tr("Open log viewer"); + clicked => { + LogViewerState.open = true; + } + } + } + + DiagnosticsPanel { } } diff --git a/crates/qbz-ui/ui/settings/DiagnosticsPanel.slint b/crates/qbz-ui/ui/settings/DiagnosticsPanel.slint new file mode 100644 index 000000000..8c8de9f00 --- /dev/null +++ b/crates/qbz-ui/ui/settings/DiagnosticsPanel.slint @@ -0,0 +1,390 @@ +// Settings > Developer — inline Diagnostics panel (1:1 port of the Tauri +// DiagnosticsPanel.svelte). A master collapsible holding seven sub-sections +// (System / Playback / Qobuz Connect / Cast / Audio / Graphics / Environment), +// each a small saved-vs-runtime table. Five sections are 3-column +// (Setting | Runtime | glyph); Audio and Graphics are 4-column +// (Setting | Saved | Runtime | glyph) — gated by the per-section `show-saved`. +// +// The data model is the DiagnosticsState global + DiagRow struct (state.slint): +// seven `[DiagRow]` models (one per section, 1:1 with the Tauri row-builders), +// plus refresh()/export-clipboard()/cast-scan() callbacks driven from Rust. +// Section open/closed state is pure UI (private `open` per DiagSection); no +// Rust round-trip. Per-row labels are plain Rust strings (Tauri hardcodes them +// too); only section titles + column headers + buttons go through @tr. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { DiagRow, DiagnosticsState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; + +// One table row. 4-col when `show-saved`, else 3-col (Saved column omitted). +// The trailing glyph maps `status`: 0 info(·) / 1 match(✓) / 2 mismatch(✗). +component DiagRowView inherits Rectangle { + in property entry; + in property show-saved; + height: 28px; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 10px; + Text { + text: root.entry.label; + color: Theme.text-secondary; + font-size: 12px; + vertical-alignment: center; + width: 38%; + overflow: elide; + } + if root.show-saved: Text { + text: root.entry.saved; + color: Theme.text-primary; + font-size: 11px; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Text { + text: root.entry.runtime; + color: Theme.text-primary; + font-size: 11px; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Text { + width: 20px; + horizontal-alignment: center; + vertical-alignment: center; + font-weight: Typography.bold; + text: root.entry.status == 1 ? "✓" : root.entry.status == 2 ? "✗" : "·"; + color: root.entry.status == 1 ? #4caf50 : root.entry.status == 2 ? #f44336 : Theme.text-muted; + } + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.border-subtle; + } +} + +// Collapsible section: clickable header (chevron + title) toggles a private +// `open` (seeded from `default-open`); when open, a column header + the rows. +component DiagSection inherits VerticalLayout { + in property title; + in property <[DiagRow]> rows; + in property show-saved: false; + in property default-open: true; + private property open: root.default-open; + spacing: 0px; + + Rectangle { + height: 34px; + TouchArea { + mouse-cursor: pointer; + clicked => { + root.open = !root.open; + } + } + HorizontalLayout { + spacing: 6px; + VerticalLayout { + alignment: center; + QbzIcon { + width: 14px; + height: 14px; + tint: Theme.text-secondary; + source: root.open + ? @image-url("../assets/icons/chevron-down.svg") + : @image-url("../assets/icons/chevron-right.svg"); + } + } + Text { + text: root.title; + color: Theme.text-primary; + font-size: 13px; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + } + + if root.open: VerticalLayout { + spacing: 0px; + Rectangle { + height: 26px; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 10px; + Text { + text: @tr("Setting"); + width: 38%; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + if root.show-saved: Text { + text: @tr("Saved"); + horizontal-stretch: 1; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + Text { + text: @tr("Runtime"); + horizontal-stretch: 1; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + Rectangle { + width: 20px; + } + } + } + for r in root.rows: DiagRowView { + entry: r; + show-saved: root.show-saved; + } + } +} + +// Action button — auto-width, 34px high, styled like DeveloperSettings' +// SecondaryButton. `enabled: false` dims it and swallows the click. +component DiagButton inherits Rectangle { + in property label; + in property enabled: true; + callback clicked(); + height: 34px; + width: btn-label.preferred-width + 28px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: root.enabled && ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + HorizontalLayout { + alignment: center; + padding-left: 14px; + padding-right: 14px; + btn-label := Text { + text: root.label; + color: root.enabled ? Theme.text-secondary : Theme.text-muted; + font-size: Typography.body; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + +export component DiagnosticsPanel inherits VerticalLayout { + spacing: 4px; + private property panel-open: false; + private property cast-open: true; + + // Master toggle (chevron + title + muted note). First expand triggers the + // initial load if it hasn't happened yet. + Rectangle { + height: 44px; + TouchArea { + mouse-cursor: pointer; + clicked => { + root.panel-open = !root.panel-open; + if (root.panel-open && !DiagnosticsState.loaded) { + DiagnosticsState.refresh(); + } + } + } + HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + width: 16px; + height: 16px; + tint: Theme.text-secondary; + source: root.panel-open + ? @image-url("../assets/icons/chevron-down.svg") + : @image-url("../assets/icons/chevron-right.svg"); + } + } + Text { + text: @tr("Diagnostics"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.semibold; + vertical-alignment: center; + } + Text { + text: @tr("Runtime vs saved configuration snapshot."); + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + } + + if root.panel-open: VerticalLayout { + spacing: 4px; + + // Actions: Refresh + Export-to-clipboard. + HorizontalLayout { + spacing: 8px; + alignment: start; + DiagButton { + label: @tr("Refresh"); + clicked => { + DiagnosticsState.refresh(); + } + } + DiagButton { + label: DiagnosticsState.copied ? @tr("Exported") : @tr("Export to clipboard"); + enabled: DiagnosticsState.loaded; + clicked => { + DiagnosticsState.export-clipboard(); + } + } + } + + if DiagnosticsState.error != "": Text { + text: DiagnosticsState.error; + color: #f44336; + font-size: 12px; + wrap: word-wrap; + } + + if DiagnosticsState.loaded: VerticalLayout { + spacing: 4px; + Text { + text: "QBZ v" + DiagnosticsState.app-version; + color: Theme.text-muted; + font-size: 11px; + } + + DiagSection { + title: @tr("System"); + rows: DiagnosticsState.system-rows; + default-open: true; + } + DiagSection { + title: @tr("Playback"); + rows: DiagnosticsState.playback-rows; + default-open: true; + } + DiagSection { + title: @tr("Qobuz Connect"); + rows: DiagnosticsState.qconnect-rows; + default-open: true; + } + + // Cast — bespoke collapsible: needs its own on-demand Scan button. + VerticalLayout { + spacing: 0px; + Rectangle { + height: 34px; + TouchArea { + mouse-cursor: pointer; + clicked => { + root.cast-open = !root.cast-open; + } + } + HorizontalLayout { + spacing: 6px; + VerticalLayout { + alignment: center; + QbzIcon { + width: 14px; + height: 14px; + tint: Theme.text-secondary; + source: root.cast-open + ? @image-url("../assets/icons/chevron-down.svg") + : @image-url("../assets/icons/chevron-right.svg"); + } + } + Text { + text: @tr("Cast Discovery"); + color: Theme.text-primary; + font-size: 13px; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + } + if root.cast-open: VerticalLayout { + spacing: 4px; + HorizontalLayout { + alignment: start; + DiagButton { + label: DiagnosticsState.cast-scanning ? @tr("Scanning…") : @tr("Scan for devices"); + enabled: !DiagnosticsState.cast-scanning; + clicked => { + DiagnosticsState.cast-scan(); + } + } + } + if DiagnosticsState.cast-rows.length > 0: VerticalLayout { + spacing: 0px; + Rectangle { + height: 26px; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 10px; + Text { + text: @tr("Setting"); + width: 38%; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + Text { + text: @tr("Runtime"); + horizontal-stretch: 1; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + Rectangle { + width: 20px; + } + } + } + for r in DiagnosticsState.cast-rows: DiagRowView { + entry: r; + show-saved: false; + } + } + } + } + + DiagSection { + title: @tr("Audio"); + rows: DiagnosticsState.audio-rows; + show-saved: true; + default-open: true; + } + DiagSection { + title: @tr("Graphics"); + rows: DiagnosticsState.graphics-rows; + show-saved: true; + default-open: true; + } + DiagSection { + title: @tr("Environment"); + rows: DiagnosticsState.env-rows; + default-open: false; + } + } + } +} diff --git a/crates/qbz-ui/ui/shell/AppShell.slint b/crates/qbz-ui/ui/shell/AppShell.slint index d913a01d9..5b6c5d4ff 100644 --- a/crates/qbz-ui/ui/shell/AppShell.slint +++ b/crates/qbz-ui/ui/shell/AppShell.slint @@ -38,6 +38,8 @@ import { AlbumCreditsModal } from "../album/AlbumCreditsModal.slint"; import { AlbumBookletModal } from "../album/AlbumBookletModal.slint"; import { ImmersiveView } from "../immersive/ImmersiveView.slint"; import { QconnectDevModal } from "QconnectDevModal.slint"; +import { LogViewerModal } from "LogViewerModal.slint"; +import { ReportIssueModal } from "ReportIssueModal.slint"; import { CastPicker } from "CastPicker.slint"; import { DiscoverConfigModal } from "../discover/DiscoverConfigModal.slint"; import { KeyboardShortcutsModal } from "KeyboardShortcutsModal.slint"; @@ -668,6 +670,20 @@ export component AppShell inherits Rectangle { height: 100%; } + // In-app log viewer (Developer settings). Declaration-order z keeps it above + // the page; self-gated on LogViewerState.open (ADR-009/010). + LogViewerModal { + width: 100%; + height: 100%; + } + + // Report-an-issue modal (header hamburger). Mounted after the log viewer so + // "Go to logs" stacks the viewer on top. Self-gated on ReportIssueState.open. + ReportIssueModal { + width: 100%; + height: 100%; + } + // Discover section configurator (Slice 5). Mounted after the other modals // so its declaration order keeps it on top of them (ADR-009). DiscoverConfigModal { diff --git a/crates/qbz-ui/ui/shell/HeaderBar.slint b/crates/qbz-ui/ui/shell/HeaderBar.slint index 53af89244..a204120c0 100644 --- a/crates/qbz-ui/ui/shell/HeaderBar.slint +++ b/crates/qbz-ui/ui/shell/HeaderBar.slint @@ -14,7 +14,7 @@ import { Spacing } from "../foundation/spacing.slint"; import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; -import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions, SearchState, SidebarPlaylistsPopupState, OfflineState, SettingsState, OfflineModeActions, AppearanceState, KeyboardShortcutsState, LinkResolverState, UiFocusState } from "../state.slint"; +import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions, SearchState, SidebarPlaylistsPopupState, OfflineState, SettingsState, OfflineModeActions, AppearanceState, KeyboardShortcutsState, LinkResolverState, UiFocusState, ReportIssueState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { SecondaryButton } from "../primitives/SecondaryButton.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; @@ -1101,6 +1101,10 @@ export component HeaderBar inherits Rectangle { MenuItem { icon: @image-url("../assets/icons/bug.svg"); label: @tr("Report an Issue"); + clicked => { + menu.close(); + ReportIssueState.open = true; + } } MenuItem { icon: @image-url("../assets/icons/info.svg"); diff --git a/crates/qbz-ui/ui/shell/LogViewerModal.slint b/crates/qbz-ui/ui/shell/LogViewerModal.slint new file mode 100644 index 000000000..918178324 --- /dev/null +++ b/crates/qbz-ui/ui/shell/LogViewerModal.slint @@ -0,0 +1,510 @@ +// In-app log viewer — the native replacement for the Tauri web console. +// +// Opened from Settings > Developer (or a hotkey). The Rust side owns a rolling +// ring of log records; it pushes an already-filtered + capped slice into +// LogViewerState.rows (with .total = ring size, .shown = after filter+cap) so +// this modal never holds the full history. All filtering/searching is delegated +// back to Rust via set-level / set-search — the level QbzSelect and the search +// field only emit; they never mutate the row model here. +// +// Layout mirrors QconnectDevModal: a dimmed full-window backdrop that closes on +// click, a centered card that swallows clicks, a header (title + shown/total + +// X), a controls row (level filter / search / refresh / auto-tail), a scrolling +// per-level-colored ListView body, and a footer button row (copy / bundle / +// upload / open file / clear). Mounted in AppShell in declaration order so its +// z-order is the mount order (ADR-009); conditional-mounted on .open (ADR-010). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { LogViewerState, UiFocusState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; +import { QbzToggle } from "../primitives/QbzToggle.slint"; +import { ListView } from "std-widgets.slint"; + +// Small bordered icon+label control used by the controls row (Refresh) and the +// whole footer (Copy / Bundle / Upload / Open file / Clear). Auto-sizes to its +// label; `danger` recolors it for the destructive Clear action. +component IconTextButton inherits Rectangle { + in property label; + in property icon; + in property has-icon: true; + in property danger: false; + in property enabled: true; + callback clicked(); + + height: 32px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated; + opacity: root.enabled ? 1.0 : 0.4; + + HorizontalLayout { + alignment: center; + padding-left: 12px; + padding-right: 12px; + spacing: 7px; + if root.has-icon: VerticalLayout { + alignment: center; + QbzIcon { + source: root.icon; + width: 14px; + height: 14px; + tint: root.danger ? Theme.danger : Theme.text-secondary; + } + } + Text { + text: root.label; + color: root.danger ? Theme.danger : Theme.text-secondary; + font-size: 13px; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + + ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + +export component LogViewerModal inherits Rectangle { + width: 100%; + height: 100%; + visible: LogViewerState.open; + // Advanced controls (level/search/refresh/auto-tail + bundle/upload/open/clear) + // hide behind a discreet [+] so the default view is just the log + Copy all. + private property advanced-open: false; + + if LogViewerState.open: Rectangle { + background: #000000bf; + // Snapshot the ring the MOMENT the viewer opens, so it always shows the + // current logs immediately — not an empty pane until Refresh/auto-tail. + init => { + LogViewerState.refresh(); + } + TouchArea { + mouse-cursor: default; + clicked => { + LogViewerState.open = false; + } + } + + Rectangle { + width: Math.min(root.width - 80px, 760px); + height: Math.min(root.height - 80px, 640px); + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks so they don't reach the closing backdrop. + TouchArea { } + + VerticalLayout { + padding: 20px; + spacing: 14px; + + // ---- Header: title + shown/total count + close X. + HorizontalLayout { + spacing: 10px; + Text { + text: @tr("Logs"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + } + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: "\{LogViewerState.shown} / \{LogViewerState.total}"; + color: Theme.text-muted; + font-size: Typography.legal; + vertical-alignment: center; + } + } + // Discreet [+] / [−] — reveals the advanced controls + actions. + adv-toggle := TouchArea { + width: 24px; + height: 24px; + mouse-cursor: pointer; + clicked => { + root.advanced-open = !root.advanced-open; + } + Text { + width: 100%; + height: 100%; + text: root.advanced-open ? "−" : "+"; + color: adv-toggle.has-hover ? Theme.text-primary : Theme.text-muted; + font-size: 18px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + LogViewerState.open = false; + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + + // ---- Controls (advanced, behind [+]): level / search / refresh / auto-tail. + if root.advanced-open: HorizontalLayout { + height: 34px; + spacing: 10px; + + // Level filter — index<->token mapping kept here so Rust only + // ever receives the canonical "all|error|warn|info|debug|trace". + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 132px; + options: [ + @tr("All levels"), + @tr("Error"), + @tr("Warn"), + @tr("Info"), + @tr("Debug"), + @tr("Trace"), + ]; + current-index: LogViewerState.filter-level == "error" ? 1 + : LogViewerState.filter-level == "warn" ? 2 + : LogViewerState.filter-level == "info" ? 3 + : LogViewerState.filter-level == "debug" ? 4 + : LogViewerState.filter-level == "trace" ? 5 + : 0; + selected(i) => { + LogViewerState.set-level( + i == 1 ? "error" + : i == 2 ? "warn" + : i == 3 ? "info" + : i == 4 ? "debug" + : i == 5 ? "trace" + : "all"); + } + } + } + + // Search box — emits set-search on every edit; Rust filters. + search-box := Rectangle { + horizontal-stretch: 1; + height: 34px; + border-radius: Radius.sm; + border-width: 1px; + border-color: search-input.has-focus ? Theme.focus-ring : Theme.border-subtle; + background: Theme.surface-elevated; + HorizontalLayout { + padding-left: 11px; + padding-right: 11px; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + Rectangle { + horizontal-stretch: 1; + clip: true; + search-input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.body; + vertical-alignment: center; + single-line: true; + text <=> LogViewerState.search; + changed has-focus => { + UiFocusState.text-input-focused = self.has-focus; + } + edited => { + LogViewerState.set-search(self.text); + } + } + Text { + visible: search-input.text == ""; + width: 100%; + height: 100%; + text: @tr("Search logs…"); + color: Theme.text-muted; + font-size: Typography.body; + vertical-alignment: center; + overflow: elide; + } + } + } + } + + VerticalLayout { + alignment: center; + IconTextButton { + label: @tr("Refresh"); + icon: @image-url("../assets/icons/refresh-cw.svg"); + clicked => { + LogViewerState.refresh(); + } + } + } + + // Auto-tail toggle (label + switch). + VerticalLayout { + alignment: center; + HorizontalLayout { + spacing: 8px; + Text { + text: @tr("Auto-tail"); + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + } + VerticalLayout { + alignment: center; + QbzToggle { + checked: LogViewerState.auto-tail; + toggled(v) => { + LogViewerState.toggle-auto-tail(v); + } + } + } + } + } + } + + // ---- Column header (compact, muted). + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 10px; + Text { + text: @tr("Time"); + width: 80px; + color: Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.semibold; + } + Text { + text: @tr("Level"); + width: 48px; + color: Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.semibold; + } + Text { + text: @tr("Target"); + width: 140px; + color: Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.semibold; + } + Text { + text: @tr("Message"); + horizontal-stretch: 1; + color: Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.semibold; + } + } + + // ---- Body: per-level-colored, scrolling log rows. + Rectangle { + vertical-stretch: 1; + background: Theme.surface-main; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + clip: true; + + ListView { + width: 100%; + height: 100%; + for row in LogViewerState.rows: Rectangle { + // Delegate height tracks the wrapped message so long + // lines aren't clipped (parent reads child preferred + // height — the supported, non-looping direction). + height: line.preferred-height; + line := HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + padding-top: 3px; + padding-bottom: 3px; + spacing: 10px; + Text { + text: row.ts; + width: 80px; + color: Theme.text-muted; + font-size: 11px; + overflow: elide; + } + Text { + text: row.level; + width: 48px; + font-size: 11px; + font-weight: Typography.semibold; + color: row.level == "ERROR" ? #f44336 + : row.level == "WARN" ? #e0a030 + : (row.level == "DEBUG" || row.level == "TRACE") ? Theme.text-muted + : Theme.text-secondary; + } + Text { + text: row.target; + width: 140px; + color: Theme.text-muted; + font-size: 11px; + overflow: elide; + } + Text { + text: row.message; + horizontal-stretch: 1; + color: Theme.text-primary; + font-size: 11px; + wrap: word-wrap; + } + } + } + } + + // Empty-state placeholder. + if LogViewerState.rows.length == 0: Text { + text: @tr("No log entries."); + color: Theme.text-muted; + font-size: Typography.body; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } + + // ---- Uploaded URL (advanced) — shown after upload, with a copy button. + if root.advanced-open && LogViewerState.uploaded-url != "": Rectangle { + height: 32px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: Theme.surface-elevated; + HorizontalLayout { + padding-left: 11px; + padding-right: 4px; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/link.svg"); + width: 14px; + height: 14px; + tint: Theme.accent; + } + } + Rectangle { + horizontal-stretch: 1; + clip: true; + url-input := TextInput { + width: 100%; + height: 100%; + read-only: true; + single-line: true; + color: Theme.text-secondary; + font-size: 12px; + vertical-alignment: center; + text: LogViewerState.uploaded-url; + } + } + VerticalLayout { + alignment: center; + url-copy := TouchArea { + width: 26px; + height: 26px; + mouse-cursor: pointer; + clicked => { + LogViewerState.copy-url(); + } + QbzIcon { + source: @image-url("../assets/icons/copy.svg"); + width: 14px; + height: 14px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: url-copy.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + } + } + + // ---- Advanced actions (behind [+]): bundle / upload / open file / clear. + if root.advanced-open: HorizontalLayout { + spacing: 10px; + IconTextButton { + label: @tr("Copy diagnostics bundle"); + icon: @image-url("../assets/icons/copy.svg"); + clicked => { + LogViewerState.copy-bundle(); + } + } + IconTextButton { + label: LogViewerState.uploading ? @tr("Uploading…") : @tr("Upload (public)"); + icon: @image-url("../assets/icons/cloud-upload.svg"); + enabled: !LogViewerState.uploading; + clicked => { + LogViewerState.upload(); + } + } + Rectangle { horizontal-stretch: 1; } + IconTextButton { + label: @tr("Open log file"); + icon: @image-url("../assets/icons/external-link.svg"); + clicked => { + LogViewerState.open-log-file(); + } + } + IconTextButton { + label: @tr("Clear"); + icon: @image-url("../assets/icons/trash-2.svg"); + danger: true; + clicked => { + LogViewerState.clear(); + } + } + } + + // ---- Footer: Copy all — the only always-visible button. + HorizontalLayout { + spacing: 10px; + IconTextButton { + label: LogViewerState.copied ? @tr("Copied") : @tr("Copy all"); + icon: LogViewerState.copied + ? @image-url("../assets/icons/check.svg") + : @image-url("../assets/icons/copy.svg"); + clicked => { + LogViewerState.copy-all(); + } + } + Rectangle { horizontal-stretch: 1; } + } + } + } + } +} diff --git a/crates/qbz-ui/ui/shell/QueueSidebar.slint b/crates/qbz-ui/ui/shell/QueueSidebar.slint index dc7ff4900..d04087249 100644 --- a/crates/qbz-ui/ui/shell/QueueSidebar.slint +++ b/crates/qbz-ui/ui/shell/QueueSidebar.slint @@ -14,9 +14,10 @@ import { Spacing } from "../foundation/spacing.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; -import { ShellState, QueueState, QueueItem, NowPlayingState, UiFocusState } from "../state.slint"; +import { ShellState, QueueState, QueueItem, NowPlayingState, UiFocusState, SleepTimerState, SleepTimerActions } from "../state.slint"; // Close button — mirrors the X control used by LyricsSidebar. component CloseButton inherits Rectangle { @@ -206,12 +207,23 @@ component QueueRow inherits Rectangle { if root.show-number: VerticalLayout { alignment: center; width: 22px; - Text { + // "Stop after this song" marker replaces the track number on the + // marked row; otherwise the 1-based track number. + if root.item.id != QueueState.stop-after-id: Text { text: (root.index + 1); color: Theme.text-muted; font-size: 12px; horizontal-alignment: center; } + if root.item.id == QueueState.stop-after-id: HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/circle-stop.svg"); + width: 13px; + height: 13px; + tint: Theme.accent; + } + } } if !root.show-number: Rectangle { width: 34px; @@ -311,7 +323,13 @@ component QueueRow inherits Rectangle { } ContextMenuItem { icon: @image-url("../assets/icons/circle-stop.svg"); - label: @tr("Stop after this"); + label: root.item.id == QueueState.stop-after-id + ? @tr("Cancel stop after this") + : @tr("Stop after this"); + clicked => { + QueueState.toggle-stop-after(root.item.id); + track-menu.close(); + } } ContextMenuItem { icon: @image-url("../assets/icons/list-x.svg"); @@ -774,12 +792,151 @@ export component QueueSidebar inherits Rectangle { } } } - // Action 4 — Sleep timer. Visual parity with Tauri; the - // timer popover / functionality is not wired yet. + // Action 4 — Sleep timer. Clock turns accent when armed; the popover + // opens above the footer with the preset picker / armed countdown. VerticalLayout { alignment: center; IconButton { icon: @image-url("../assets/icons/clock.svg"); + active: SleepTimerState.active; + clicked => { + sleep-menu.show(); + } + } + sleep-menu := PopupWindow { + x: -188px; + y: -214px; + width: 220px; + Rectangle { + width: 220px; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + VerticalLayout { + padding: 12px; + spacing: 10px; + + // Armed: countdown + Cancel. + if SleepTimerState.active: VerticalLayout { + spacing: 8px; + Text { + text: @tr("Stops in"); + color: Theme.text-muted; + font-size: 11px; + } + Text { + text: SleepTimerState.remaining; + color: Theme.accent; + font-size: 16px; + font-weight: Typography.semibold; + } + Rectangle { + height: 32px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + Text { + width: 100%; + height: 100%; + text: @tr("Cancel Timer"); + color: Theme.text-secondary; + font-size: 12px; + horizontal-alignment: center; + vertical-alignment: center; + } + cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + SleepTimerActions.cancel(); + sleep-menu.close(); + } + } + } + } + + // Idle: preset picker (+ optional custom minutes) + Set. + if !SleepTimerState.active: VerticalLayout { + spacing: 10px; + Text { + text: @tr("Stop playback after:"); + color: Theme.text-muted; + font-size: 11px; + } + QbzSelect { + options: [@tr("30 min"), @tr("1 hr"), @tr("2 hr"), @tr("3 hr"), @tr("5 hr"), @tr("Custom…")]; + current-index: SleepTimerState.selected-index; + sm: true; + selected(idx) => { + SleepTimerState.selected-index = idx; + } + } + Rectangle { + height: SleepTimerState.selected-index == 5 ? 32px : 0px; + visible: SleepTimerState.selected-index == 5; + clip: true; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: Theme.surface-elevated; + HorizontalLayout { + padding-left: 9px; + padding-right: 9px; + spacing: 6px; + custom-input := TextInput { + horizontal-stretch: 1; + color: Theme.text-primary; + font-size: 12px; + vertical-alignment: center; + single-line: true; + input-type: number; + text: SleepTimerState.custom-minutes; + changed has-focus => { + UiFocusState.text-input-focused = self.has-focus; + } + } + VerticalLayout { + alignment: center; + Text { + text: @tr("min"); + color: Theme.text-muted; + font-size: 11px; + } + } + } + } + Rectangle { + height: 34px; + border-radius: Radius.sm; + background: set-ta.has-hover ? Theme.accent-hover : Theme.accent; + Text { + width: 100%; + height: 100%; + text: @tr("Set Timer"); + color: Theme.accent-text; + font-size: 12px; + font-weight: Typography.medium; + horizontal-alignment: center; + vertical-alignment: center; + } + set-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + SleepTimerActions.set( + SleepTimerState.selected-index == 0 ? 30 + : SleepTimerState.selected-index == 1 ? 60 + : SleepTimerState.selected-index == 2 ? 120 + : SleepTimerState.selected-index == 3 ? 180 + : SleepTimerState.selected-index == 4 ? 300 + : custom-input.text.to-float()); + sleep-menu.close(); + } + } + } + } + } + } } } diff --git a/crates/qbz-ui/ui/shell/ReportIssueModal.slint b/crates/qbz-ui/ui/shell/ReportIssueModal.slint new file mode 100644 index 000000000..c56b8290f --- /dev/null +++ b/crates/qbz-ui/ui/shell/ReportIssueModal.slint @@ -0,0 +1,141 @@ +// Report-an-issue modal — 1:1 with the Tauri `ReportIssueModal.svelte`. +// +// Opened from the header hamburger menu ("Report an Issue"). Explains the manual, +// redacted log-sharing flow, then offers "Go to logs" (opens the in-app log +// viewer) and "Create issue report" (opens the GitHub new-issue page via Rust). +// Mounted in AppShell in declaration order (ADR-009); conditional-mount on +// `ReportIssueState.open` (ADR-010). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { ReportIssueState, ReportIssueActions, LogViewerState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; + +export component ReportIssueModal inherits Rectangle { + width: 100%; + height: 100%; + visible: ReportIssueState.open; + + if ReportIssueState.open: Rectangle { + background: #000000bf; + TouchArea { + mouse-cursor: default; + clicked => { + ReportIssueState.open = false; + } + } + + Rectangle { + width: Math.min(root.width - 80px, 520px); + height: Math.min(root.height - 80px, 300px); + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks so they don't reach the closing backdrop. + TouchArea { } + + VerticalLayout { + padding: 24px; + spacing: 16px; + + // ---- Header: title + close X. + HorizontalLayout { + spacing: 10px; + Text { + text: @tr("Report an issue"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + ReportIssueState.open = false; + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + + // ---- Intro: the manual, redacted log-sharing explanation. + Text { + text: @tr("To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared."); + color: Theme.text-secondary; + font-size: 14px; + wrap: word-wrap; + vertical-stretch: 1; + } + + // ---- Actions: Go to logs (secondary) / Create issue (primary). + HorizontalLayout { + spacing: 10px; + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: go-label.preferred-width + 28px; + height: 36px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: go-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + go-label := Text { + text: @tr("Go to logs"); + color: Theme.text-secondary; + font-size: 13px; + font-weight: Typography.medium; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + go-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + ReportIssueState.open = false; + LogViewerState.open = true; + } + } + } + Rectangle { + width: create-label.preferred-width + 28px; + height: 36px; + border-radius: Radius.sm; + background: create-ta.has-hover ? Theme.accent-hover : Theme.accent; + create-label := Text { + text: @tr("Create issue report"); + color: Theme.accent-text; + font-size: 13px; + font-weight: Typography.medium; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + create-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + ReportIssueState.open = false; + ReportIssueActions.create-issue(); + } + } + } + } + } + } + } +} diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index ac3b54e31..19bde86f5 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -370,6 +370,16 @@ export global ExternalRecoState { in property top-albums; // Cold-start fallback: Qobuz editorial top artists. in property <[SlimItem]> top-artists: []; + // Results-cache freshness window selector index for the Recommendations tab: + // 0=24h 1=36h 2=48h 3=72h. Seeded from discover_prefs at startup; default 48h. + in-out property cache-ttl-index: 2; +} + +// Recommendations-tab-only actions: cache-window select + force refresh. Wired to +// discover_prefs (persist) + external_reco (refetch). Other Discover tabs unaffected. +export global ExternalRecoActions { + callback set-cache-ttl(int); // index 0..3 -> persist hours + reseed + callback refresh-now(); // force-invalidate the results cache + refetch } // One genre in the Filter-by-genre popup's simple grid. @@ -4217,6 +4227,93 @@ export global QueueState { // Reflects whether infinite-play is currently enabled. in property infinite-play: false; + + // "Stop after this song" marker: the decimal-string id of the marked track, + // or "" for none. Each queue row compares its item.id to this to show the + // CircleStop glyph. `toggle-stop-after` flips the marker for a given track id. + callback toggle-stop-after(string /* track-id */); + in property stop-after-id: ""; +} + +// Sleep timer (queue footer). Frontend-only: a Rust tokio task owns the monotonic +// deadline + 1 Hz countdown and calls core().pause() at expiry. Non-persistent. +export global SleepTimerState { + in property active: false; + in property remaining: ""; // pre-formatted by Rust (single source of truth) + in-out property popover-open: false; + in-out property selected-index: 0; // preset index; last entry = Custom + in-out property custom-minutes: 60; +} +export global SleepTimerActions { + callback set(int /* minutes */); + callback cancel(); +} + +// In-app log viewer (Settings > Developer). One row of the [LogRow] model. +export struct LogRow { + ts: string, + level: string, + target: string, + message: string, +} +export global LogViewerState { + in-out property open: false; + in property <[LogRow]> rows: []; // already filtered + capped by Rust + in property total: 0; // ring size + in property shown: 0; // after filter + cap + in-out property filter-level: "all"; // all|error|warn|info|debug|trace + in-out property search: ""; + in-out property auto-tail: false; + in property uploading: false; + in property uploaded-url: ""; + in property copied: false; + callback refresh(); + callback clear(); + callback set-level(string); + callback set-search(string); + callback copy-all(); + callback copy-bundle(); + callback upload(); // public paste (already redacted) -> uploaded-url + callback open-log-file(); + callback toggle-auto-tail(bool); + callback copy-url(); // copy the uploaded paste URL to the clipboard +} + +// Report-an-issue modal (hamburger menu). Mirrors the Tauri ReportIssueModal: +// explains the manual, redacted log-sharing flow, then offers "Go to logs" +// (opens the log viewer) and "Create issue" (opens the GitHub new-issue page). +export global ReportIssueState { + in-out property open: false; +} +export global ReportIssueActions { + callback create-issue(); // opens the GitHub new-issue URL (Rust: open::that) +} + +// Diagnostics panel (Settings > Developer). One saved-vs-runtime table row. +// status: 0 info | 1 match | 2 mismatch. +export struct DiagRow { + label: string, + saved: string, + runtime: string, + status: int, +} +export global DiagnosticsState { + in property loaded: false; + in property loading: false; + in property error: ""; + in property app-version: ""; + in property copied: false; + in property cast-scanning: false; + in property <[DiagRow]> system-rows: []; + in property <[DiagRow]> playback-rows: []; + in property <[DiagRow]> qconnect-rows: []; + in property <[DiagRow]> cast-rows: []; + in property <[DiagRow]> audio-rows: []; + in property <[DiagRow]> graphics-rows: []; + in property <[DiagRow]> env-rows: []; + callback refresh(); + callback export-clipboard(); + callback cast-scan(); } // One row in the "Add to playlist" picker. diff --git a/crates/qbz/Cargo.toml b/crates/qbz/Cargo.toml index 7fc2aa90d..226657171 100644 --- a/crates/qbz/Cargo.toml +++ b/crates/qbz/Cargo.toml @@ -49,6 +49,7 @@ qbz-ui = { path = "../qbz-ui" } qbz-dac-wizard = { path = "../qbz-dac-wizard" } qbz-text-utils = { path = "../qbz-text-utils" } qbz-slint-common = { path = "../qbz-slint-common" } +qbz-log = { path = "../qbz-log" } log = { workspace = true } env_logger = "0.11" tokio = { workspace = true } diff --git a/crates/qbz/src/auth.rs b/crates/qbz/src/auth.rs index 1434c2edc..b1572037c 100644 --- a/crates/qbz/src/auth.rs +++ b/crates/qbz/src/auth.rs @@ -93,6 +93,10 @@ where let display_name = session.display_name.clone(); let subscription = session.subscription_label.clone(); let token = session.user_auth_token.clone(); + // Redaction (defense in depth): register the live auth token so any log line + // that embeds it bare (e.g. a signed stream URL, a header dump) is scrubbed + // before it reaches the in-memory ring, the on-disk log, or the clipboard. + qbz_log::register_secret(token.clone()); // Emit LoggedIn through the core (idempotent set_session). core.set_session(session).await.map_err(|e| e.to_string())?; diff --git a/crates/qbz/src/diagnostics.rs b/crates/qbz/src/diagnostics.rs new file mode 100644 index 000000000..ce1d0d524 --- /dev/null +++ b/crates/qbz/src/diagnostics.rs @@ -0,0 +1,1184 @@ +//! Diagnostics panel controller (Settings > Developer). +//! +//! Wires the `DiagnosticsState` Slint global. On `refresh()` it reads the three +//! settings stores (audio/graphics/developer) + computes the graphics runtime, +//! builds the frontend-agnostic `RuntimeDiagnostics` + `SystemInfo` snapshots +//! (`qbz_app::diagnostics`), snapshots the core player for the Playback rows, and +//! reads the LIVE Qobuz Connect session for the QConnect rows — then pushes all +//! seven per-section `[DiagRow]` models in one event-loop hop. Cast is the only +//! on-demand section: `cast-scan()` reuses the existing `CastService` discovery +//! and reads the populated `CastState`. Export serializes the cached snapshot +//! (camelCase, matching the Tauri DiagnosticsPanel export) to the clipboard. +//! +//! 1:1 port of `src/lib/components/DiagnosticsPanel.svelte` (the row builders), +//! over the shared backend extracted to `qbz_app::diagnostics`. + +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::adapter::SlintAdapter; +use crate::{AppWindow, CastState, DiagRow, DiagnosticsState}; + +type Runtime = Arc>; + +/// The Diagnostics controller. Cloned into each `DiagnosticsState` callback. +#[derive(Clone)] +struct DiagController { + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + /// Cached export base built on each `refresh()` — a JSON object with the + /// runtime-diagnostics fields flattened + `systemInfo` + `playback` + + /// `qconnect`. `castScan` + `exportedAt` are merged in at export time. + export: Arc>>, + /// Last Cast scan result (camelCase), merged into the export as `castScan`. + last_cast: Arc>>, +} + +/// Wire every `DiagnosticsState` callback. Call once at shell setup. +pub fn install(window: &AppWindow, runtime: Runtime, handle: tokio::runtime::Handle) { + let ctrl = DiagController { + runtime, + weak: window.as_weak(), + handle, + export: Arc::new(Mutex::new(None)), + last_cast: Arc::new(Mutex::new(None)), + }; + + let state = window.global::(); + { + let c = ctrl.clone(); + state.on_refresh(move || c.refresh()); + } + { + let c = ctrl.clone(); + state.on_export_clipboard(move || c.export_clipboard()); + } + { + let c = ctrl.clone(); + state.on_cast_scan(move || c.cast_scan()); + } +} + +impl DiagController { + /// Build the diagnostics snapshot and push the seven models. Called on the UI + /// thread (Slint callback); flips `loading` immediately, then spawns the work. + fn refresh(&self) { + if let Some(w) = self.weak.upgrade() { + w.global::().set_loading(true); + } + let this = self.clone(); + self.handle.spawn(async move { + this.refresh_async().await; + }); + } + + async fn refresh_async(&self) { + // (a) blocking: the three settings stores + /proc + /sys reads. + let collected = tokio::task::spawn_blocking(|| { + let audio = qbz_audio::settings::AudioSettingsStore::new() + .and_then(|s| s.get_settings()) + .unwrap_or_default(); + let (graphics, gfx_failed) = + match qbz_app::settings::graphics::GraphicsSettingsStore::new() + .and_then(|s| s.get_settings()) + { + Ok(g) => (g, false), + Err(_) => (Default::default(), true), + }; + let developer = qbz_app::settings::developer::DeveloperSettingsStore::new() + .and_then(|s| s.get_settings()) + .unwrap_or_default(); + let gfx = qbz_app::diagnostics::detect_graphics_runtime(&graphics, gfx_failed); + let runtime_diag = + qbz_app::diagnostics::runtime_diagnostics(&qbz_app::diagnostics::DiagnosticsInputs { + audio: &audio, + graphics: &graphics, + developer: &developer, + gfx, + app_version: env!("CARGO_PKG_VERSION"), + }); + let sys = qbz_app::diagnostics::system_info(); + // Live output sinks (BLOCKING CPAL enumeration — stays inside this + // spawn_blocking, never on the async path). + let (active_output, available_outputs, active_fmt) = collect_output_sinks(); + (runtime_diag, sys, active_output, available_outputs, active_fmt) + }) + .await; + + let (runtime_diag, sys, active_output, available_outputs, active_fmt) = match collected { + Ok(v) => v, + Err(e) => { + log::warn!("[qbz-slint] diagnostics: settings read panicked: {e}"); + let weak = self.weak.clone(); + let _ = weak.upgrade_in_event_loop(|w| { + let d = w.global::(); + d.set_loading(false); + d.set_error("Failed to read diagnostics".into()); + }); + return; + } + }; + + // (b) async core snapshot for the Playback section. + let pb = self.runtime.core().get_playback_state(); + let track = self.runtime.core().current_track().await; + + // (c) LIVE QConnect snapshot (no discovery; default when not running). + let qc = match crate::qconnect_service::service() { + Some(s) => s.diagnostics_snapshot().await, + None => Default::default(), + }; + + // (d) build the seven row vectors (1:1 with the Tauri row builders). + let system_rows = build_system_rows(&sys); + let playback_rows = build_playback_rows(&pb, track.as_ref()); + let qconnect_rows = build_qconnect_rows(&qc); + let audio_rows = build_audio_rows( + &runtime_diag, + active_output.as_deref(), + &available_outputs, + active_fmt + .as_ref() + .map(|(r, _)| r.as_str()) + .filter(|s| !s.is_empty()), + active_fmt + .as_ref() + .map(|(_, f)| f.as_str()) + .filter(|s| !s.is_empty()), + ); + let graphics_rows = build_graphics_rows(&runtime_diag); + let env_rows = build_env_rows(&runtime_diag); + + // (e) cache the export base (runtimeDiag flattened + systemInfo + + // playback + qconnect). castScan + exportedAt are added at export. + let playback_json = build_playback_json(&pb, track.as_ref()); + let qconnect_json = build_qconnect_json(&qc); + let mut map = serde_json::Map::new(); + if let Ok(Value::Object(rd)) = serde_json::to_value(&runtime_diag) { + for (k, v) in rd { + map.insert(k, v); + } + } + map.insert( + "systemInfo".to_string(), + serde_json::to_value(&sys).unwrap_or(Value::Null), + ); + map.insert("playback".to_string(), playback_json); + map.insert("qconnect".to_string(), qconnect_json); + if let Ok(mut g) = self.export.lock() { + *g = Some(Value::Object(map)); + } + + let app_version = runtime_diag.app_version.clone(); + + // (f) one event-loop hop: push all seven models + version + flags. + let weak = self.weak.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + let d = w.global::(); + d.set_system_rows(ModelRc::new(VecModel::from(system_rows))); + d.set_playback_rows(ModelRc::new(VecModel::from(playback_rows))); + d.set_qconnect_rows(ModelRc::new(VecModel::from(qconnect_rows))); + d.set_audio_rows(ModelRc::new(VecModel::from(audio_rows))); + d.set_graphics_rows(ModelRc::new(VecModel::from(graphics_rows))); + d.set_env_rows(ModelRc::new(VecModel::from(env_rows))); + d.set_app_version(app_version.into()); + d.set_loaded(true); + d.set_loading(false); + d.set_error("".into()); + }); + } + + /// Serialize the cached snapshot (+ last Cast scan + exportedAt) to the + /// clipboard. Flips `copied` for 1.5s. + fn export_clipboard(&self) { + let base = self.export.lock().ok().and_then(|g| g.clone()); + let Some(mut value) = base else { + return; + }; + if let Some(map) = value.as_object_mut() { + let cast = self.last_cast.lock().ok().and_then(|g| g.clone()); + map.insert("castScan".to_string(), cast.unwrap_or(Value::Null)); + map.insert( + "exportedAt".to_string(), + Value::String(chrono::Utc::now().to_rfc3339()), + ); + } + let json = serde_json::to_string_pretty(&value).unwrap_or_default(); + crate::share::copy_to_clipboard(json); + + if let Some(w) = self.weak.upgrade() { + w.global::().set_copied(true); + } + let weak = self.weak.clone(); + self.handle.spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_copied(false); + }); + }); + } + + /// On-demand Cast discovery scan: reuse the existing `CastService`, wait 10s, + /// then read the populated `CastState` to build the cast rows. Guarded against + /// re-entrancy; stops discovery afterwards only if the picker isn't using it. + fn cast_scan(&self) { + if let Some(w) = self.weak.upgrade() { + let st = w.global::(); + if st.get_cast_scanning() { + return; + } + st.set_cast_scanning(true); + } + + let this = self.clone(); + self.handle.spawn(async move { + let Some(svc) = crate::cast_service::service() else { + let _ = this.weak.upgrade_in_event_loop(|w| { + w.global::().set_cast_scanning(false); + }); + return; + }; + + svc.start_discovery().await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + svc.refresh_devices().await; + + // Read the live CastState on the UI thread, build the rows, and report + // the picker-open flag back so we can gate the discovery stop. + let (tx, rx) = tokio::sync::oneshot::channel::(); + let last_cast = this.last_cast.clone(); + let _ = this.weak.upgrade_in_event_loop(move |w| { + let cs = w.global::(); + let cc = cs.get_chromecast_count(); + let dl = cs.get_dlna_count(); + let devices = cs.get_devices(); + + let mut rows: Vec = Vec::new(); + rows.push(row("Chromecast devices", "—", &cc.to_string(), 0)); + rows.push(row("DLNA devices", "—", &dl.to_string(), 0)); + let mut device_json: Vec = Vec::new(); + for i in 0..devices.row_count() { + if let Some(dev) = devices.row_data(i) { + let protocol = dev.protocol.to_string(); + let name = dev.name.to_string(); + rows.push(row(&format!("• {protocol}"), "—", &name, 0)); + device_json.push(json!({ "name": name, "protocol": protocol })); + } + } + + if let Ok(mut g) = last_cast.lock() { + *g = Some(json!({ + "chromecastCount": cc, + "dlnaCount": dl, + "devices": device_json, + })); + } + + let d = w.global::(); + d.set_cast_rows(ModelRc::new(VecModel::from(rows))); + d.set_cast_scanning(false); + + let _ = tx.send(cs.get_picker_open()); + }); + + // Stop discovery only when the picker isn't relying on it (otherwise + // we'd kill the picker's live device list). + if let Ok(false) = rx.await { + svc.stop_discovery().await; + } + }); + } +} + +// ---- Full markdown report (uploaded diagnostics paste) ---------------------- + +/// Append a `- **key:** value` markdown bullet (one self-contained line, so it +/// renders correctly without relying on trailing-whitespace hard breaks). +fn md_line(out: &mut String, key: &str, value: &str) { + out.push_str("- **"); + out.push_str(key); + out.push_str(":** "); + out.push_str(value); + out.push('\n'); +} + +/// Build a COMPLETE, human-readable markdown diagnostics report — the same data +/// `refresh` gathers, formatted for the uploaded paste. The caller appends logs +/// separately, so this is the non-log body. Runs in an async tokio context, so +/// `tokio::task::spawn_blocking` works without an explicit handle. +pub async fn build_full_report(runtime: &Runtime) -> String { + // (a) blocking: the three settings stores + /proc + /sys + CPAL sinks. + let collected = tokio::task::spawn_blocking(|| { + let audio = qbz_audio::settings::AudioSettingsStore::new() + .and_then(|s| s.get_settings()) + .unwrap_or_default(); + let (graphics, gfx_failed) = + match qbz_app::settings::graphics::GraphicsSettingsStore::new() + .and_then(|s| s.get_settings()) + { + Ok(g) => (g, false), + Err(_) => (Default::default(), true), + }; + let developer = qbz_app::settings::developer::DeveloperSettingsStore::new() + .and_then(|s| s.get_settings()) + .unwrap_or_default(); + let gfx = qbz_app::diagnostics::detect_graphics_runtime(&graphics, gfx_failed); + let runtime_diag = + qbz_app::diagnostics::runtime_diagnostics(&qbz_app::diagnostics::DiagnosticsInputs { + audio: &audio, + graphics: &graphics, + developer: &developer, + gfx, + app_version: env!("CARGO_PKG_VERSION"), + }); + let sys = qbz_app::diagnostics::system_info(); + let (active_output, available_outputs, active_fmt) = collect_output_sinks(); + (runtime_diag, sys, active_output, available_outputs, active_fmt) + }) + .await; + + let (d, sys, active_output, available_outputs, active_fmt) = match collected { + Ok(v) => v, + Err(e) => { + return format!( + "# qbz diagnostics\n\n**Version:** {}\n\nFailed to gather diagnostics: {e}\n", + env!("CARGO_PKG_VERSION") + ); + } + }; + + // (b) async core snapshot for the Playback section. + let pb = runtime.core().get_playback_state(); + let track = runtime.core().current_track().await; + + // (c) LIVE QConnect snapshot (no discovery; default when not running). + let qc = match crate::qconnect_service::service() { + Some(s) => s.diagnostics_snapshot().await, + None => Default::default(), + }; + + let mut out = String::new(); + out.push_str("# qbz diagnostics\n\n"); + md_line(&mut out, "Version", env!("CARGO_PKG_VERSION")); + md_line( + &mut out, + "Generated", + &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), + ); + + // ## System + out.push_str("\n## System\n\n"); + md_line(&mut out, "OS", &sys.os); + md_line(&mut out, "Arch", &sys.arch); + md_line(&mut out, "Kernel", &opt(&sys.kernel_version)); + md_line(&mut out, "Distro", &opt(&sys.distro_pretty_name)); + md_line(&mut out, "Distro ID", &opt(&sys.distro_id)); + md_line(&mut out, "Distro Version", &opt(&sys.distro_version_id)); + md_line(&mut out, "Install Method", &sys.install_method); + if let Some(rt) = &sys.flatpak_runtime { + md_line( + &mut out, + "Flatpak Runtime", + &format!("{} {}", rt, opt(&sys.flatpak_runtime_version)), + ); + } + md_line(&mut out, "WebKit2GTK", &opt(&sys.webkit2gtk_version)); + md_line(&mut out, "GTK", &opt(&sys.gtk_version)); + md_line(&mut out, "glibc", &opt(&sys.glibc_version)); + md_line(&mut out, "ALSA", &opt(&sys.alsa_version)); + md_line(&mut out, "PipeWire", &opt(&sys.pipewire_version)); + md_line(&mut out, "PulseAudio", &opt(&sys.pulseaudio_version)); + + // ## Audio + out.push_str("\n## Audio\n\n"); + let sample_rate = match d.audio_preferred_sample_rate { + Some(hz) => format!("{hz} Hz"), + None => "Auto".to_string(), + }; + let available_str = if available_outputs.is_empty() { + "—".to_string() + } else { + available_outputs.join(", ") + }; + md_line(&mut out, "Output Device (saved)", &opt(&d.audio_output_device)); + md_line( + &mut out, + "Active Output (runtime)", + active_output.as_deref().unwrap_or("—"), + ); + if let Some((rate, fmt)) = &active_fmt { + if !rate.is_empty() { + md_line(&mut out, "Active Rate (runtime)", rate); + } + if !fmt.is_empty() { + md_line(&mut out, "Active Format (runtime)", fmt); + } + } + md_line(&mut out, "Available Outputs", &available_str); + md_line(&mut out, "Backend", &opt(&d.audio_backend_type)); + md_line(&mut out, "Exclusive Mode", yn(d.audio_exclusive_mode)); + md_line(&mut out, "DAC Passthrough", yn(d.audio_dac_passthrough)); + md_line(&mut out, "Preferred Sample Rate", &sample_rate); + md_line(&mut out, "ALSA Plugin", &opt(&d.audio_alsa_plugin)); + md_line(&mut out, "ALSA HW Volume", yn(d.audio_alsa_hardware_volume)); + md_line(&mut out, "Normalization", yn(d.audio_normalization_enabled)); + md_line( + &mut out, + "Normalization Target", + &format!("{} LUFS", d.audio_normalization_target_lufs), + ); + md_line(&mut out, "Gapless", yn(d.audio_gapless_enabled)); + md_line( + &mut out, + "PW Force Bitperfect", + yn(d.audio_pw_force_bitperfect), + ); + md_line( + &mut out, + "Stream Buffer", + &format!("{}s", d.audio_stream_buffer_seconds), + ); + md_line(&mut out, "Streaming Only", yn(d.audio_streaming_only)); + + // ## Graphics (saved + runtime) + out.push_str("\n## Graphics\n\n"); + let compositing = if d.env_webkit_disable_compositing.as_deref() == Some("1") { + "DISABLED" + } else { + "ENABLED" + }; + let dmabuf = if d.env_webkit_disable_dmabuf.as_deref() == Some("1") { + "DISABLED" + } else { + "ENABLED" + }; + md_line( + &mut out, + "Hardware Acceleration", + &format!( + "saved {} / runtime {}", + yn(d.gfx_hardware_acceleration), + yn(d.runtime_hw_accel_enabled) + ), + ); + md_line( + &mut out, + "Force DMA-BUF", + &format!("saved {} / runtime {}", yn(d.dev_force_dmabuf), dmabuf), + ); + md_line( + &mut out, + "Force X11", + &format!( + "saved {} / runtime {}", + yn(d.gfx_force_x11), + yn(d.runtime_force_x11_active) + ), + ); + md_line( + &mut out, + "GSK Renderer", + &format!( + "saved {} / runtime {}", + opt(&d.gfx_gsk_renderer), + opt(&d.env_gsk_renderer) + ), + ); + md_line(&mut out, "GDK Scale", &opt(&d.gfx_gdk_scale)); + md_line(&mut out, "GDK DPI Scale", &opt(&d.gfx_gdk_dpi_scale)); + md_line(&mut out, "Compositing Mode", compositing); + md_line( + &mut out, + "GPU", + if d.runtime_gpu_name.is_empty() { + "Unknown" + } else { + &d.runtime_gpu_name + }, + ); + md_line( + &mut out, + "GPU: NVIDIA", + if d.runtime_has_nvidia { "Detected" } else { "No" }, + ); + md_line( + &mut out, + "GPU: Intel", + if d.runtime_has_intel { "Detected" } else { "No" }, + ); + md_line( + &mut out, + "GPU: AMD", + if d.runtime_has_amd { "Detected" } else { "No" }, + ); + md_line( + &mut out, + "Desktop Environment", + if d.runtime_desktop_environment.is_empty() { + "Unknown" + } else { + &d.runtime_desktop_environment + }, + ); + md_line( + &mut out, + "Wayland", + if d.runtime_is_wayland { + "Yes" + } else { + "No (X11)" + }, + ); + md_line(&mut out, "VM", if d.runtime_is_vm { "Yes" } else { "No" }); + md_line(&mut out, "Using Fallback", yn(d.runtime_using_fallback)); + + // ## Environment + out.push_str("\n## Environment\n\n"); + md_line( + &mut out, + "WEBKIT_DISABLE_DMABUF_RENDERER", + &opt(&d.env_webkit_disable_dmabuf), + ); + md_line( + &mut out, + "WEBKIT_DISABLE_COMPOSITING_MODE", + &opt(&d.env_webkit_disable_compositing), + ); + md_line(&mut out, "GDK_BACKEND", &opt(&d.env_gdk_backend)); + md_line(&mut out, "GSK_RENDERER", &opt(&d.env_gsk_renderer)); + md_line( + &mut out, + "LIBGL_ALWAYS_SOFTWARE", + &opt(&d.env_libgl_always_software), + ); + md_line(&mut out, "WAYLAND_DISPLAY", &opt(&d.env_wayland_display)); + md_line(&mut out, "XDG_SESSION_TYPE", &opt(&d.env_xdg_session_type)); + + // ## Playback + out.push_str("\n## Playback\n\n"); + let volume_percent = (pb.volume * 100.0).round() as i64; + let title = track.as_ref().map(|t| t.title.clone()); + let artist = track.as_ref().map(|t| t.artist.clone()); + let album = track.as_ref().map(|t| t.album.clone()); + let source = track.as_ref().and_then(|t| t.source.clone()); + let bit_depth = track + .as_ref() + .and_then(|t| t.bit_depth) + .map(|b| format!("{b}-bit")) + .unwrap_or_else(|| "—".to_string()); + let track_sample_rate = track + .as_ref() + .and_then(|t| t.sample_rate) + .map(|r| format!("{} kHz", trim_khz(r))) + .unwrap_or_else(|| "—".to_string()); + let is_local = match track.as_ref() { + Some(t) => yn(t.is_local).to_string(), + None => "—".to_string(), + }; + md_line(&mut out, "Playing", yn(pb.is_playing)); + md_line(&mut out, "Volume", &format!("{volume_percent}%")); + md_line( + &mut out, + "Position / Duration", + &format!("{}s / {}s", pb.position, pb.duration), + ); + md_line(&mut out, "Has Track", yn(track.is_some())); + md_line(&mut out, "Track Title", &opt(&title)); + md_line(&mut out, "Track Artist", &opt(&artist)); + md_line(&mut out, "Track Album", &opt(&album)); + md_line(&mut out, "Track Source", &opt(&source)); + md_line(&mut out, "Track Is Local", &is_local); + md_line(&mut out, "Track Quality", "—"); + md_line(&mut out, "Track Format", "—"); + md_line(&mut out, "Track Bit Depth", &bit_depth); + md_line(&mut out, "Track Sample Rate", &track_sample_rate); + + // ## Qobuz Connect + out.push_str("\n## Qobuz Connect\n\n"); + let role = if qc.role.is_empty() { "none" } else { qc.role }; + let last_error = qc + .last_error + .as_deref() + .map(redact_id_like) + .unwrap_or_else(|| "—".to_string()); + md_line(&mut out, "Running", yn(qc.running)); + md_line( + &mut out, + "Transport Connected", + yn(qc.transport_connected), + ); + md_line(&mut out, "Has Endpoint", yn(qc.has_endpoint)); + md_line(&mut out, "Role", role); + md_line(&mut out, "Active Renderer", &opt(&qc.active_name)); + md_line(&mut out, "Renderer Brand", &opt(&qc.active_brand)); + md_line(&mut out, "Renderer Model", &opt(&qc.active_model)); + md_line(&mut out, "Visible Renderers", &qc.renderer_count.to_string()); + md_line(&mut out, "Last Error", &last_error); + + out +} + +// ---- Row builders (1:1 with DiagnosticsPanel.svelte) ------------------------ + +/// One diagnostics row. `status`: 0 info | 1 match | 2 mismatch. +fn row(label: &str, saved: &str, runtime: &str, status: i32) -> DiagRow { + DiagRow { + label: label.into(), + saved: saved.into(), + runtime: runtime.into(), + status, + } +} + +/// `ON`/`OFF`, mirroring the Tauri `bool()` helper. +fn yn(value: bool) -> &'static str { + if value { + "ON" + } else { + "OFF" + } +} + +/// `Some -> value`, `None -> "—"`, mirroring the Tauri `str()` helper. +fn opt(value: &Option) -> String { + value.clone().unwrap_or_else(|| "—".to_string()) +} + +/// Match status for the saved-vs-runtime comparison (Audio/Graphics). +fn match_status(saved: &str, runtime: &str) -> i32 { + if saved == "—" || runtime == "—" { + 0 + } else if saved == runtime { + 1 + } else { + 2 + } +} + +/// Format a kHz value without a trailing ".0" (96.0 -> "96", 44.1 -> "44.1"). +fn trim_khz(khz: f64) -> String { + if khz.fract().abs() < f64::EPSILON { + format!("{}", khz as i64) + } else { + format!("{khz:.1}") + } +} + +/// Query the live output sinks (BLOCKING — CPAL enumeration). Must be called +/// inside a `spawn_blocking`. Returns `(active_output, available_outputs)`: +/// `active_output` is the description (fallback name) of the default sink (the +/// ACTIVE output), and `available_outputs` is the description of every sink. +/// On `Err`, both are empty (treated as no sinks). +fn collect_output_sinks() -> (Option, Vec, Option<(String, String)>) { + let label = |s: &qbz_audio::output_sinks::OutputSinkInfo| -> String { + if s.description.is_empty() { + s.name.clone() + } else { + s.description.clone() + } + }; + let fmt = active_sink_format(); + match qbz_audio::output_sinks::list_output_sinks() { + Ok(sinks) => { + let active = sinks.iter().find(|s| s.is_default).map(&label); + let available = sinks.iter().map(&label).collect(); + (active, available, fmt) + } + Err(e) => { + log::warn!("[qbz-slint] diagnostics: list_output_sinks failed: {e}"); + (None, Vec::new(), fmt) + } + } +} + +/// Best-effort LIVE sample format of the active (default) output sink, parsed +/// from `pactl list sinks short`. Returns `(rate, format)` like +/// `("44100 Hz", "s32le · 2ch")` — the rate is what the device is ACTUALLY +/// running at right now (vs the saved "Preferred Sample Rate"). Linux/PipeWire/ +/// Pulse only (via pactl); `None` if pactl is unavailable or the sink can't be +/// determined. READ-ONLY — never touches the protected audio backend. +fn active_sink_format() -> Option<(String, String)> { + use std::process::Command; + let default = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()); + + let out = Command::new("pactl") + .args(["list", "sinks", "short"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8(out.stdout).ok()?; + + // sample-spec token like "s32le 2ch 44100Hz" -> ("44100 Hz", "s32le · 2ch"). + let parse_spec = |spec: &str| -> (String, String) { + let (mut rate, mut chans, mut fmt) = (String::new(), String::new(), String::new()); + for tok in spec.split_whitespace() { + if let Some(hz) = tok.strip_suffix("Hz") { + rate = format!("{hz} Hz"); + } else if tok.ends_with("ch") { + chans = tok.to_string(); + } else { + fmt = tok.to_string(); + } + } + let format = match (fmt.is_empty(), chans.is_empty()) { + (false, false) => format!("{fmt} · {chans}"), + (false, true) => fmt, + (true, false) => chans, + (true, true) => spec.trim().to_string(), + }; + (rate, format) + }; + + // Prefer the default sink; fall back to the first RUNNING sink. + let mut running: Option<(String, String)> = None; + for line in text.lines() { + let cols: Vec<&str> = line.split('\t').collect(); + if cols.len() < 5 { + continue; + } + let (name, spec, state) = (cols[1], cols[3], cols[4]); + if let Some(d) = &default { + if name == d { + return Some(parse_spec(spec)); + } + } + if state.eq_ignore_ascii_case("RUNNING") && running.is_none() { + running = Some(parse_spec(spec)); + } + } + running +} + +fn build_audio_rows( + d: &qbz_app::diagnostics::RuntimeDiagnostics, + active_output: Option<&str>, + available_outputs: &[String], + active_rate: Option<&str>, + active_fmt: Option<&str>, +) -> Vec { + let sample_rate = match d.audio_preferred_sample_rate { + Some(hz) => format!("{hz} Hz"), + None => "Auto".to_string(), + }; + + // Output Device: saved id (may be a stale/unplugged DAC) vs the live active + // output. Match (1) when the active equals OR is contained/suffixed by the + // saved value; mismatch (2) when an active device exists but differs (so the + // stale-saved-vs-live discrepancy is visible); info (0) when no live device. + let saved_output = opt(&d.audio_output_device); + let output_runtime = active_output.unwrap_or("—"); + let output_status = match active_output { + Some(active) => { + if saved_output == active + || saved_output.contains(active) + || saved_output.ends_with(active) + { + 1 + } else { + 2 + } + } + None => 0, + }; + let available_runtime = if available_outputs.is_empty() { + "—".to_string() + } else { + available_outputs.join(", ") + }; + + vec![ + row("Output Device", &saved_output, output_runtime, output_status), + row("Backend", &opt(&d.audio_backend_type), "—", 0), + row("Exclusive Mode", yn(d.audio_exclusive_mode), "—", 0), + row("DAC Passthrough", yn(d.audio_dac_passthrough), "—", 0), + row("Preferred Sample Rate", &sample_rate, active_rate.unwrap_or("—"), 0), + row("Active Format", "—", active_fmt.unwrap_or("—"), 0), + row("ALSA Plugin", &opt(&d.audio_alsa_plugin), "—", 0), + row("ALSA HW Volume", yn(d.audio_alsa_hardware_volume), "—", 0), + row("Normalization", yn(d.audio_normalization_enabled), "—", 0), + row( + "Normalization Target", + &format!("{} LUFS", d.audio_normalization_target_lufs), + "—", + 0, + ), + row("Gapless", yn(d.audio_gapless_enabled), "—", 0), + row("PW Force Bitperfect", yn(d.audio_pw_force_bitperfect), "—", 0), + row( + "Stream Buffer", + &format!("{}s", d.audio_stream_buffer_seconds), + "—", + 0, + ), + row("Streaming Only", yn(d.audio_streaming_only), "—", 0), + row("Available Outputs", "—", &available_runtime, 0), + ] +} + +fn build_graphics_rows(d: &qbz_app::diagnostics::RuntimeDiagnostics) -> Vec { + let hw_saved = yn(d.gfx_hardware_acceleration); + let hw_runtime = yn(d.runtime_hw_accel_enabled); + let x11_saved = yn(d.gfx_force_x11); + let x11_runtime = yn(d.runtime_force_x11_active); + let compositing = if d.env_webkit_disable_compositing.as_deref() == Some("1") { + "DISABLED" + } else { + "ENABLED" + }; + let dmabuf = if d.env_webkit_disable_dmabuf.as_deref() == Some("1") { + "DISABLED" + } else { + "ENABLED" + }; + let gsk_saved = opt(&d.gfx_gsk_renderer); + let gsk_runtime = opt(&d.env_gsk_renderer); + let dmabuf_status = if d.dev_force_dmabuf == (dmabuf == "ENABLED") { + 1 + } else { + 2 + }; + + vec![ + row( + "Hardware Acceleration", + hw_saved, + hw_runtime, + match_status(hw_saved, hw_runtime), + ), + row( + "Force DMA-BUF", + yn(d.dev_force_dmabuf), + dmabuf, + dmabuf_status, + ), + row( + "Force X11", + x11_saved, + x11_runtime, + match_status(x11_saved, x11_runtime), + ), + row( + "GSK Renderer", + &gsk_saved, + &gsk_runtime, + match_status(&gsk_saved, &gsk_runtime), + ), + row("GDK Scale", &opt(&d.gfx_gdk_scale), "—", 0), + row("GDK DPI Scale", &opt(&d.gfx_gdk_dpi_scale), "—", 0), + row("Compositing Mode", "—", compositing, 0), + row( + "GPU", + "—", + if d.runtime_gpu_name.is_empty() { + "Unknown" + } else { + &d.runtime_gpu_name + }, + 0, + ), + row( + "GPU: NVIDIA", + "—", + if d.runtime_has_nvidia { "Detected" } else { "No" }, + 0, + ), + row( + "GPU: Intel", + "—", + if d.runtime_has_intel { "Detected" } else { "No" }, + 0, + ), + row( + "GPU: AMD", + "—", + if d.runtime_has_amd { "Detected" } else { "No" }, + 0, + ), + row( + "Desktop Environment", + "—", + if d.runtime_desktop_environment.is_empty() { + "Unknown" + } else { + &d.runtime_desktop_environment + }, + 0, + ), + row( + "Wayland", + "—", + if d.runtime_is_wayland { + "Yes" + } else { + "No (X11)" + }, + 0, + ), + row("VM", "—", if d.runtime_is_vm { "Yes" } else { "No" }, 0), + row( + "Using Fallback", + "—", + yn(d.runtime_using_fallback), + if d.runtime_using_fallback { 2 } else { 0 }, + ), + ] +} + +fn build_env_rows(d: &qbz_app::diagnostics::RuntimeDiagnostics) -> Vec { + vec![ + row( + "WEBKIT_DISABLE_DMABUF_RENDERER", + "—", + &opt(&d.env_webkit_disable_dmabuf), + 0, + ), + row( + "WEBKIT_DISABLE_COMPOSITING_MODE", + "—", + &opt(&d.env_webkit_disable_compositing), + 0, + ), + row("GDK_BACKEND", "—", &opt(&d.env_gdk_backend), 0), + row("GSK_RENDERER", "—", &opt(&d.env_gsk_renderer), 0), + row( + "LIBGL_ALWAYS_SOFTWARE", + "—", + &opt(&d.env_libgl_always_software), + 0, + ), + row("WAYLAND_DISPLAY", "—", &opt(&d.env_wayland_display), 0), + row("XDG_SESSION_TYPE", "—", &opt(&d.env_xdg_session_type), 0), + ] +} + +fn build_system_rows(s: &qbz_app::diagnostics::SystemInfo) -> Vec { + let mut rows = vec![ + row("OS", "—", &s.os, 0), + row("Arch", "—", &s.arch, 0), + row("Kernel", "—", &opt(&s.kernel_version), 0), + row("Distro", "—", &opt(&s.distro_pretty_name), 0), + row("Distro ID", "—", &opt(&s.distro_id), 0), + row("Distro Version", "—", &opt(&s.distro_version_id), 0), + row("Install Method", "—", &s.install_method, 0), + ]; + if let Some(runtime) = &s.flatpak_runtime { + rows.push(row( + "Flatpak Runtime", + "—", + &format!("{} {}", runtime, opt(&s.flatpak_runtime_version)), + 0, + )); + } + rows.push(row("WebKit2GTK", "—", &opt(&s.webkit2gtk_version), 0)); + rows.push(row("GTK", "—", &opt(&s.gtk_version), 0)); + rows.push(row("glibc", "—", &opt(&s.glibc_version), 0)); + rows.push(row("ALSA", "—", &opt(&s.alsa_version), 0)); + rows.push(row("PipeWire", "—", &opt(&s.pipewire_version), 0)); + rows.push(row("PulseAudio", "—", &opt(&s.pulseaudio_version), 0)); + rows +} + +fn build_playback_rows( + pb: &qbz_player::PlaybackState, + track: Option<&qbz_models::QueueTrack>, +) -> Vec { + let volume_percent = (pb.volume * 100.0).round() as i64; + let has_track = track.is_some(); + let title = track.map(|t| t.title.clone()); + let artist = track.map(|t| t.artist.clone()); + let album = track.map(|t| t.album.clone()); + let source = track.and_then(|t| t.source.clone()); + let bit_depth = track + .and_then(|t| t.bit_depth) + .map(|d| format!("{d}-bit")) + .unwrap_or_else(|| "—".to_string()); + let sample_rate = track + .and_then(|t| t.sample_rate) + .map(|r| format!("{} kHz", trim_khz(r))) + .unwrap_or_else(|| "—".to_string()); + let is_local = match track { + Some(t) => yn(t.is_local).to_string(), + None => "—".to_string(), + }; + + vec![ + row("Playing", "—", yn(pb.is_playing), 0), + row("Volume", "—", &format!("{volume_percent}%"), 0), + row( + "Position / Duration", + "—", + &format!("{}s / {}s", pb.position, pb.duration), + 0, + ), + row("Has Track", "—", yn(has_track), 0), + row("Track Title", "—", &opt(&title), 0), + row("Track Artist", "—", &opt(&artist), 0), + row("Track Album", "—", &opt(&album), 0), + row("Track Source", "—", &opt(&source), 0), + row("Track Is Local", "—", &is_local, 0), + // No quality/format field on QueueTrack — emit "—" (faithful to data). + row("Track Quality", "—", "—", 0), + row("Track Format", "—", "—", 0), + row("Track Bit Depth", "—", &bit_depth, 0), + row("Track Sample Rate", "—", &sample_rate, 0), + ] +} + +fn build_qconnect_rows(q: &crate::qconnect_service::QconnectDiagSnapshot) -> Vec { + let role = if q.role.is_empty() { "none" } else { q.role }; + let last_error = q + .last_error + .as_deref() + .map(redact_id_like) + .unwrap_or_else(|| "—".to_string()); + vec![ + row("Running", "—", yn(q.running), 0), + row("Transport Connected", "—", yn(q.transport_connected), 0), + row("Has Endpoint", "—", yn(q.has_endpoint), 0), + row("Role", "—", role, 0), + row("Active Renderer", "—", &opt(&q.active_name), 0), + row("Renderer Brand", "—", &opt(&q.active_brand), 0), + row("Renderer Model", "—", &opt(&q.active_model), 0), + row("Visible Renderers", "—", &q.renderer_count.to_string(), 0), + row("Last Error", "—", &last_error, 0), + ] +} + +// ---- Export JSON (camelCase, matching the Tauri export shape) --------------- + +fn build_playback_json( + pb: &qbz_player::PlaybackState, + track: Option<&qbz_models::QueueTrack>, +) -> Value { + json!({ + "isPlaying": pb.is_playing, + "volumePercent": (pb.volume * 100.0).round() as i64, + "positionSecs": pb.position, + "durationSecs": pb.duration, + "hasTrack": track.is_some(), + "trackTitle": track.map(|t| t.title.clone()), + "trackArtist": track.map(|t| t.artist.clone()), + "trackAlbum": track.map(|t| t.album.clone()), + "trackQuality": Value::Null, + "trackFormat": Value::Null, + "trackBitDepth": track.and_then(|t| t.bit_depth), + "trackSamplingRate": track.and_then(|t| t.sample_rate), + "trackIsLocal": track.map(|t| t.is_local), + "trackSource": track.and_then(|t| t.source.clone()), + }) +} + +fn build_qconnect_json(q: &crate::qconnect_service::QconnectDiagSnapshot) -> Value { + let role = if q.role.is_empty() { "none" } else { q.role }; + json!({ + "running": q.running, + "transport_connected": q.transport_connected, + "hasEndpoint": q.has_endpoint, + "lastError": q.last_error.as_deref().map(redact_id_like), + "role": role, + "activeRendererName": q.active_name, + "activeRendererBrand": q.active_brand, + "activeRendererModel": q.active_model, + "rendererCount": q.renderer_count, + }) +} + +/// Redact UUID + long-hex substrings (ported from DiagnosticsPanel.svelte's +/// `redactIdLike`, JS regex `/[0-9a-f]{8}-…/` + `/\b[0-9a-f]{32,}\b/`). Keeps +/// pasted diagnostics free of anything a secret scanner might flag. Operates on +/// chars so it is UTF-8 safe. +fn redact_id_like(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let n = chars.len(); + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + let mut out = String::with_capacity(value.len()); + let mut i = 0; + while i < n { + // UUID shape (8-4-4-4-12 hex), word-boundary delimited. + if uuid_at(&chars, i) + && (i == 0 || !is_word(chars[i - 1])) + && (i + 36 >= n || !is_word(chars[i + 36])) + { + out.push_str(""); + i += 36; + continue; + } + // A maximal word token that is entirely hex and >= 32 chars long. + if chars[i].is_ascii_hexdigit() && (i == 0 || !is_word(chars[i - 1])) { + let mut j = i; + while j < n && is_word(chars[j]) { + j += 1; + } + if j - i >= 32 && chars[i..j].iter().all(|c| c.is_ascii_hexdigit()) { + out.push_str(""); + i = j; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +/// Whether a 36-char `8-4-4-4-12` hex UUID starts at `i`. +fn uuid_at(chars: &[char], i: usize) -> bool { + let groups = [8usize, 4, 4, 4, 12]; + let mut p = i; + for (gi, &len) in groups.iter().enumerate() { + for _ in 0..len { + if p >= chars.len() || !chars[p].is_ascii_hexdigit() { + return false; + } + p += 1; + } + if gi < 4 { + if p >= chars.len() || chars[p] != '-' { + return false; + } + p += 1; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_uuid_and_long_hex() { + let s = "session 550e8400-e29b-41d4-a716-446655440000 token \ + 0123456789abcdef0123456789abcdef ok"; + let out = redact_id_like(s); + assert!(out.contains(""), "{out}"); + assert!(out.contains(""), "{out}"); + assert!(out.contains("session")); + assert!(out.contains("ok")); + } + + #[test] + fn leaves_short_hex_alone() { + // 8 hex chars (a SONAME-ish short id) is below the 32-char threshold. + assert_eq!(redact_id_like("abc123 deadbeef end"), "abc123 deadbeef end"); + } + + #[test] + fn match_status_rules() { + assert_eq!(match_status("ON", "ON"), 1); + assert_eq!(match_status("ON", "OFF"), 2); + assert_eq!(match_status("—", "ON"), 0); + assert_eq!(match_status("ON", "—"), 0); + } +} diff --git a/crates/qbz/src/discover_prefs.rs b/crates/qbz/src/discover_prefs.rs index c0934ff4f..660838c14 100644 --- a/crates/qbz/src/discover_prefs.rs +++ b/crates/qbz/src/discover_prefs.rs @@ -27,7 +27,10 @@ use qbz_app::settings::discover_prefs::{ use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; use crate::artwork::{ImageCache, spawn_loads}; -use crate::{AppWindow, ConfigRow, DiscoverSection, DiscoverState, SectionDescriptor, SettingsState}; +use crate::{ + AppWindow, ConfigRow, DiscoverSection, DiscoverState, ExternalRecoState, SectionDescriptor, + SettingsState, +}; /// Per-user persistent store. `None` outside an active session. static STORE: Mutex> = Mutex::new(None); @@ -87,6 +90,45 @@ pub fn set_show_recommendations(value: bool) { persist(); } +// --------------------------------------------------------------------------- +// Recommendations cache-window (TTL) setting +// --------------------------------------------------------------------------- + +/// The offered cache-window options, in hours. Index <-> hours mapping is the +/// contract with the `ExternalRecoState.cache-ttl-index` Slint global and the +/// QbzSelect option order (24 / 36 / 48 / 72 hours). +const TTL_HOURS: [i64; 4] = [24, 36, 48, 72]; + +/// Map a stored hour value to its select index (default index 2 == 48h). +fn ttl_index_from_hours(h: i64) -> i32 { + TTL_HOURS.iter().position(|&x| x == h).unwrap_or(2) as i32 +} + +/// Map a select index back to its hour value (clamped; default 48h). +fn ttl_hours_from_index(i: i32) -> i64 { + *TTL_HOURS.get(i.clamp(0, 3) as usize).unwrap_or(&48) +} + +/// Set the Recommendations cache window from the select index (0..=3). Mirrors +/// `set_show_recommendations`: mutate the in-memory authoritative prefs, persist, +/// then push the resolved index back into the `ExternalRecoState` global. +pub fn set_reco_cache_ttl_index(window: &AppWindow, index: i32) { + let hours = ttl_hours_from_index(index); + if let Some(p) = PREFS.lock().unwrap().as_mut() { + p.reco_cache_ttl_hours = hours; + } + persist(); + window + .global::() + .set_cache_ttl_index(ttl_index_from_hours(hours)); +} + +/// The current Recommendations cache window expressed in SECONDS — consumed by +/// the external-reco loader's `get_results` TTL check. +pub fn reco_cache_ttl_secs() -> i64 { + current().reco_cache_ttl_hours * 3600 +} + // --------------------------------------------------------------------------- // id -> render kind / i18n label key // --------------------------------------------------------------------------- @@ -235,6 +277,10 @@ pub fn seed(window: &AppWindow) { window .global::() .set_show_recommendations(prefs.show_recommendations); + // Seed the Recommendations cache-window select to the persisted choice. + window + .global::() + .set_cache_ttl_index(ttl_index_from_hours(prefs.reco_cache_ttl_hours)); // MusicBrainz opt-out lives in ui_prefs (not the discover prefs store). // Seed it here so both SettingsState seed sites (main.rs:320/554) populate // the toggle that gates the artist sidebar + playlist suggestions. diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index d4c833575..1e57d4e49 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -127,7 +127,26 @@ pub fn ensure_loaded( return; } w.global::().set_loading(true); - spawn(runtime.clone(), weak.clone(), handle, image_cache.clone()); + spawn(runtime.clone(), weak.clone(), handle, image_cache.clone(), false); +} + +/// Force a full rebuild of the Recommendations tab, bypassing the instant +/// results-cache paint (the "Refresh now" action). Resets the loaded/loading +/// latches and runs `spawn` with `force = true`, which skips the cache-read +/// early-return so every row is rebuilt and the results blob is overwritten. +pub fn force_reload( + runtime: &Arc>, + weak: &slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: &ImageCache, +) { + let Some(w) = weak.upgrade() else { + return; + }; + let s = w.global::(); + s.set_loaded(false); + s.set_loading(true); + spawn(runtime.clone(), weak.clone(), handle, image_cache.clone(), true); } fn rotation_seed() -> u64 { @@ -142,6 +161,7 @@ fn spawn( weak: slint::Weak, handle: &tokio::runtime::Handle, image_cache: ImageCache, + force: bool, ) { handle.spawn(async move { let cfg = crate::scrobbler_settings::get(); @@ -211,37 +231,50 @@ fn spawn( inputs.listenbrainz.is_some() ); log::info!( - "[reco] spawn: lastfm={} listenbrainz={} source_key={source_key}", + "[reco] spawn: lastfm={} listenbrainz={} source_key={source_key} force={force}", inputs.lastfm.is_some(), inputs.listenbrainz.is_some() ); + // Effective results-cache window (Recommendations setting -> seconds). + let ttl_secs = crate::discover_prefs::reco_cache_ttl_secs(); + // 1. Results cache: paint the NON-weekly rows INSTANTLY if a fresh - // (<48h) build is cached. The Weekly Exploration/Jams rows are NOT - // trusted from this blob — they follow ListenBrainz's own weekly cadence - // and have their own per-week cache, so we ALWAYS (re)build them via - // build_and_apply_weeklies (cheap on a weekly-cache hit). This is what - // stops a single transient empty build from hiding them for 48h. - if let Some(cache_mutex) = inputs.cache { - let cached = cache_mutex.lock().ok().and_then(|g| g.get_results(&source_key)); - if let Some(json) = cached { - if let Ok(result) = serde_json::from_str::(&json) { - apply_all(&weak, &image_cache, result); - // The non-weekly rows painted instantly from the blob; the - // two Weekly rows rebuild from their own per-week cache, so - // show their skeletons until build_and_apply_weeklies fills - // them (instant on a weekly-cache hit). - if inputs.listenbrainz.is_some() { - let w = weak.clone(); - let _ = w.upgrade_in_event_loop(|w| { - let s = w.global::(); - s.set_pending_weekly_exploration(true); - s.set_pending_weekly_jams(true); - }); + // (within the configured window) build is cached. The Weekly + // Exploration/Jams rows are NOT trusted from this blob — they follow + // ListenBrainz's own weekly cadence and have their own per-week cache, so + // we ALWAYS (re)build them via build_and_apply_weeklies (cheap on a + // weekly-cache hit). This is what stops a single transient empty build + // from hiding them for the whole window. + // + // A FORCED reload (the "Refresh now" action) skips this block entirely so + // the tab always rebuilds from scratch; the rebuild overwrites the blob + // via put_results below. + if !force { + if let Some(cache_mutex) = inputs.cache { + let cached = cache_mutex + .lock() + .ok() + .and_then(|g| g.get_results(&source_key, ttl_secs)); + if let Some(json) = cached { + if let Ok(result) = serde_json::from_str::(&json) { + apply_all(&weak, &image_cache, result); + // The non-weekly rows painted instantly from the blob; the + // two Weekly rows rebuild from their own per-week cache, so + // show their skeletons until build_and_apply_weeklies fills + // them (instant on a weekly-cache hit). + if inputs.listenbrainz.is_some() { + let w = weak.clone(); + let _ = w.upgrade_in_event_loop(|w| { + let s = w.global::(); + s.set_pending_weekly_exploration(true); + s.set_pending_weekly_jams(true); + }); + } + build_and_apply_weeklies(&inputs, &weak, &image_cache).await; + latch_loaded(&weak); + return; } - build_and_apply_weeklies(&inputs, &weak, &image_cache).await; - latch_loaded(&weak); - return; } } } diff --git a/crates/qbz/src/log_viewer.rs b/crates/qbz/src/log_viewer.rs new file mode 100644 index 000000000..a9b716b97 --- /dev/null +++ b/crates/qbz/src/log_viewer.rs @@ -0,0 +1,277 @@ +//! Log viewer controller (developer log overlay). +//! +//! Wires the `LogViewerState` Slint global to the `qbz_log` in-memory ring. The +//! viewer is a thin read surface over `qbz_log::ring`: `refresh` snapshots the +//! ring, applies the level + search filters, caps to the last 1000 rows, and +//! pushes `[LogRow]`. `clear` empties the ring; `set-level` / `set-search` +//! re-filter; `auto-tail` re-runs `refresh` every 1.5s via a `slint::Timer`. +//! `copy-all` copies the currently-filtered rows; `copy-bundle` builds a +//! GitHub-ready diagnostics bundle; `upload` POSTs that bundle to paste.rs and +//! surfaces the returned URL; `open-log-file` opens the on-disk log. +//! +//! All log text is redacted at the ring's write choke point; clipboard/upload +//! paths redact again defensively. + +use std::sync::Arc; + +use slint::{ComponentHandle, ModelRc, VecModel}; + +use crate::adapter::SlintAdapter; +use crate::{AppWindow, LogRow, LogViewerState}; + +type Runtime = Arc>; + +/// Maximum rows pushed to the viewer after filtering (the ring holds up to +/// `qbz_log::ring::RING_CAP`; the view shows the most recent slice). +const MAX_VIEW_ROWS: usize = 1000; + +/// Auto-tail refresh cadence. +const AUTO_TAIL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1500); + +thread_local! { + /// The auto-tail timer. UI-thread only (slint::Timer requirement); started / + /// stopped from the `toggle-auto-tail` callback (always on the UI thread). + static AUTO_TAIL_TIMER: slint::Timer = slint::Timer::default(); +} + +/// Wire every `LogViewerState` callback. Call once at shell setup. `runtime` is +/// used by the "Copy diagnostics bundle" / "Upload" paths to gather the COMPLETE +/// diagnostics report (system + live audio + graphics + playback + qconnect). +pub fn install(window: &AppWindow, runtime: Runtime, handle: tokio::runtime::Handle) { + let state = window.global::(); + + { + let weak = window.as_weak(); + state.on_refresh(move || rebuild(&weak)); + } + { + let weak = window.as_weak(); + state.on_clear(move || { + qbz_log::ring::clear(); + rebuild(&weak); + }); + } + { + let weak = window.as_weak(); + // The new value is already stored in the in-out `filter-level`; rebuild + // reads it back. Same for search. + state.on_set_level(move |_level| rebuild(&weak)); + } + { + let weak = window.as_weak(); + state.on_set_search(move |_search| rebuild(&weak)); + } + { + let weak = window.as_weak(); + state.on_toggle_auto_tail(move |on| { + if let Some(w) = weak.upgrade() { + w.global::().set_auto_tail(on); + } + AUTO_TAIL_TIMER.with(|timer| { + if on { + let weak = weak.clone(); + timer.start(slint::TimerMode::Repeated, AUTO_TAIL_INTERVAL, move || { + rebuild(&weak); + }); + } else { + timer.stop(); + } + }); + }); + } + { + let weak = window.as_weak(); + let handle = handle.clone(); + state.on_copy_all(move || { + let text = filtered_text(&weak).join("\n"); + crate::share::copy_to_clipboard(text); + flash_copied(&weak, &handle); + }); + } + { + let weak = window.as_weak(); + let handle = handle.clone(); + let runtime = runtime.clone(); + state.on_copy_bundle(move || { + let weak = weak.clone(); + let runtime = runtime.clone(); + handle.spawn(async move { + let bundle = build_share_text(&runtime).await; + crate::share::copy_to_clipboard(bundle); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_copied(true); + }); + tokio::time::sleep(AUTO_TAIL_INTERVAL).await; + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_copied(false); + }); + }); + }); + } + { + let weak = window.as_weak(); + let handle = handle.clone(); + let runtime = runtime.clone(); + state.on_upload(move || { + if let Some(w) = weak.upgrade() { + w.global::().set_uploading(true); + } + let weak = weak.clone(); + let runtime = runtime.clone(); + handle.spawn(async move { + let bundle = build_share_text(&runtime).await; + let url = match reqwest::Client::new() + .post("https://paste.rs/") + .body(bundle) + .send() + .await + { + Ok(resp) => resp.text().await.unwrap_or_default().trim().to_string(), + Err(e) => { + log::warn!("[qbz-slint] log upload failed: {e}"); + String::new() + } + }; + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_uploaded_url(url.into()); + st.set_uploading(false); + }); + }); + }); + } + { + state.on_open_log_file(move || { + if let Some(path) = qbz_log::install::log_file_path() { + if let Err(e) = open::that(path) { + log::warn!("[qbz-slint] open log file failed: {e}"); + } + } + }); + } + { + let weak = window.as_weak(); + state.on_copy_url(move || { + if let Some(w) = weak.upgrade() { + let url = w.global::().get_uploaded_url().to_string(); + if !url.is_empty() { + crate::share::copy_to_clipboard(url); + } + } + }); + } +} + +/// Whether `line` passes the level + search filters currently set on the global. +/// `level` is the lowercased `filter-level` ("all" = no level filter); `search` +/// is the lowercased query (empty = no search filter), matched over target + +/// message. +fn line_matches(line: &qbz_log::LogLine, level: &str, search: &str) -> bool { + let level_ok = level == "all" || line.level_str().eq_ignore_ascii_case(level); + let search_ok = search.is_empty() + || line.target.to_lowercase().contains(search) + || line.message.to_lowercase().contains(search); + level_ok && search_ok +} + +/// Snapshot + filter the ring, cap to the last [`MAX_VIEW_ROWS`], and push the +/// rows + counters onto `LogViewerState`. Runs on the UI thread. +fn rebuild(weak: &slint::Weak) { + let Some(w) = weak.upgrade() else { + return; + }; + let st = w.global::(); + let level = st.get_filter_level().to_string().to_lowercase(); + let search = st.get_search().to_string().to_lowercase(); + + let snap = qbz_log::ring::snapshot(); + let total = snap.len(); + let filtered: Vec<&qbz_log::LogLine> = snap + .iter() + .filter(|line| line_matches(line, &level, &search)) + .collect(); + let start = filtered.len().saturating_sub(MAX_VIEW_ROWS); + let rows: Vec = filtered[start..] + .iter() + .map(|line| LogRow { + ts: line.format_ts().into(), + level: line.level_str().into(), + target: line.target.clone().into(), + message: line.message.clone().into(), + }) + .collect(); + let shown = rows.len(); + + st.set_rows(ModelRc::new(VecModel::from(rows))); + st.set_total(total as i32); + st.set_shown(shown as i32); +} + +/// The currently-filtered rows as redacted `"{ts} {level} {target} {message}"` +/// lines (last [`MAX_VIEW_ROWS`]). Used by `copy-all`. +fn filtered_text(weak: &slint::Weak) -> Vec { + let Some(w) = weak.upgrade() else { + return Vec::new(); + }; + let st = w.global::(); + let level = st.get_filter_level().to_string().to_lowercase(); + let search = st.get_search().to_string().to_lowercase(); + + let snap = qbz_log::ring::snapshot(); + let filtered: Vec<&qbz_log::LogLine> = snap + .iter() + .filter(|line| line_matches(line, &level, &search)) + .collect(); + let start = filtered.len().saturating_sub(MAX_VIEW_ROWS); + filtered[start..] + .iter() + .map(|line| { + format!( + "{} {} {} {}", + line.format_ts(), + line.level_str(), + line.target, + qbz_log::redact(&line.message) + ) + }) + .collect() +} + +/// Flash `copied = true` for the standard window, then reset on a tokio timer. +fn flash_copied(weak: &slint::Weak, handle: &tokio::runtime::Handle) { + if let Some(w) = weak.upgrade() { + w.global::().set_copied(true); + } + let weak = weak.clone(); + handle.spawn(async move { + tokio::time::sleep(AUTO_TAIL_INTERVAL).await; + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_copied(false); + }); + }); +} + +/// Build the COMPLETE shareable diagnostics text used by both "Copy diagnostics +/// bundle" and "Upload (public)": the full diagnostics report (system + the LIVE +/// active audio device + graphics + playback + qconnect) followed by the last 200 +/// redacted log lines. This is what makes the uploaded paste complete rather than +/// "just logs". All log lines are already redacted at the ring's write choke +/// point; `qbz_log::redact` is applied again defensively. +async fn build_share_text(runtime: &Runtime) -> String { + let report = crate::diagnostics::build_full_report(runtime).await; + + let lines = qbz_log::ring::snapshot(); + let start = lines.len().saturating_sub(200); + let mut logs = String::new(); + for line in &lines[start..] { + logs.push_str(&format!( + "{} {} {} {}\n", + line.format_ts(), + line.level_str(), + line.target, + qbz_log::redact(&line.message) + )); + } + + format!("{report}\n\n## Recent logs\n\n```log\n{logs}```\n") +} diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index e62cbd451..69011aa46 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -30,6 +30,9 @@ mod blacklist_manager; mod booklet; mod commands; mod custom_artwork; +mod diagnostics; +mod log_viewer; +mod sleep_timer; pub use qbz_text_utils::{dates, strip_html}; mod discover_browse; mod discord_rpc; @@ -5177,7 +5180,10 @@ fn system_font_family() -> Option { } fn main() -> Result<(), Box> { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + // Composite logger: stderr (unchanged) + a bounded in-memory ring + an on-disk + // file, all redacted at the write choke point. Feeds the in-app log viewer and + // the diagnostics bundle. Honours RUST_LOG (default "info"). + qbz_log::install("info"); // Install the rustls process-level CryptoProvider ONCE, before ANY TLS use. // The full binary compiles BOTH rustls providers (aws-lc-rs via qbz-cast's @@ -5340,6 +5346,25 @@ fn main() -> Result<(), Box> { window .global::() .set_intelligent_search(crate::ui_prefs::load().intelligent_search); + // Appearance toggles that used to be live-only (no persistence): seed the + // live globals from the persisted prefs so the user's choice survives a + // restart. Their Rust handlers now persist via on_appearance_bool/select. + { + let prefs = crate::ui_prefs::load(); + let appearance = window.global::(); + appearance.set_window_title_show(prefs.window_title_show); + appearance.set_show_volume_steppers(prefs.show_volume_steppers); + appearance.set_sidebar_playlist_collage(prefs.sidebar_playlist_collage); + appearance.set_local_library_track_artwork(prefs.local_library_track_artwork); + appearance.set_in_app_toasts(prefs.in_app_toasts); + appearance.set_theme_filter(prefs.theme_filter); + window + .global::() + .set_playlist_collage(prefs.sidebar_playlist_collage); + window + .global::() + .set_enabled(prefs.in_app_toasts); + } // Purchases opt-in nav visibility + title-bar placement (both default OFF). // Seeded here from the persisted prefs so the sidebar / header entries // reflect the user's choice on startup (the Slint equivalent of Tauri's @@ -7546,6 +7571,31 @@ fn main() -> Result<(), Box> { prefs.nav_tb_purchases = value; crate::ui_prefs::save(&prefs); } + "window-title-show" => { + let mut prefs = crate::ui_prefs::load(); + prefs.window_title_show = value; + crate::ui_prefs::save(&prefs); + } + "show-volume-steppers" => { + let mut prefs = crate::ui_prefs::load(); + prefs.show_volume_steppers = value; + crate::ui_prefs::save(&prefs); + } + "sidebar-playlist-collage" => { + let mut prefs = crate::ui_prefs::load(); + prefs.sidebar_playlist_collage = value; + crate::ui_prefs::save(&prefs); + } + "local-library-track-artwork" => { + let mut prefs = crate::ui_prefs::load(); + prefs.local_library_track_artwork = value; + crate::ui_prefs::save(&prefs); + } + "in-app-toasts" => { + let mut prefs = crate::ui_prefs::load(); + prefs.in_app_toasts = value; + crate::ui_prefs::save(&prefs); + } other => log::debug!("[qbz-slint] unhandled appearance-bool '{other}'"), }); let theme_weak = window.as_weak(); @@ -7624,6 +7674,13 @@ fn main() -> Result<(), Box> { crate::theme::apply_theme(&w, id); } } + "theme-filter" => { + // Theme-list filter cycle (0 = All, 1 = Dark, 2 = Light). Cosmetic, + // but persist it so the list doesn't reset to All every launch. + let mut prefs = crate::ui_prefs::load(); + prefs.theme_filter = index; + crate::ui_prefs::save(&prefs); + } other => log::debug!("[qbz-slint] unhandled appearance-select '{other}'"), }); } @@ -10057,6 +10114,38 @@ fn main() -> Result<(), Box> { let c = controller.clone(); qs.on_toggle_infinite_play(move || c.toggle_infinite_play()); } + { + let c = controller.clone(); + qs.on_toggle_stop_after(move |id| c.toggle_stop_after(id.to_string())); + } + // Sleep timer (queue footer): a Rust-owned tokio task drives the countdown + // and pauses playback at the deadline. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_set(move |minutes| { + sleep_timer::set(runtime.clone(), weak.clone(), handle.clone(), minutes) + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_cancel(move || sleep_timer::cancel(weak.clone())); + } + // Developer panel: in-app log viewer + the full diagnostics panel. + log_viewer::install(&window, app_runtime.clone(), tokio_rt.handle().clone()); + diagnostics::install(&window, app_runtime.clone(), tokio_rt.handle().clone()); + // Report-an-issue: "Create issue report" opens the GitHub new-issue page. + window.global::().on_create_issue(|| { + let url = "https://github.com/vicrodh/qbz/issues/new?template=bug_report.yml"; + if let Err(e) = open::that(url) { + log::warn!("[qbz-slint] open GitHub issues failed: {e}"); + } + }); { let c = controller.clone(); let weak = window.as_weak(); @@ -12803,6 +12892,28 @@ fn main() -> Result<(), Box> { } }); } + // Recommendations-tab cache controls (unique to this tab). + { + let weak = window.as_weak(); + window + .global::() + .on_set_cache_ttl(move |index| { + if let Some(w) = weak.upgrade() { + discover_prefs::set_reco_cache_ttl_index(&w, index); + } + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_refresh_now(move || { + external_reco::force_reload(&runtime, &weak, &handle, &image_cache); + }); + } { let weak = window.as_weak(); window diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index 78058e9ba..a38bf8b39 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -3886,10 +3886,17 @@ pub fn start_poll_loop( // and hand them to `Player::play_next`. The // `gapless_requested_for` guard stops the 450ms ticker from // re-firing while the download is in flight. + // Stop-after guard (last condition, short-circuited): if the CURRENT + // track is marked "stop after this", suppress the gapless pre-queue so + // it ends naturally and the track-end handler can fire + // `consume_stop_after_if`. Mirrors the Tauri `setGaplessGetNextTrackId` + // null-return for the marked track. Without this the engine seamlessly + // hands off and the marker never fires. if event.gapless_ready && event.gapless_next_track_id == 0 && track_id != 0 && gapless_requested_for != track_id + && runtime.core().get_stop_after().await != Some(track_id) { let upcoming = runtime.core().peek_upcoming(1).await; if let Some(next) = upcoming.into_iter().next() { @@ -4075,6 +4082,29 @@ pub fn start_poll_loop( .await .map(|t| t.id) .unwrap_or(0); + // Stop-after-this-song: if the track that just ended is the marked + // one, HALT here (pause) — do NOT advance, do NOT infinite-refill. + // The queue stays intact and the finished track stays parked in + // now-playing. `consume_stop_after_if` is one-shot (clears the + // marker). Mirrors the Tauri end-of-track `consumeStopAfterIf` -> + // stopPlayback + early-return, ahead of any repeat/shuffle. + if ended_track_id != 0 + && runtime.core().consume_stop_after_if(ended_track_id).await + { + if let Err(e) = runtime.core().pause() { + log::warn!("[qbz-slint] stop-after: pause failed: {e}"); + } + last_track_id = 0; + was_playing = false; + seen_position = 0; + gapless_requested_for = 0; + refresh_sidebar(true); + let _ = weak.upgrade_in_event_loop(|w| { + let np = w.global::(); + np.set_playing(false); + }); + continue; + } last_track_id = 0; was_playing = false; seen_position = 0; diff --git a/crates/qbz/src/qconnect_service.rs b/crates/qbz/src/qconnect_service.rs index e31ead6df..0a4e5676d 100644 --- a/crates/qbz/src/qconnect_service.rs +++ b/crates/qbz/src/qconnect_service.rs @@ -328,6 +328,26 @@ fn build_reorder_payload( })) } +/// Read-only snapshot of the live QConnect session for the Diagnostics panel. +/// +/// Every field is a PURE read of state the running service already maintains +/// (lock -> read -> clone -> unlock). It never touches discovery, guard, or +/// transport logic — QConnect is control-fragile, so this only observes. +/// `last_error` is returned raw; the diagnostics controller redacts id-like +/// substrings before it reaches the UI/export. +#[derive(Default)] +pub struct QconnectDiagSnapshot { + pub running: bool, + pub transport_connected: bool, + pub has_endpoint: bool, + pub last_error: Option, + pub role: &'static str, + pub active_name: Option, + pub active_brand: Option, + pub active_model: Option, + pub renderer_count: usize, +} + impl SlintQconnectService { pub fn new(runtime: Runtime, window: slint::Weak) -> Self { let saved_name = load_persisted_device_name(); @@ -344,6 +364,76 @@ impl SlintQconnectService { self.inner.lock().await.runtime.is_some() } + /// Pure read of the live session for the Diagnostics panel (Settings > + /// Developer). Mirrors the Tauri `snapshotQconnect`: reads `inner.last_error`, + /// the transport-connected flag (`app.state_handle().transport_connected`), + /// and the session topology (renderers / active / local ids). Touches NO + /// guard or transport logic — lock, read, clone, unlock. When the service + /// isn't running it returns a default snapshot (role "none"). + pub async fn diagnostics_snapshot(&self) -> QconnectDiagSnapshot { + // Pull the app + sync-state handles (and the static fields) under a brief + // inner lock, then release it before awaiting the per-handle locks. + let (app, sync_state, last_error, has_endpoint, running) = { + let guard = self.inner.lock().await; + match guard.runtime.as_ref() { + Some(rt) => ( + Some(Arc::clone(&rt.app)), + Some(Arc::clone(&rt.sync_state)), + guard.last_error.clone(), + !rt.config.endpoint_url.is_empty(), + true, + ), + None => (None, None, guard.last_error.clone(), false, false), + } + }; + + let mut snap = QconnectDiagSnapshot { + running, + has_endpoint, + last_error, + role: "none", + ..Default::default() + }; + + let (Some(app), Some(sync_state)) = (app, sync_state) else { + return snap; + }; + + // Transport-connected mirror (same source the event sink reads). + snap.transport_connected = app.state_handle().lock().await.transport_connected; + + // Session topology: renderer count + the active renderer's identity. + let state = sync_state.lock().await; + let session = &state.session; + snap.renderer_count = session.renderers.len(); + let active_id = session.active_renderer_id; + let local_id = session.local_renderer_id; + if let Some(active_id) = active_id { + if let Some(renderer) = session + .renderers + .iter() + .find(|r| r.renderer_id == active_id) + { + snap.active_name = renderer.friendly_name.clone(); + snap.active_brand = renderer.brand.clone(); + snap.active_model = renderer.model.clone(); + } + // active == local -> we render locally; active != local -> we control + // a peer. Mirrors the Tauri role rule. + snap.role = if Some(active_id) == local_id { + "local-renderer" + } else { + "controller" + }; + } else if snap.running || snap.transport_connected { + snap.role = "observer"; + } else { + snap.role = "none"; + } + + snap + } + /// D5 (offline-MODE): force-disconnect on every transition INTO offline /// (induced or real), so an established session never outlives the offline /// gate. Spawned once at service init (main.rs, next to `init_service`). diff --git a/crates/qbz/src/queue.rs b/crates/qbz/src/queue.rs index f1e785dab..9c0f2257c 100644 --- a/crates/qbz/src/queue.rs +++ b/crates/qbz/src/queue.rs @@ -375,9 +375,18 @@ impl QueueController { } } + let stop_after_id: slint::SharedString = state + .stop_after_track_id + .map(|id| id.to_string()) + .unwrap_or_default() + .into(); + let weak = self.weak.clone(); let _ = weak.upgrade_in_event_loop(move |w| { let qs = w.global::(); + // Reflect the core's "stop after this song" marker so the .slint can + // render the CircleStop on the matching row (item.id == stop-after-id). + qs.set_stop_after_id(stop_after_id); // Snapshot prior decoded handles into ONE GLOBAL id -> artwork map // covering EVERY prior list (now-playing + upcoming + history + @@ -792,6 +801,26 @@ impl QueueController { }); } + /// Toggle the "stop after this song" marker on the queue track with `id` + /// (a decimal string, matching `QueueItem.id`). Idempotent — tapping the same + /// track clears it. The marker auto-clears on queue mutation inside the core, + /// and `refresh_async` reflects the current marker into `QueueState.stop-after-id`. + pub fn toggle_stop_after(&self, id: String) { + let Ok(track_id) = id.parse::() else { + return; + }; + let this = self.clone(); + self.handle.spawn(async move { + let already = this.runtime.core().get_stop_after().await == Some(track_id); + if already { + this.runtime.core().clear_stop_after().await; + } else { + this.runtime.core().set_stop_after(track_id).await; + } + this.refresh_async().await; + }); + } + /// Whether `InfiniteRadio` autoplay is currently on (the queue auto-refills /// with a smart radio when it runs out). Reads the same playback preference /// the toggle and the sidebar flag use. diff --git a/crates/qbz/src/sleep_timer.rs b/crates/qbz/src/sleep_timer.rs new file mode 100644 index 000000000..0e3ce11b9 --- /dev/null +++ b/crates/qbz/src/sleep_timer.rs @@ -0,0 +1,80 @@ +//! Sleep timer (queue footer). +//! +//! Frontend-only behaviour, 1:1 with the Tauri `sleepTimerStore`: a single armed +//! deadline that pauses playback when it elapses, plus a 1 Hz countdown. Ported to +//! a Rust-owned `tokio` task driving a monotonic `Instant` deadline (robust to +//! laptop suspend / clock changes, unlike the Tauri wall-clock `setTimeout`). +//! Nothing is persisted — an armed timer is lost on restart (deliberate). +//! +//! A process-wide generation counter invalidates an in-flight task on cancel or +//! re-arm, so there is never a stale timer that pauses playback unexpectedly. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use slint::ComponentHandle; + +use crate::adapter::SlintAdapter; +use crate::{AppWindow, SleepTimerState}; + +type Runtime = Arc>; + +/// Monotonically increasing token. Each `set`/`cancel` bumps it; a running task +/// keeps the value it was spawned with and exits as soon as it no longer matches. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +const MIN_MINUTES: i32 = 1; +const MAX_MINUTES: i32 = 24 * 60; // 1440 + +fn push_state(weak: &slint::Weak, active: bool, remaining: String) { + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_active(active); + st.set_remaining(remaining.into()); + }); +} + +/// Arm (or re-arm) the sleep timer for `minutes` (clamped to [1, 1440]). Replaces +/// any running timer. At expiry it pauses playback (if anything is playing) and +/// returns to idle. +pub fn set(runtime: Runtime, weak: slint::Weak, handle: tokio::runtime::Handle, minutes: i32) { + if minutes <= 0 { + return; + } + let minutes = minutes.clamp(MIN_MINUTES, MAX_MINUTES); + // Bump the generation; this task owns `my_gen` until the next set/cancel. + let my_gen = GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1); + + handle.spawn(async move { + let deadline = Instant::now() + Duration::from_secs((minutes as u64) * 60); + // Immediate feedback: armed + initial countdown. + push_state(&weak, true, qbz_text_utils::format_sleep_remaining((minutes as i64) * 60)); + + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + loop { + ticker.tick().await; // first tick fires immediately, then every 1s + if GENERATION.load(Ordering::SeqCst) != my_gen { + return; // cancelled or superseded + } + let now = Instant::now(); + if now >= deadline { + if runtime.core().get_playback_state().is_playing { + if let Err(e) = runtime.core().pause() { + log::warn!("[qbz-slint] sleep-timer: pause on expiry failed: {e}"); + } + } + push_state(&weak, false, String::new()); + return; + } + let remaining = (deadline - now).as_secs() as i64; + push_state(&weak, true, qbz_text_utils::format_sleep_remaining(remaining)); + } + }); +} + +/// Cancel any armed timer and return to idle. +pub fn cancel(weak: slint::Weak) { + GENERATION.fetch_add(1, Ordering::SeqCst); // invalidate the running task + push_state(&weak, false, String::new()); +} diff --git a/crates/qbz/src/ui_prefs.rs b/crates/qbz/src/ui_prefs.rs index 2cda8670c..42f4c97a8 100644 --- a/crates/qbz/src/ui_prefs.rs +++ b/crates/qbz/src/ui_prefs.rs @@ -240,6 +240,20 @@ pub struct UiPrefs { /// Whether intelligent search (cache, ranking, preview dropdown) is enabled. #[serde(default = "default_intelligent_search")] pub intelligent_search: bool, + /// Appearance toggles (persisted; the live Slint globals are seeded from + /// these at startup, so the user's choice survives a restart). + #[serde(default = "default_window_title_show")] + pub window_title_show: bool, + #[serde(default = "default_show_volume_steppers")] + pub show_volume_steppers: bool, + #[serde(default = "default_sidebar_playlist_collage")] + pub sidebar_playlist_collage: bool, + #[serde(default = "default_local_library_track_artwork")] + pub local_library_track_artwork: bool, + #[serde(default = "default_in_app_toasts")] + pub in_app_toasts: bool, + #[serde(default = "default_theme_filter")] + pub theme_filter: i32, /// Whether desktop "now playing" system notifications fire on track change. /// Default ON (the notify backend ships default-on; this is the user gate). #[serde(default = "default_system_notifications")] @@ -425,6 +439,25 @@ fn default_intelligent_search() -> bool { DEFAULT_INTELLIGENT_SEARCH } +fn default_window_title_show() -> bool { + false +} +fn default_show_volume_steppers() -> bool { + false +} +fn default_sidebar_playlist_collage() -> bool { + true +} +fn default_local_library_track_artwork() -> bool { + false +} +fn default_in_app_toasts() -> bool { + true +} +fn default_theme_filter() -> i32 { + 0 +} + fn default_immersive_search_action() -> String { DEFAULT_IMMERSIVE_SEARCH_ACTION.to_string() } @@ -450,6 +483,12 @@ impl Default for UiPrefs { large_spectrum_mode: default_large_spectrum_mode(), album_header_gradient: default_album_header_gradient(), intelligent_search: default_intelligent_search(), + window_title_show: default_window_title_show(), + show_volume_steppers: default_show_volume_steppers(), + sidebar_playlist_collage: default_sidebar_playlist_collage(), + local_library_track_artwork: default_local_library_track_artwork(), + in_app_toasts: default_in_app_toasts(), + theme_filter: default_theme_filter(), system_notifications: default_system_notifications(), musicbrainz_enabled: default_musicbrainz_enabled(), discord_rpc_enabled: false,