From b1b64f2dfbc70f088ba9d0cd55c4b3da15eb0b46 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:59:27 -0600 Subject: [PATCH] feat(ui): translation language setting in the lyrics flyout New QbzSelect row (Auto (account language) + en es fr de it pt nl ja ru, the owner-approved list) after the Size row; language names are native literals, the same convention as the Appearance dropdown. The options array lives on LyricsState (reseeded from Rust on language change) and the choice persists per-user in lyrics_prefs.json as translation_language ("auto" or an ISO 639-1 code), sanitized on load (unknown -> auto) and round-tripped through apply_to_ui/persist_from_ui like the other prefs. Flyout popup height grows by the new row (and the comment now counts the lite-fill row it had missed). km --- .../ui/shell/LyricsControlsFlyout.slint | 23 ++++++++++- crates/qbz-ui/ui/shell/LyricsSidebar.slint | 7 ++-- crates/qbz-ui/ui/state.slint | 19 +++++++++ crates/qbz/src/lyrics_prefs.rs | 39 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint b/crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint index f96797caf..df0ce4a95 100644 --- a/crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint +++ b/crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint @@ -8,7 +8,9 @@ // 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). +// Uppercase / Dimming / footer (Copy lyrics + Reset) — plus two Slint-side +// additions in order: Translation language (v10, after Size) and Lite fill +// (perf, after Uppercase). // // 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 @@ -132,6 +134,25 @@ export component LyricsControlsPanel inherits Rectangle { } } + // Translation language (Qobuz v10) — Auto (account language) + the + // 9 approved ISO 639-1 targets; options reseeded from Rust on + // language change (the array carries one @tr'd label). + HorizontalLayout { + height: 30px; + spacing: 8px; + RowLabel { text: @tr("Translation language"); } + QbzSelect { + sm: true; + menu-width: 178px; + options: LyricsState.translation-language-options; + current-index: LyricsState.translation-language-index; + selected(i) => { + LyricsState.translation-language-index = i; + LyricsState.prefs-changed(); + } + } + } + // Active color — label (+ "Theme" reset while custom), swatch palette // underneath, right-aligned on the control grid (D6). VerticalLayout { diff --git a/crates/qbz-ui/ui/shell/LyricsSidebar.slint b/crates/qbz-ui/ui/shell/LyricsSidebar.slint index 92f5634e1..b1fc54fd9 100644 --- a/crates/qbz-ui/ui/shell/LyricsSidebar.slint +++ b/crates/qbz-ui/ui/shell/LyricsSidebar.slint @@ -176,9 +176,10 @@ export component LyricsSidebar inherits Rectangle { 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; + // padding 28 + rows (22+30+30+30+44+22+22+30+34 = + // 264, incl. lite-fill + translation language) + + // spacing 12 x 8 = 96. + height: 388px; close-policy: PopupClosePolicy.close-on-click-outside; LyricsControlsPanel { } } diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 269c6640b..6f68e3328 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -4555,6 +4555,25 @@ export global LyricsState { // between line changes). Default false (smooth karaoke). Kiosk / weak-GPU // tiers force it on via ShellState.reduce-motion regardless of this pref. in-out property lite-fill: false; + // Translation language (Qobuz v10, persisted like the other prefs): + // 0 Auto (account language) · 1..9 = en es fr de it pt nl ja ru. Option + // labels: the Auto entry is @tr'd, the language names are native + // literals — the same convention as AppearanceState.languages. The @tr + // DEFAULT is not reactive, so `reseed_i18n_labels` re-pushes the array + // on language change. + in-out property translation-language-index: 0; + in-out property <[string]> translation-language-options: [ + @tr("Auto (account language)"), + "English", + "Español", + "Français", + "Deutsch", + "Italiano", + "Português", + "Nederlands", + "日本語", + "Русский", + ]; // 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). diff --git a/crates/qbz/src/lyrics_prefs.rs b/crates/qbz/src/lyrics_prefs.rs index 59cf09706..630db5693 100644 --- a/crates/qbz/src/lyrics_prefs.rs +++ b/crates/qbz/src/lyrics_prefs.rs @@ -56,6 +56,10 @@ pub struct LyricsPrefs { /// karaoke sweep, so the lyrics surface stops driving continuous repaints. #[serde(default)] pub lite_fill: bool, + /// Translation target (Qobuz v10): "auto" (account language, UI-locale + /// fallback) or an explicit ISO 639-1 code from [`TRANSLATION_LANGS`]. + #[serde(default = "d_translation_lang")] + pub translation_language: String, } fn d_true() -> bool { @@ -70,6 +74,9 @@ fn d_size() -> String { fn d_dimming() -> String { "strong".to_string() } +fn d_translation_lang() -> String { + "auto".to_string() +} impl Default for LyricsPrefs { /// Tauri defaults (`lyricsDisplayStore.ts:37-44`). @@ -82,6 +89,7 @@ impl Default for LyricsPrefs { active_color: String::new(), uppercase: false, lite_fill: false, + translation_language: d_translation_lang(), } } } @@ -89,6 +97,11 @@ impl Default for LyricsPrefs { 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"]; +/// Translation targets (owner-approved list, spec §Decisions.2): "auto" + +/// the observed `translation_langs` ∪ app UI locales. Order matches the +/// flyout's `translation-language-options` (Auto, en es fr de it pt nl ja ru). +const TRANSLATION_LANGS: [&str; 10] = + ["auto", "en", "es", "fr", "de", "it", "pt", "nl", "ja", "ru"]; /// `#RRGGBB` (or empty = theme) — the Tauri validator /// (`lyricsDisplayStore.ts:46-52`). @@ -115,6 +128,9 @@ impl LyricsPrefs { if !valid_color(&self.active_color) { self.active_color.clear(); } + if !TRANSLATION_LANGS.contains(&self.translation_language.as_str()) { + self.translation_language = d_translation_lang(); + } self } } @@ -188,6 +204,13 @@ fn dimming_index(dimming: &str) -> i32 { DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32 } +fn translation_lang_index(lang: &str) -> i32 { + TRANSLATION_LANGS + .iter() + .position(|l| *l == lang) + .unwrap_or(0) as i32 +} + fn parse_color(hex: &str) -> Option { if hex.len() != 7 || !hex.starts_with('#') { return None; @@ -218,6 +241,7 @@ pub fn apply_to_ui(window: &AppWindow, prefs: &LyricsPrefs) { None => state.set_use_custom_color(false), } state.set_lite_fill(prefs.lite_fill); + state.set_translation_language_index(translation_lang_index(&prefs.translation_language)); } /// Read the in-out props back into a pref set and persist — the @@ -248,6 +272,11 @@ pub fn persist_from_ui(window: &AppWindow) { }, uppercase: state.get_uppercase(), lite_fill: state.get_lite_fill(), + translation_language: TRANSLATION_LANGS + .get(state.get_translation_language_index().max(0) as usize) + .copied() + .unwrap_or("auto") + .to_string(), }; save(&prefs); } @@ -273,6 +302,7 @@ mod tests { assert_eq!(p.dimming, "strong"); assert_eq!(p.active_color, ""); assert!(!p.uppercase); + assert_eq!(p.translation_language, "auto"); } #[test] @@ -291,6 +321,7 @@ mod tests { active_color: "purple".into(), uppercase: true, lite_fill: false, + translation_language: "klingon".into(), } .sanitized(); assert!(!p.auto_follow); // bools pass through @@ -299,6 +330,8 @@ mod tests { assert_eq!(p.font_size, "medium"); assert_eq!(p.dimming, "strong"); assert_eq!(p.active_color, ""); + // Unknown translation language falls back to Auto. + assert_eq!(p.translation_language, "auto"); } #[test] @@ -311,12 +344,14 @@ mod tests { active_color: "#8b5cf6".into(), uppercase: false, lite_fill: true, + translation_language: "it".into(), } .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"); + assert_eq!(p.translation_language, "it"); } #[test] @@ -340,10 +375,14 @@ mod tests { for (i, d) in DIMMINGS.iter().enumerate() { assert_eq!(dimming_index(d), i as i32); } + for (i, l) in TRANSLATION_LANGS.iter().enumerate() { + assert_eq!(translation_lang_index(l), 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); + assert_eq!(translation_lang_index("nope"), 0); } #[test]