diff --git a/crates/qbz-slint/src/lyrics.rs b/crates/qbz-slint/src/lyrics.rs index 900ad9063..70df308f4 100644 --- a/crates/qbz-slint/src/lyrics.rs +++ b/crates/qbz-slint/src/lyrics.rs @@ -168,6 +168,80 @@ pub fn teardown() { } } +/// Join the current doc's lines with `\n` and copy them to the clipboard — +/// the flyout's "Copy lyrics" action (Tauri `copyLyrics`, +/// `LyricsControlsPopover.svelte:39-55`). The button is gated UI-side on +/// ready + lines>0; an empty doc is a silent no-op. The clipboard write is +/// fire-and-forget on a blocking thread (arboard), so the toast reports the +/// dispatch like Tauri reports its `writeText` success. +pub fn copy_current_lyrics(weak: &slint::Weak) { + let text = with_current_doc(|doc| { + doc.map(|d| { + d.lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") + }) + }) + .unwrap_or_default(); + if text.is_empty() { + return; + } + crate::share::copy_to_clipboard(text); + crate::toast::success_weak(weak, "Lyrics copied"); +} + +/// Refresh the Settings cache row from the REAL per-user lyrics.db (F1 +/// fix-forward: entries AND size from the bound per-user path, via +/// `qbz_lyrics` cache_stats). Pushes `cache-loaded=false` when the service +/// or cache is unavailable (the row then shows no stats line). +pub fn refresh_cache_stats(handle: &tokio::runtime::Handle, weak: slint::Weak) { + let Some(service) = SERVICE.get().cloned() else { + return; + }; + handle.spawn(async move { + let stats = service.cache_stats().await; + let _ = weak.upgrade_in_event_loop(move |w| { + let state = w.global::(); + match stats { + Ok(stats) => { + state.set_cache_entries_text(stats.entries.to_string().into()); + state.set_cache_size( + crate::offline_manager::human_size(stats.size_bytes).into(), + ); + state.set_cache_loaded(true); + } + Err(e) => { + log::warn!("[qbz-slint] lyrics cache stats failed: {e}"); + state.set_cache_loaded(false); + } + } + }); + }); +} + +/// Clear the per-user lyrics cache (Settings row action), then re-push the +/// stats. Mirrors Tauri's `v2_lyrics_clear_cache` flow with a result toast. +pub fn clear_cache(handle: &tokio::runtime::Handle, weak: slint::Weak) { + let Some(service) = SERVICE.get().cloned() else { + return; + }; + let handle_for_refresh = handle.clone(); + handle.spawn(async move { + match service.clear_cache().await { + Ok(()) => { + crate::toast::success_weak(&weak, "Lyrics cache cleared"); + refresh_cache_stats(&handle_for_refresh, weak); + } + Err(e) => { + log::error!("[qbz-slint] lyrics cache clear failed: {e}"); + crate::toast::error_weak(&weak, "Failed to clear lyrics cache"); + } + } + }); +} + /// Qobuz vs non-Qobuz from the queue track's source tag. `qobuz_download` /// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz /// primary applies to them too; local user files / ephemeral folders / Plex diff --git a/crates/qbz-slint/src/lyrics_prefs.rs b/crates/qbz-slint/src/lyrics_prefs.rs new file mode 100644 index 000000000..30f0c359c --- /dev/null +++ b/crates/qbz-slint/src/lyrics_prefs.rs @@ -0,0 +1,347 @@ +//! Per-user lyrics display-prefs persistence (S5). +//! +//! Mirrors Tauri's `lyricsDisplayStore` (per-user localStorage key +//! `qbz-lyrics-display`, `lyricsDisplayStore.ts:35`): the six display prefs +//! the controls flyout edits, persisted per user so different Qobuz accounts +//! keep independent settings. Field values use Tauri's exact strings +//! (`font: 'line-seed-jp'`, `fontSize: 'medium'`, ...) so the shape stays +//! diff-able against the Tauri store; defaults are Tauri's +//! (`lyricsDisplayStore.ts:37-44`) and loads are sanitized the same way +//! (`:64-85` — any unknown value falls back to its default, `activeColor` +//! must be `#RRGGBB` or empty). +//! +//! Storage follows the `myqbz_view_prefs.rs` template — one tiny per-user +//! JSON, synchronous best-effort IO: +//! +//! /qbz/users//lyrics_prefs.json +//! +//! Lifecycle: +//! - `init_for_user` binds the store on session activation (all paths run +//! through `init_shell_for_user`), then the caller seeds the UI via +//! [`apply_to_ui`] on the event loop. +//! - The flyout fires `LyricsState.prefs-changed()` after any mutation → +//! [`persist_from_ui`] reads the in-out props back and saves. +//! - `LyricsState.reset-prefs()` → [`reset`] re-seeds the defaults and +//! persists them (Tauri `resetLyricsDisplay`). + +use std::path::PathBuf; +use std::sync::{LazyLock, Mutex}; + +use serde::{Deserialize, Serialize}; +use slint::ComponentHandle; + +use crate::{AppWindow, LyricsState}; + +/// The active user id, set by `init_for_user`. `None` before login (the +/// store degrades to defaults). +static USER_ID: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// The persisted pref set — field names and value strings match Tauri's +/// `LyricsDisplayPrefs` (`lyricsDisplayStore.ts`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LyricsPrefs { + #[serde(default = "d_true")] + pub auto_follow: bool, + #[serde(default = "d_font")] + pub font: String, // system | line-seed-jp | montserrat | noto-sans | source-sans-3 + #[serde(default = "d_size")] + pub font_size: String, // small | medium | large | xl + #[serde(default = "d_dimming")] + pub dimming: String, // off | soft | strong + #[serde(default)] + pub active_color: String, // "#RRGGBB" or "" = theme accent + #[serde(default)] + pub uppercase: bool, +} + +fn d_true() -> bool { + true +} +fn d_font() -> String { + "system".to_string() +} +fn d_size() -> String { + "medium".to_string() +} +fn d_dimming() -> String { + "strong".to_string() +} + +impl Default for LyricsPrefs { + /// Tauri defaults (`lyricsDisplayStore.ts:37-44`). + fn default() -> Self { + Self { + auto_follow: true, + font: d_font(), + font_size: d_size(), + dimming: d_dimming(), + active_color: String::new(), + uppercase: false, + } + } +} + +const FONTS: [&str; 5] = ["system", "line-seed-jp", "montserrat", "noto-sans", "source-sans-3"]; +const SIZES: [&str; 4] = ["small", "medium", "large", "xl"]; +const DIMMINGS: [&str; 3] = ["off", "soft", "strong"]; + +/// `#RRGGBB` (or empty = theme) — the Tauri validator +/// (`lyricsDisplayStore.ts:46-52`). +fn valid_color(value: &str) -> bool { + value.is_empty() + || (value.len() == 7 + && value.starts_with('#') + && value[1..].chars().all(|c| c.is_ascii_hexdigit())) +} + +impl LyricsPrefs { + /// Clamp every field to its known value set (Tauri's sanitize-on-load, + /// `lyricsDisplayStore.ts:64-85`): unknown values fall back to defaults. + fn sanitized(mut self) -> Self { + if !FONTS.contains(&self.font.as_str()) { + self.font = d_font(); + } + if !SIZES.contains(&self.font_size.as_str()) { + self.font_size = d_size(); + } + if !DIMMINGS.contains(&self.dimming.as_str()) { + self.dimming = d_dimming(); + } + if !valid_color(&self.active_color) { + self.active_color.clear(); + } + self + } +} + +/// `/qbz/users//lyrics_prefs.json` for the active user. +fn store_path() -> Option { + let user_id = (*USER_ID.lock().ok()?)?; + Some( + dirs::data_dir()? + .join("qbz") + .join("users") + .join(user_id.to_string()) + .join("lyrics_prefs.json"), + ) +} + +/// Bind the store to `user_id` on session activation. +pub fn init_for_user(user_id: u64) { + if let Ok(mut guard) = USER_ID.lock() { + *guard = Some(user_id); + } +} + +/// Load the stored prefs, sanitized; missing/unreadable/unparseable file → +/// defaults. +pub fn load() -> LyricsPrefs { + let Some(path) = store_path() else { + return LyricsPrefs::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(LyricsPrefs::sanitized) + .unwrap_or_default(), + Err(_) => LyricsPrefs::default(), + } +} + +/// Persist the prefs (best-effort — failures are logged). +pub fn save(prefs: &LyricsPrefs) { + let Some(path) = store_path() else { + log::warn!("[qbz-slint] lyrics prefs: no active user, not saving"); + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::error!("[qbz-slint] lyrics prefs: create dir failed: {e}"); + return; + } + } + match serde_json::to_vec_pretty(prefs) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::error!("[qbz-slint] lyrics prefs: write failed: {e}"); + } + } + Err(e) => log::error!("[qbz-slint] lyrics prefs: serialize failed: {e}"), + } +} + +// ---- LyricsState <-> LyricsPrefs mapping (UI thread only) ------------------ + +fn font_index(font: &str) -> i32 { + FONTS.iter().position(|f| *f == font).unwrap_or(0) as i32 +} + +fn size_index(size: &str) -> i32 { + SIZES.iter().position(|s| *s == size).unwrap_or(1) as i32 +} + +fn dimming_index(dimming: &str) -> i32 { + DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32 +} + +fn parse_color(hex: &str) -> Option { + if hex.len() != 7 || !hex.starts_with('#') { + return None; + } + let r = u8::from_str_radix(&hex[1..3], 16).ok()?; + let g = u8::from_str_radix(&hex[3..5], 16).ok()?; + let b = u8::from_str_radix(&hex[5..7], 16).ok()?; + Some(slint::Color::from_rgb_u8(r, g, b)) +} + +fn format_color(color: slint::Color) -> String { + format!("#{:02x}{:02x}{:02x}", color.red(), color.green(), color.blue()) +} + +/// Seed `LyricsState` from a loaded pref set. UI thread only. +pub fn apply_to_ui(window: &AppWindow, prefs: &LyricsPrefs) { + let state = window.global::(); + state.set_auto_follow(prefs.auto_follow); + state.set_font_index(font_index(&prefs.font)); + state.set_size_index(size_index(&prefs.font_size)); + state.set_dimming_mode(dimming_index(&prefs.dimming)); + state.set_uppercase(prefs.uppercase); + match parse_color(&prefs.active_color) { + Some(color) => { + state.set_use_custom_color(true); + state.set_custom_color(color); + } + None => state.set_use_custom_color(false), + } +} + +/// Read the in-out props back into a pref set and persist — the +/// `prefs-changed()` handler. UI thread only. +pub fn persist_from_ui(window: &AppWindow) { + let state = window.global::(); + let prefs = LyricsPrefs { + auto_follow: state.get_auto_follow(), + font: FONTS + .get(state.get_font_index().max(0) as usize) + .copied() + .unwrap_or("system") + .to_string(), + font_size: SIZES + .get(state.get_size_index().max(0) as usize) + .copied() + .unwrap_or("medium") + .to_string(), + dimming: DIMMINGS + .get(state.get_dimming_mode().max(0) as usize) + .copied() + .unwrap_or("strong") + .to_string(), + active_color: if state.get_use_custom_color() { + format_color(state.get_custom_color()) + } else { + String::new() + }, + uppercase: state.get_uppercase(), + }; + save(&prefs); +} + +/// Restore + persist the Tauri defaults — the `reset-prefs()` handler +/// (Tauri `resetLyricsDisplay`). UI thread only. +pub fn reset(window: &AppWindow) { + let prefs = LyricsPrefs::default(); + apply_to_ui(window, &prefs); + save(&prefs); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_tauri() { + let p = LyricsPrefs::default(); + assert!(p.auto_follow); + assert_eq!(p.font, "system"); + assert_eq!(p.font_size, "medium"); + assert_eq!(p.dimming, "strong"); + assert_eq!(p.active_color, ""); + assert!(!p.uppercase); + } + + #[test] + fn empty_json_deserializes_to_defaults() { + let p: LyricsPrefs = serde_json::from_str("{}").expect("empty object deserializes"); + assert_eq!(p, LyricsPrefs::default()); + } + + #[test] + fn sanitize_clamps_unknown_values() { + let p = LyricsPrefs { + auto_follow: false, + font: "comic-sans".into(), + font_size: "huge".into(), + dimming: "max".into(), + active_color: "purple".into(), + uppercase: true, + } + .sanitized(); + assert!(!p.auto_follow); // bools pass through + assert!(p.uppercase); + assert_eq!(p.font, "system"); + assert_eq!(p.font_size, "medium"); + assert_eq!(p.dimming, "strong"); + assert_eq!(p.active_color, ""); + } + + #[test] + fn sanitize_keeps_valid_values() { + let p = LyricsPrefs { + auto_follow: false, + font: "line-seed-jp".into(), + font_size: "xl".into(), + dimming: "off".into(), + active_color: "#8b5cf6".into(), + uppercase: false, + } + .sanitized(); + assert_eq!(p.font, "line-seed-jp"); + assert_eq!(p.font_size, "xl"); + assert_eq!(p.dimming, "off"); + assert_eq!(p.active_color, "#8b5cf6"); + } + + #[test] + fn color_validation() { + assert!(valid_color("")); + assert!(valid_color("#8b5cf6")); + assert!(valid_color("#FFFFFF")); + assert!(!valid_color("#fff")); + assert!(!valid_color("8b5cf6")); + assert!(!valid_color("#8b5cg6")); + } + + #[test] + fn index_round_trips() { + for (i, f) in FONTS.iter().enumerate() { + assert_eq!(font_index(f), i as i32); + } + for (i, s) in SIZES.iter().enumerate() { + assert_eq!(size_index(s), i as i32); + } + for (i, d) in DIMMINGS.iter().enumerate() { + assert_eq!(dimming_index(d), i as i32); + } + // Unknowns fall back to the defaults' indices. + assert_eq!(font_index("nope"), 0); + assert_eq!(size_index("nope"), 1); + assert_eq!(dimming_index("nope"), 2); + } + + #[test] + fn color_round_trip() { + let c = parse_color("#8b5cf6").expect("parses"); + assert_eq!(format_color(c), "#8b5cf6"); + assert!(parse_color("").is_none()); + assert!(parse_color("#xyzxyz").is_none()); + } +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index b6c3d83bb..fc5a9b636 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -63,6 +63,7 @@ mod local_library; mod local_playlist; mod local_library_settings; mod lyrics; +mod lyrics_prefs; mod lyrics_sync; mod media_controls; mod locallibrary_prefs; @@ -158,6 +159,18 @@ fn init_shell_for_user( // open, cleared on delete (spec 12 §18). myqbz_view_prefs::init_for_user(user_id); + // Bind the lyrics display prefs (auto-follow / font / size / dimming / + // active color / uppercase — per-user lyrics_prefs.json) and seed them + // into LyricsState so the sidebar + controls flyout reflect the + // persisted values from the first open (defaults = Tauri's). + lyrics_prefs::init_for_user(user_id); + { + let prefs = lyrics_prefs::load(); + let _ = weak.upgrade_in_event_loop(move |w| { + lyrics_prefs::apply_to_ui(&w, &prefs); + }); + } + // Create the system tray from this user's persisted settings (gated by // enable_tray). Reflects the chosen icon variant. On Linux the ksni // service runs on its own thread; macOS/Windows are no-ops until the @@ -6427,6 +6440,50 @@ fn main() -> Result<(), Box> { }); } + // S5 lyrics controls flyout + prefs + settings cache row. + { + // Persist any flyout mutation (the flyout writes the in-out props + // directly for live preview, then fires prefs-changed). + let weak = window.as_weak(); + window.global::().on_prefs_changed(move || { + if let Some(w) = weak.upgrade() { + lyrics_prefs::persist_from_ui(&w); + } + }); + } + { + // Reset to the Tauri defaults + persist (flyout footer). + let weak = window.as_weak(); + window.global::().on_reset_prefs(move || { + if let Some(w) = weak.upgrade() { + lyrics_prefs::reset(&w); + } + }); + } + { + // Copy the current lyrics to the clipboard (flyout footer). + let weak = window.as_weak(); + window.global::().on_copy_lyrics(move || { + lyrics::copy_current_lyrics(&weak); + }); + } + { + // Settings > Offline lyrics-cache row: stats refresh on section + // mount + clear action (F1: stats from the real per-user DB). + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_cache_refresh(move || { + lyrics::refresh_cache_stats(&handle, weak.clone()); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_cache_clear(move || { + lyrics::clear_cache(&handle, weak.clone()); + }); + } + // S4 lyrics sync engine — a UI-thread `slint::Timer` driving // `LyricsState.active-index` / `line-progress` at ~30Hz while the panel // is open + the doc is synced + playback is live (idle gate polling diff --git a/crates/qbz-slint/src/offline_manager.rs b/crates/qbz-slint/src/offline_manager.rs index b5dbf19ad..93c67b240 100644 --- a/crates/qbz-slint/src/offline_manager.rs +++ b/crates/qbz-slint/src/offline_manager.rs @@ -48,7 +48,9 @@ fn current_filters() -> Filters { // --- Formatting --------------------------------------------------------- -fn human_size(bytes: u64) -> String { +// pub(crate): also formats the lyrics-cache size in the Settings row +// (crate::lyrics::refresh_cache_stats). +pub(crate) fn human_size(bytes: u64) -> String { let b = bytes as f64; if bytes >= GB { format!("{:.1} GB", b / GB as f64) diff --git a/crates/qbz-slint/ui/settings/OfflineSettings.slint b/crates/qbz-slint/ui/settings/OfflineSettings.slint index 6dc19cc29..a7310a387 100644 --- a/crates/qbz-slint/ui/settings/OfflineSettings.slint +++ b/crates/qbz-slint/ui/settings/OfflineSettings.slint @@ -19,7 +19,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Palette } from "../foundation/palette.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { OfflineManagerActions, OfflineModeActions, OfflineState, SettingsState } from "../state.slint"; +import { LyricsState, OfflineManagerActions, OfflineModeActions, OfflineState, SettingsState } from "../state.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; import { SettingRow } from "SettingRow.slint"; @@ -74,9 +74,11 @@ export component OfflineSettings inherits VerticalLayout { spacing: 4px; // Re-seed the toggle states from the engine store on every mount of - // this section (same lazy-load hook LocalLibrarySettings uses). + // this section (same lazy-load hook LocalLibrarySettings uses); also + // refresh the lyrics-cache stats for the LYRICS group below. init => { OfflineModeActions.load(); + LyricsState.cache-refresh(); } GroupHeader { text: @tr("OFFLINE MODE"); } @@ -162,4 +164,27 @@ export component OfflineSettings inherits VerticalLayout { } } } + + Rectangle { height: 12px; } + Divider { } + Rectangle { height: 12px; } + + // Lyrics cache (Tauri Settings > Storage row, SettingsView.svelte:6539- + // 6562). Stats come from the REAL per-user lyrics.db via qbz_lyrics + // cache_stats — fix-forward F1 (Tauri measured a stale global path). + GroupHeader { text: @tr("LYRICS"); } + + SettingRow { + label: @tr("Clear lyrics cache"); + description: LyricsState.cache-loaded + ? @tr("{} entries using {}", LyricsState.cache-entries-text, LyricsState.cache-size) + : ""; + SecondaryButton { + label: @tr("Clear"); + danger: true; + clicked => { + LyricsState.cache-clear(); + } + } + } } diff --git a/crates/qbz-slint/ui/shell/LyricsControlsFlyout.slint b/crates/qbz-slint/ui/shell/LyricsControlsFlyout.slint new file mode 100644 index 000000000..2d58a77c7 --- /dev/null +++ b/crates/qbz-slint/ui/shell/LyricsControlsFlyout.slint @@ -0,0 +1,216 @@ +// Lyrics display-settings flyout panel — 1:1 to Tauri's +// `LyricsControlsPopover.svelte` (268px popover below-right of the sliders +// trigger), ADAPTED to the Slint flyout idiom (alignment standard "Modals & +// flyouts": padding 14px / spacing 12px, toggle rows = label +// `horizontal-stretch: 1` + control trailing, footer actions right-aligned +// `alignment: end` with the shared auto-width SecondaryButton; selects are +// `QbzSelect` with explicit menu-width — never pill option buttons, ADR-008, +// so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects). +// +// Row order is Tauri's exactly: Auto-follow / Font / Size / Active color / +// Uppercase / Dimming / footer (Copy lyrics + Reset). +// +// Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint +// has no native color-picker input); the "Theme" reset link shows only while +// a custom color is set (exact Tauri chip semantics). Q6 RESOLVED SHIPPED — +// the font row is included: the four non-system families are bundled TTFs +// embedded at compile time (see LyricsLinesView.slint). +// +// This component is the popup CONTENT; the PopupWindow shell (explicit +// height + close-policy, the documented footgun) lives at the anchor site in +// LyricsSidebar. Every mutation writes the LyricsState in-out pref directly +// (live preview) and fires prefs-changed() so Rust persists. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Radius } from "../foundation/radius.slint"; +import { QbzToggle } from "../primitives/QbzToggle.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; +import { QbzTextLink } from "../primitives/QbzTextLink.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; +import { LyricsState } from "../state.slint"; + +// One selectable color swatch (D6 palette). Ring-highlighted while it is the +// active custom color. +component ColorSwatch inherits Rectangle { + in property swatch; + property selected: + LyricsState.use-custom-color && LyricsState.custom-color == root.swatch; + + width: 18px; + height: 18px; + border-radius: 9px; + background: root.swatch; + border-width: root.selected ? 2px : (ta.has-hover ? 1px : 0px); + border-color: root.selected ? Theme.text-primary : Theme.border-muted; + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + LyricsState.use-custom-color = true; + LyricsState.custom-color = root.swatch; + LyricsState.prefs-changed(); + } + } +} + +component RowLabel inherits Text { + color: Theme.text-secondary; + font-size: 12px; + vertical-alignment: center; + horizontal-stretch: 1; +} + +export component LyricsControlsPanel inherits Rectangle { + // Copy gate — Tauri canCopy: loaded && !error && lines > 0 + // (LyricsSidebar.svelte:35). + property can-copy: + LyricsState.status == 2 && LyricsState.lines.length > 0; + + background: Theme.surface-main; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-muted; + drop-shadow-blur: 24px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 6px; + + VerticalLayout { + padding: 14px; + spacing: 12px; + + // Auto-follow. + HorizontalLayout { + height: 22px; + spacing: 8px; + RowLabel { text: @tr("Auto-follow"); } + QbzToggle { + checked: LyricsState.auto-follow; + toggled(v) => { + LyricsState.auto-follow = v; + LyricsState.prefs-changed(); + } + } + } + + // Font (Q6 shipped — embedded families). + HorizontalLayout { + height: 30px; + spacing: 8px; + RowLabel { text: @tr("Font"); } + QbzSelect { + sm: true; + menu-width: 178px; + options: [ + @tr("System"), + @tr("LINE Seed JP"), + @tr("Montserrat"), + @tr("Noto Sans"), + @tr("Source Sans 3"), + ]; + current-index: LyricsState.font-index; + selected(i) => { + LyricsState.font-index = i; + LyricsState.prefs-changed(); + } + } + } + + // Size (Tauri segmented S/M/L/XL -> select, flyout idiom). + HorizontalLayout { + height: 30px; + spacing: 8px; + RowLabel { text: @tr("Size"); } + QbzSelect { + sm: true; + menu-width: 120px; + options: [@tr("S"), @tr("M"), @tr("L"), @tr("XL")]; + current-index: LyricsState.size-index; + selected(i) => { + LyricsState.size-index = i; + LyricsState.prefs-changed(); + } + } + } + + // Active color — label (+ "Theme" reset while custom), swatch palette + // underneath, right-aligned on the control grid (D6). + VerticalLayout { + height: 44px; + spacing: 8px; + HorizontalLayout { + spacing: 8px; + RowLabel { text: @tr("Active color"); } + if LyricsState.use-custom-color: QbzTextLink { + label: @tr("Theme"); + clicked => { + LyricsState.use-custom-color = false; + LyricsState.prefs-changed(); + } + } + } + HorizontalLayout { + alignment: end; + spacing: 6px; + ColorSwatch { swatch: #8b5cf6; } + ColorSwatch { swatch: #ec4899; } + ColorSwatch { swatch: #ef4444; } + ColorSwatch { swatch: #f97316; } + ColorSwatch { swatch: #eab308; } + ColorSwatch { swatch: #22c55e; } + ColorSwatch { swatch: #06b6d4; } + ColorSwatch { swatch: #3b82f6; } + } + } + + // Uppercase. + HorizontalLayout { + height: 22px; + spacing: 8px; + RowLabel { text: @tr("Uppercase"); } + QbzToggle { + checked: LyricsState.uppercase; + toggled(v) => { + LyricsState.uppercase = v; + LyricsState.prefs-changed(); + } + } + } + + // Dimming (Tauri segmented Off/Soft/Strong -> select). + HorizontalLayout { + height: 30px; + spacing: 8px; + RowLabel { text: @tr("Dimming"); } + QbzSelect { + sm: true; + menu-width: 130px; + options: [@tr("Off"), @tr("Soft"), @tr("Strong")]; + current-index: LyricsState.dimming-mode; + selected(i) => { + LyricsState.dimming-mode = i; + LyricsState.prefs-changed(); + } + } + } + + // Footer: Copy lyrics (gated) + Reset, right-aligned (standard). + HorizontalLayout { + height: 34px; + alignment: end; + spacing: 8px; + SecondaryButton { + label: @tr("Copy lyrics"); + enabled: root.can-copy; + clicked => { + LyricsState.copy-lyrics(); + } + } + SecondaryButton { + label: @tr("Reset"); + clicked => { + LyricsState.reset-prefs(); + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/LyricsLinesView.slint b/crates/qbz-slint/ui/shell/LyricsLinesView.slint index 23d3380f1..26f45eca0 100644 --- a/crates/qbz-slint/ui/shell/LyricsLinesView.slint +++ b/crates/qbz-slint/ui/shell/LyricsLinesView.slint @@ -33,6 +33,21 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { LyricsState } from "../state.slint"; +// Q6 (SHIPPED): Tauri's four non-system lyric font families, embedded into +// the binary at compile time (slint-build default EmbedAllResources for Rust +// output -> RegisterCustomFontByMemory at window setup — the same mechanism +// as the app-wide Inter imports in app.slint). Family names inside the TTFs +// match Tauri's CSS stacks exactly: "LINE Seed JP" / "Montserrat" / +// "Noto Sans" / "Source Sans 3" (verified via fc-scan). The variable-font +// weight axis IS applied at raster time (femtovg's swash scaler receives the +// fontique normalized coords), so Montserrat's Thin-default fvar is harmless. +// "System" = empty font-family -> the window default (Inter), 1:1 with +// Tauri's 'system' stack. +import "../../../../static/fonts/LINESeedJP-Regular.ttf"; +import "../../../../static/fonts/Montserrat-VariableFont_wght.ttf"; +import "../../../../static/fonts/NotoSans-VariableFont_wdth,wght.ttf"; +import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf"; + // One lyric line. FOOTGUN (memory `reference_slint_root_opacity_absolute_position`): // the dimming ladder is applied to the INNER Text (opacity + color), NEVER as // an opacity binding on a row root — a root-opacity binding wraps the row in @@ -44,8 +59,18 @@ component LyricsLineRow inherits VerticalLayout { in property line-text; in property size-inactive; in property size-active; - // 0 = off, 1 = soft, 2 = strong (Tauri default) — S5 wires the pref. + // 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5). in property dimming-mode; + // S5 display prefs: uppercase transform, font family ("" = window + // default), karaoke fill / active color (theme accent or D6 swatch). + in property uppercase; + in property font-name; + in property active-color; + + // Tauri renders uppercase via CSS text-transform; Slint transforms the + // string (display only — the engine still matches on the raw text). + property display-text: + root.uppercase ? root.line-text.to-uppercase() : root.line-text; // Active-row geometry for the view's auto-center scroll. y/height are // LAYOUT-relative (self.y within the body VerticalLayout) so the @@ -103,7 +128,7 @@ component LyricsLineRow inherits VerticalLayout { } base := Text { - text: root.line-text; + text: root.display-text; wrap: word-wrap; opacity: root.line-opacity(); // Active base = the DIM (unsung) layer under the karaoke overlay @@ -111,6 +136,7 @@ component LyricsLineRow inherits VerticalLayout { color: root.is-active ? Theme.text-secondary : (root.is-past ? Theme.text-muted : Theme.text-secondary); + font-family: root.font-name; font-size: root.is-active ? root.size-active : root.size-inactive; font-weight: root.is-active ? Typography.bold : Typography.medium; // Friendly letter-spacing (Q3): slightly open, never cramped. @@ -134,9 +160,10 @@ component LyricsLineRow inherits VerticalLayout { y: 0; width: base.width; height: base.height; - text: root.line-text; + text: root.display-text; wrap: word-wrap; - color: Theme.accent; + color: root.active-color; + font-family: root.font-name; font-size: root.size-active; font-weight: Typography.bold; letter-spacing: 0.2px; @@ -150,14 +177,20 @@ export component LyricsLinesView inherits Rectangle { // surface later overrides padding/gap without touching display prefs. in property side-padding: 6px; in property line-gap: 12px; // Tauri compact gap parity - // 280px-adapted type scale (Q3). + // 280px-adapted type scale (Q3). The sidebar maps the persisted S/M/L/XL + // pref onto these; the miniplayer surface later keeps the defaults + // (Tauri plumbs NO display prefs there, spec §3.6). in property size-inactive: 13px; in property size-active: 15px; in property dimming-mode: 2; // strong (Tauri default) in property show-attribution: true; - // Auto-center the active line (S5 wires the persisted pref; Tauri - // default true, lyricsDisplayStore.ts:37-44). + // Auto-center the active line (persisted pref; Tauri default true, + // lyricsDisplayStore.ts:37-44). in property auto-follow: true; + // S5 display prefs (defaults = Tauri's). + in property uppercase: false; + in property font-name: ""; // "" = window default font + in property active-color: Theme.accent; // ---- Auto-center scroll state ---- // Last index the view centered on; -1 = nothing yet -> next center is @@ -243,6 +276,9 @@ export component LyricsLinesView inherits Rectangle { size-inactive: root.size-inactive; size-active: root.size-active; dimming-mode: root.dimming-mode; + uppercase: root.uppercase; + font-name: root.font-name; + active-color: root.active-color; report-active-geometry(report-idx, row-y, row-h) => { root.center-active(report-idx, row-y, row-h); } diff --git a/crates/qbz-slint/ui/shell/LyricsSidebar.slint b/crates/qbz-slint/ui/shell/LyricsSidebar.slint index be4c6108e..90502716e 100644 --- a/crates/qbz-slint/ui/shell/LyricsSidebar.slint +++ b/crates/qbz-slint/ui/shell/LyricsSidebar.slint @@ -7,18 +7,22 @@ // // 1:1 to the Tauri `LyricsSidebar.svelte` header/states (spec §3.1): 36px // mic icon tile + LYRICS overline + "{title} - {artist}" meta + the -// display-settings trigger (S5 wires the LyricsControlsFlyout), plus the +// display-settings trigger opening the LyricsControlsFlyout (S5), plus the // Slint-convention close button (deviation D4, homologated with Queue). // Body renders by LyricsState.status: loading / no-lyrics / offline-miss -// (typed NotAvailableOffline, F3) / error / the shared lines view. +// (typed NotAvailableOffline, F3) / error / the shared lines view, which +// receives the persisted display prefs (size scale / dimming / uppercase / +// font / active color / auto-follow) from LyricsState. import { Theme } from "../foundation/semantic-colors.slint"; 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 { IconButton } from "../primitives/BarControls.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LyricsLinesView } from "LyricsLinesView.slint"; +import { LyricsControlsPanel } from "LyricsControlsFlyout.slint"; import { ShellState, LyricsState } from "../state.slint"; component CloseButton inherits Rectangle { @@ -45,33 +49,6 @@ component CloseButton inherits Rectangle { } } -// 32px icon button — the display-settings trigger (Tauri SlidersHorizontal, -// LyricsSidebar.svelte:38-66). -component HeaderIconButton inherits Rectangle { - in property icon; - callback clicked(); - width: 32px; - height: 32px; - border-radius: Radius.sm; - background: ta.has-hover ? Theme.surface-hover : transparent; - - QbzIcon { - source: root.icon; - width: 16px; - height: 16px; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary; - } - - ta := TouchArea { - mouse-cursor: pointer; - clicked => { - root.clicked(); - } - } -} - export component LyricsSidebar inherits Rectangle { // Width is owned by the right-panel column in AppShell; this fills it and // vertical-stretches to its share of the height (stacked under the queue). @@ -142,11 +119,38 @@ export component LyricsSidebar inherits Rectangle { } } - // Display-settings trigger — the LyricsControlsFlyout lands in S5. + // Display-settings trigger + the controls flyout. IconButton + // (PlayerBar primitive) for the shared tooltip channel — Tauri's + // aria/tooltip 'Display settings'. PopupWindow footgun handled: + // explicit width/height + close-policy; right edge aligned with + // the close button's right edge (Tauri: right: 8px), 8px below + // the trigger (Tauri: top calc(100% + 8px)). VerticalLayout { alignment: center; - HeaderIconButton { - icon: @image-url("../assets/icons/sliders-horizontal.svg"); + Rectangle { + width: 32px; + height: 32px; + IconButton { + icon: @image-url("../assets/icons/sliders-horizontal.svg"); + icon-size: 16px; + btn-width: 32px; + tooltip: @tr("Display settings"); + clicked => { + controls-flyout.show(); + } + } + controls-flyout := PopupWindow { + // Right-align with the close button right edge: + // trigger 32px + spacing 8px + close 28px - width. + x: 32px + 8px + 28px - 260px; + y: 32px + 8px; + width: 260px; + // padding 28 + rows (22+30+30+44+22+30+34 = 212) + + // spacing 12 x 6 = 72. + height: 312px; + close-policy: PopupClosePolicy.close-on-click-outside; + LyricsControlsPanel { } + } } } VerticalLayout { @@ -230,10 +234,35 @@ export component LyricsSidebar inherits Rectangle { } } - // Lyrics body — the shared lines renderer. + // Lyrics body — the shared lines renderer, fed the persisted + // display prefs. Size matrix = the 280px-adapted scale (Q3): + // the S3 13/15px values are the 'medium' tier, with Tauri's + // relative steps (compact matrix, LyricsLines.svelte:769-792) + // applied around it: S 11/13 · M 13/15 · L 16/19 · XL 20/24. if LyricsState.status == 2 && LyricsState.lines.length > 0: LyricsLinesView { width: 100%; height: 100%; + size-inactive: LyricsState.size-index == 0 ? 11px + : LyricsState.size-index == 2 ? 16px + : LyricsState.size-index == 3 ? 20px + : 13px; + size-active: LyricsState.size-index == 0 ? 13px + : LyricsState.size-index == 2 ? 19px + : LyricsState.size-index == 3 ? 24px + : 15px; + dimming-mode: LyricsState.dimming-mode; + auto-follow: LyricsState.auto-follow; + uppercase: LyricsState.uppercase; + // Family names as registered by the embedded TTFs; "" = + // window default = Tauri's 'system' option. + font-name: LyricsState.font-index == 1 ? "LINE Seed JP" + : LyricsState.font-index == 2 ? "Montserrat" + : LyricsState.font-index == 3 ? "Noto Sans" + : LyricsState.font-index == 4 ? "Source Sans 3" + : ""; + active-color: LyricsState.use-custom-color + ? LyricsState.custom-color + : Theme.accent; } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 007dd8e24..bb3085aed 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -2901,6 +2901,44 @@ export global LyricsState { // Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests // lyrics for the current track (cache-served when already fetched). callback panel-opened(); + + // ---- Display prefs (S5) ------------------------------------------------- + // 1:1 to Tauri's lyricsDisplayStore (defaults lyricsDisplayStore.ts:37-44), + // persisted per-user as JSON by `crate::lyrics_prefs` and seeded on every + // session activation. The flyout mutates these in-out props directly and + // fires `prefs-changed()`; Rust reads them back and persists. + in-out property auto-follow: true; + // 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3 + // (Q6 SHIPPED: the four families are bundled TTFs embedded at compile time + // in LyricsLinesView.slint; "System" = empty font-family -> window default). + in-out property font-index: 0; + // 0 S · 1 M · 2 L · 3 XL — Tauri default 'medium'. Px mapping (the + // 280px-adapted scale, Q3) lives in LyricsSidebar. + in-out property size-index: 1; + // 0 off · 1 soft · 2 strong (Tauri default 'strong'). + in-out property dimming-mode: 2; + in-out property uppercase: false; + // Active (karaoke fill) color. `use-custom-color: false` = follow the theme + // accent (Tauri activeColor: ''). The palette swatches set both (deviation + // D6: swatches instead of a native color input — Slint has none). + in-out property use-custom-color: false; + in-out property custom-color: #8b5cf6; + // Fired by the flyout after ANY pref mutation -> Rust persists the set. + callback prefs-changed(); + // Footer actions: join current lines -> clipboard (+ toast) / restore the + // Tauri defaults (+ persist + re-seed). + callback copy-lyrics(); + callback reset-prefs(); + + // ---- Lyrics cache row (Settings > Offline, S5) -------------------------- + // Stats read from the REAL per-user lyrics.db via qbz_lyrics cache_stats + // (fix-forward F1 — Tauri measured a stale global path). Pre-formatted in + // Rust; `cache-loaded` gates the description line. + in property cache-loaded: false; + in property cache-entries-text; + in property cache-size; + callback cache-refresh(); + callback cache-clear(); } // One row in the QConnect device picker (a session renderer). Populated by the