feat(slint): lyrics controls flyout, per-user prefs, cache settings row
Sidebar header gains the controls flyout (auto-follow, font size on the 280px-adapted scale, dimming, active color, uppercase, copy lyrics, reset). Every setting persists per user via the new lyrics_prefs store (loaded on all session activation paths, applied into LyricsState, Tauri defaults). Settings gains the lyrics cache row with honest per-user stats and clear (the F1 fix shipped in qbz-lyrics). Parity and @tr sweep over the sidebar surfaces per the review inventory. Closes the S1-S5 implementation run of the Lyrics epic: Qobuz-first word-synced lyrics (real per-word timestamps - richer than Tauri's line-level estimation), LRCLIB/lyrics.ovh fallback, shared per-user cache with additive wsync column, 30Hz sync engine with word-anchored karaoke fill, auto-center scroll, and QConnect peer-following. cc
This commit is contained in:
9 files changed
+867
-43
No files matched your search
@@ -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<AppWindow>) {
|
||||||
|
let text = with_current_doc(|doc| {
|
||||||
|
doc.map(|d| {
|
||||||
|
d.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<AppWindow>) {
|
||||||
|
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::<LyricsState>();
|
||||||
|
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<AppWindow>) {
|
||||||
|
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`
|
/// 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
|
/// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz
|
||||||
/// primary applies to them too; local user files / ephemeral folders / Plex
|
/// primary applies to them too; local user files / ephemeral folders / Plex
|
||||||
|
|||||||
@@ -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:
|
||||||
|
//!
|
||||||
|
//! <data_dir>/qbz/users/<user_id>/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<Mutex<Option<u64>>> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/qbz/users/<user_id>/lyrics_prefs.json` for the active user.
|
||||||
|
fn store_path() -> Option<PathBuf> {
|
||||||
|
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::<LyricsPrefs>(&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<slint::Color> {
|
||||||
|
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::<LyricsState>();
|
||||||
|
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::<LyricsState>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ mod local_library;
|
|||||||
mod local_playlist;
|
mod local_playlist;
|
||||||
mod local_library_settings;
|
mod local_library_settings;
|
||||||
mod lyrics;
|
mod lyrics;
|
||||||
|
mod lyrics_prefs;
|
||||||
mod lyrics_sync;
|
mod lyrics_sync;
|
||||||
mod media_controls;
|
mod media_controls;
|
||||||
mod locallibrary_prefs;
|
mod locallibrary_prefs;
|
||||||
@@ -158,6 +159,18 @@ fn init_shell_for_user(
|
|||||||
// open, cleared on delete (spec 12 §18).
|
// open, cleared on delete (spec 12 §18).
|
||||||
myqbz_view_prefs::init_for_user(user_id);
|
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
|
// Create the system tray from this user's persisted settings (gated by
|
||||||
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
||||||
// service runs on its own thread; macOS/Windows are no-ops until the
|
// service runs on its own thread; macOS/Windows are no-ops until the
|
||||||
@@ -6427,6 +6440,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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::<LyricsState>().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::<LyricsState>().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::<LyricsState>().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::<LyricsState>().on_cache_refresh(move || {
|
||||||
|
lyrics::refresh_cache_stats(&handle, weak.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window.global::<LyricsState>().on_cache_clear(move || {
|
||||||
|
lyrics::clear_cache(&handle, weak.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// S4 lyrics sync engine — a UI-thread `slint::Timer` driving
|
// S4 lyrics sync engine — a UI-thread `slint::Timer` driving
|
||||||
// `LyricsState.active-index` / `line-progress` at ~30Hz while the panel
|
// `LyricsState.active-index` / `line-progress` at ~30Hz while the panel
|
||||||
// is open + the doc is synced + playback is live (idle gate polling
|
// is open + the doc is synced + playback is live (idle gate polling
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ fn current_filters() -> Filters {
|
|||||||
|
|
||||||
// --- Formatting ---------------------------------------------------------
|
// --- 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;
|
let b = bytes as f64;
|
||||||
if bytes >= GB {
|
if bytes >= GB {
|
||||||
format!("{:.1} GB", b / GB as f64)
|
format!("{:.1} GB", b / GB as f64)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
|
|||||||
import { Palette } from "../foundation/palette.slint";
|
import { Palette } from "../foundation/palette.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.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 { QbzToggle } from "../primitives/QbzToggle.slint";
|
||||||
import { SettingRow } from "SettingRow.slint";
|
import { SettingRow } from "SettingRow.slint";
|
||||||
|
|
||||||
@@ -74,9 +74,11 @@ export component OfflineSettings inherits VerticalLayout {
|
|||||||
spacing: 4px;
|
spacing: 4px;
|
||||||
|
|
||||||
// Re-seed the toggle states from the engine store on every mount of
|
// 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 => {
|
init => {
|
||||||
OfflineModeActions.load();
|
OfflineModeActions.load();
|
||||||
|
LyricsState.cache-refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupHeader { text: @tr("OFFLINE MODE"); }
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 <color> swatch;
|
||||||
|
property <bool> 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 <bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,21 @@ import { Theme } from "../foundation/semantic-colors.slint";
|
|||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { LyricsState } from "../state.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`):
|
// One lyric line. FOOTGUN (memory `reference_slint_root_opacity_absolute_position`):
|
||||||
// the dimming ladder is applied to the INNER Text (opacity + color), NEVER as
|
// 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
|
// 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 <string> line-text;
|
in property <string> line-text;
|
||||||
in property <length> size-inactive;
|
in property <length> size-inactive;
|
||||||
in property <length> size-active;
|
in property <length> 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 <int> dimming-mode;
|
in property <int> dimming-mode;
|
||||||
|
// S5 display prefs: uppercase transform, font family ("" = window
|
||||||
|
// default), karaoke fill / active color (theme accent or D6 swatch).
|
||||||
|
in property <bool> uppercase;
|
||||||
|
in property <string> font-name;
|
||||||
|
in property <color> active-color;
|
||||||
|
|
||||||
|
// Tauri renders uppercase via CSS text-transform; Slint transforms the
|
||||||
|
// string (display only — the engine still matches on the raw text).
|
||||||
|
property <string> 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
|
// Active-row geometry for the view's auto-center scroll. y/height are
|
||||||
// LAYOUT-relative (self.y within the body VerticalLayout) so the
|
// LAYOUT-relative (self.y within the body VerticalLayout) so the
|
||||||
@@ -103,7 +128,7 @@ component LyricsLineRow inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
base := Text {
|
base := Text {
|
||||||
text: root.line-text;
|
text: root.display-text;
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
opacity: root.line-opacity();
|
opacity: root.line-opacity();
|
||||||
// Active base = the DIM (unsung) layer under the karaoke overlay
|
// Active base = the DIM (unsung) layer under the karaoke overlay
|
||||||
@@ -111,6 +136,7 @@ component LyricsLineRow inherits VerticalLayout {
|
|||||||
color: root.is-active
|
color: root.is-active
|
||||||
? Theme.text-secondary
|
? Theme.text-secondary
|
||||||
: (root.is-past ? Theme.text-muted : 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-size: root.is-active ? root.size-active : root.size-inactive;
|
||||||
font-weight: root.is-active ? Typography.bold : Typography.medium;
|
font-weight: root.is-active ? Typography.bold : Typography.medium;
|
||||||
// Friendly letter-spacing (Q3): slightly open, never cramped.
|
// Friendly letter-spacing (Q3): slightly open, never cramped.
|
||||||
@@ -134,9 +160,10 @@ component LyricsLineRow inherits VerticalLayout {
|
|||||||
y: 0;
|
y: 0;
|
||||||
width: base.width;
|
width: base.width;
|
||||||
height: base.height;
|
height: base.height;
|
||||||
text: root.line-text;
|
text: root.display-text;
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
color: Theme.accent;
|
color: root.active-color;
|
||||||
|
font-family: root.font-name;
|
||||||
font-size: root.size-active;
|
font-size: root.size-active;
|
||||||
font-weight: Typography.bold;
|
font-weight: Typography.bold;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
@@ -150,14 +177,20 @@ export component LyricsLinesView inherits Rectangle {
|
|||||||
// surface later overrides padding/gap without touching display prefs.
|
// surface later overrides padding/gap without touching display prefs.
|
||||||
in property <length> side-padding: 6px;
|
in property <length> side-padding: 6px;
|
||||||
in property <length> line-gap: 12px; // Tauri compact gap parity
|
in property <length> 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 <length> size-inactive: 13px;
|
in property <length> size-inactive: 13px;
|
||||||
in property <length> size-active: 15px;
|
in property <length> size-active: 15px;
|
||||||
in property <int> dimming-mode: 2; // strong (Tauri default)
|
in property <int> dimming-mode: 2; // strong (Tauri default)
|
||||||
in property <bool> show-attribution: true;
|
in property <bool> show-attribution: true;
|
||||||
// Auto-center the active line (S5 wires the persisted pref; Tauri
|
// Auto-center the active line (persisted pref; Tauri default true,
|
||||||
// default true, lyricsDisplayStore.ts:37-44).
|
// lyricsDisplayStore.ts:37-44).
|
||||||
in property <bool> auto-follow: true;
|
in property <bool> auto-follow: true;
|
||||||
|
// S5 display prefs (defaults = Tauri's).
|
||||||
|
in property <bool> uppercase: false;
|
||||||
|
in property <string> font-name: ""; // "" = window default font
|
||||||
|
in property <color> active-color: Theme.accent;
|
||||||
|
|
||||||
// ---- Auto-center scroll state ----
|
// ---- Auto-center scroll state ----
|
||||||
// Last index the view centered on; -1 = nothing yet -> next center is
|
// 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-inactive: root.size-inactive;
|
||||||
size-active: root.size-active;
|
size-active: root.size-active;
|
||||||
dimming-mode: root.dimming-mode;
|
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) => {
|
report-active-geometry(report-idx, row-y, row-h) => {
|
||||||
root.center-active(report-idx, row-y, row-h);
|
root.center-active(report-idx, row-y, row-h);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,22 @@
|
|||||||
//
|
//
|
||||||
// 1:1 to the Tauri `LyricsSidebar.svelte` header/states (spec §3.1): 36px
|
// 1:1 to the Tauri `LyricsSidebar.svelte` header/states (spec §3.1): 36px
|
||||||
// mic icon tile + LYRICS overline + "{title} - {artist}" meta + the
|
// 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).
|
// Slint-convention close button (deviation D4, homologated with Queue).
|
||||||
// Body renders by LyricsState.status: loading / no-lyrics / offline-miss
|
// 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 { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Spacing } from "../foundation/spacing.slint";
|
import { Spacing } from "../foundation/spacing.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { IconButton } from "../primitives/BarControls.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
import { LyricsLinesView } from "LyricsLinesView.slint";
|
import { LyricsLinesView } from "LyricsLinesView.slint";
|
||||||
|
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
|
||||||
import { ShellState, LyricsState } from "../state.slint";
|
import { ShellState, LyricsState } from "../state.slint";
|
||||||
|
|
||||||
component CloseButton inherits Rectangle {
|
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 <image> 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 {
|
export component LyricsSidebar inherits Rectangle {
|
||||||
// Width is owned by the right-panel column in AppShell; this fills it and
|
// 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).
|
// 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 {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
HeaderIconButton {
|
Rectangle {
|
||||||
icon: @image-url("../assets/icons/sliders-horizontal.svg");
|
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 {
|
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 {
|
if LyricsState.status == 2 && LyricsState.lines.length > 0: LyricsLinesView {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2901,6 +2901,44 @@ export global LyricsState {
|
|||||||
// Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests
|
// Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests
|
||||||
// lyrics for the current track (cache-served when already fetched).
|
// lyrics for the current track (cache-served when already fetched).
|
||||||
callback panel-opened();
|
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 <bool> 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 <int> 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 <int> size-index: 1;
|
||||||
|
// 0 off · 1 soft · 2 strong (Tauri default 'strong').
|
||||||
|
in-out property <int> dimming-mode: 2;
|
||||||
|
in-out property <bool> 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 <bool> use-custom-color: false;
|
||||||
|
in-out property <color> 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 <bool> cache-loaded: false;
|
||||||
|
in property <string> cache-entries-text;
|
||||||
|
in property <string> cache-size;
|
||||||
|
callback cache-refresh();
|
||||||
|
callback cache-clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row in the QConnect device picker (a session renderer). Populated by the
|
// One row in the QConnect device picker (a session renderer). Populated by the
|
||||||
|
|||||||
Reference in new issue
Block a user