From b4ead674d8d7869b1963c5f7242ff20a02fa9b7a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:59:47 -0600 Subject: [PATCH] feat(qbz): wire the lyrics translation toggle end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit push now carries translation-available (doc translation_langs non-empty) and per-line translation-text, aligned 1:1 with the rendered lines (the core filters translation lines like the originals; a missing entry degrades to empty). Toggle ON resolves the target (explicit setting, else Auto: account language_code -> UI locale) and refetches the current track with LyricsRequest.language set — show-translation flips only when the response actually embeds a translation; any gap toasts the Android- parity error string and reverts, never putting the lyrics view in an error state. Toggle OFF is pure UI (no network). While ON, track-change requests ride the resolved target so the translation follows across tracks; prefetch stays original-only. A doc that already carries the target's translation flips locally without a refetch. The miniplayer mirrors show-translation so the mini lyrics surface follows the sidebar. km --- crates/qbz/src/lyrics.rs | 253 +++++++++++++++++++++++++++++++++-- crates/qbz/src/main.rs | 41 ++++++ crates/qbz/src/miniplayer.rs | 3 + 3 files changed, 289 insertions(+), 8 deletions(-) diff --git a/crates/qbz/src/lyrics.rs b/crates/qbz/src/lyrics.rs index 13c15be18..a253286a5 100644 --- a/crates/qbz/src/lyrics.rs +++ b/crates/qbz/src/lyrics.rs @@ -31,6 +31,16 @@ //! Rust-side in [`CURRENT_DOC`]; the UI model only carries the line list + //! a `has-words` flag. The S4 sync engine consumes the doc for the //! word-anchored karaoke fill. +//! +//! Translation (Qobuz API v10): the default flow fetches original-only and +//! pushes `translation-available` (doc `translation_langs` non-empty), which +//! gates the sidebar's floating toggle. Toggling ON refetches the current +//! track WITH the resolved target (`LyricsRequest.language`) via +//! [`enable_translation`] and flips `show-translation` only when a +//! translation actually arrived — any gap toasts and reverts (fail soft, +//! never an error state). Toggling OFF ([`disable_translation`]) is pure +//! UI. While ON, track-change requests ride the resolved target so the +//! translation follows across tracks. use std::sync::{Arc, Mutex, OnceLock}; @@ -58,6 +68,11 @@ const STATUS_OFFLINE: i32 = 5; /// the per-user cache handle re-binds via `init_at` on every activation. static SERVICE: OnceLock> = OnceLock::new(); +/// The shared core client lock, kept for the translation "Auto" resolution +/// (the account `language_code` lives on the client's session). Installed +/// alongside [`SERVICE`] on the first session activation. +static CLIENT: OnceLock>>> = OnceLock::new(); + /// Identity of the latest requested track + whether a Found result has been /// committed for it. `key` doubles as the stale guard (F2) and the /// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`). @@ -71,6 +86,20 @@ static CURRENT: Mutex = Mutex::new(CurrentLyrics { loaded: false, }); +/// Active translation session (Qobuz v10), the Rust-side source of truth +/// behind `LyricsState.show-translation`: `enabled` = the toggle is ON, +/// `language` = the target it was resolved with (kept so track changes +/// re-request the same target — spec §B: translation follows across tracks). +struct TranslationSession { + enabled: bool, + language: Option, +} + +static TRANSLATION: Mutex = Mutex::new(TranslationSession { + enabled: false, + language: None, +}); + /// The parsed document for the current track, held Rust-side so the native /// Qobuz word timestamps survive for the S4 sync engine (the UI model only /// carries text + line bounds + a has-words flag). @@ -133,6 +162,10 @@ impl LyricsProviders for SharedClientProviders { /// Best-effort: failures are logged, never block entry. Must run within the /// tokio runtime context (the bind is spawned). pub fn init_for_user(client: Arc>>, user_id: u64) { + // Keep a handle for the translation "Auto" resolution (the account + // language lives on the client's session). Same lock per process, so a + // later activation's re-set is a no-op. + let _ = CLIENT.set(client.clone()); let service = SERVICE .get_or_init(move || { Arc::new(LyricsService::new(Arc::new(SharedClientProviders { @@ -168,6 +201,7 @@ pub fn teardown() { if let Ok(mut doc) = CURRENT_DOC.lock() { *doc = None; } + reset_translation_session(); if let Some(service) = SERVICE.get().cloned() { tokio::spawn(async move { service.teardown().await; @@ -249,6 +283,187 @@ pub fn clear_cache(handle: &tokio::runtime::Handle, weak: slint::Weak }); } +// ---- Translation (Qobuz API v10) ------------------------------------------- + +/// Whether the translation toggle is currently ON (Rust-side source of truth +/// behind `LyricsState.show-translation`). main.rs reads it to route the +/// toggle callback; the fetch path reads [`active_translation_language`] to +/// keep requesting the target across track changes. +pub fn translation_enabled() -> bool { + TRANSLATION.lock().map(|t| t.enabled).unwrap_or(false) +} + +fn reset_translation_session() { + if let Ok(mut t) = TRANSLATION.lock() { + t.enabled = false; + t.language = None; + } +} + +/// The target the toggle was enabled with, while ON — the track-change fetch +/// rides it so the translation follows across tracks (spec §B). +fn active_translation_language() -> Option { + TRANSLATION + .lock() + .ok() + .and_then(|t| t.enabled.then(|| t.language.clone()).flatten()) +} + +/// Account language (ISO 639-1) from the shared client's session — the +/// "Auto" resolution's first hop. None pre-login / pre-v10 sessions. +async fn account_language() -> Option { + let client = CLIENT.get()?.clone(); + let guard = client.read().await; + guard.as_ref()?.session_language_code().await +} + +/// Resolve the effective translation target (spec §B.3): the explicit +/// setting when set; "Auto" = account `language_code`, falling back to the +/// UI locale. `None` when nothing resolves — the caller fails soft (toast + +/// revert); the lyrics view never enters an error state over translation. +async fn resolve_target_language() -> Option { + let pref = crate::lyrics_prefs::load().translation_language; + if pref != "auto" { + return Some(pref); + } + if let Some(lang) = account_language().await { + return Some(lang); + } + let ui_locale = qbz_i18n::current_language(); + (!ui_locale.is_empty()).then(|| ui_locale.to_string()) +} + +/// Toggle OFF: drop the session and re-render original-only. Pure UI — the +/// committed line model keeps its (now hidden) translation texts; NO +/// network (spec §B.3). +pub fn disable_translation(weak: &slint::Weak) { + reset_translation_session(); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_show_translation(false); + }); +} + +/// Toast + revert, for every translation failure path (toggle ON with no +/// current track, no resolvable target, fetch error, or a response without +/// a translation). Fail soft: the current lyrics view is left untouched. +pub fn translation_unavailable(weak: &slint::Weak) { + reset_translation_session(); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_show_translation(false); + }); + crate::toast::error_weak( + weak, + qbz_i18n::t("Translation is not available right now. Please try again later."), + ); +} + +/// Toggle ON: refetch the current track's lyrics WITH the resolved target +/// language (spec §B.3). The committed doc then carries the embedded +/// translation and `show-translation` flips on — but ONLY when the response +/// actually holds one; any gap toasts and reverts (Android +/// `player_lyrics_translation_error` parity). When the current doc already +/// holds the translation for the target (toggle off -> on on the same +/// track), the flip is local — no refetch. +pub fn enable_translation(weak: slint::Weak, track: &QueueTrack) { + let Some(service) = SERVICE.get().cloned() else { + translation_unavailable(&weak); + return; + }; + let source = source_kind(track); + let track_id = (source == LyricsSourceKind::Qobuz).then_some(track.id); + let duration_secs = (track.duration_secs > 0).then_some(track.duration_secs); + let key = request_identity( + track_id, + &build_cache_key(track.title.trim(), track.artist.trim(), duration_secs), + ); + let request = LyricsRequest { + track_id, + source, + title: track.title.clone(), + artist: track.artist.clone(), + album: (!track.album.is_empty()).then(|| track.album.clone()), + duration_secs, + offline: crate::offline_mode::engine().is_offline(), + // Resolved inside the task (the session read is async). + language: None, + }; + tokio::spawn(async move { + let Some(target) = resolve_target_language().await else { + translation_unavailable(&weak); + return; + }; + // Fast path: the current doc already carries this translation. + let already = with_current_doc(|doc| { + doc.and_then(|d| d.translation.as_ref()) + .and_then(|t| t.lang.as_deref()) + == Some(target.as_str()) + }); + if already { + if let Ok(mut t) = TRANSLATION.lock() { + t.enabled = true; + t.language = Some(target); + } + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_show_translation(true); + }); + return; + } + let mut request = request; + request.language = Some(target.clone()); + let result = service.get(request).await; + // Same stale guard (F2) as the track-change path: a superseded + // response is dropped whole. + let response_key = match &result { + Ok(response) => { + request_identity(response.request_track_id, &response.request_key) + } + Err(_) => key.clone(), + }; + { + let mut current = CURRENT.lock().expect("lyrics CURRENT lock poisoned"); + if current.key != response_key { + return; + } + if matches!( + result.as_ref().map(|r| &r.outcome), + Ok(LyricsOutcome::Found(_)) + ) { + current.loaded = true; + } + } + match result { + Ok(response) if matches!(response.outcome, LyricsOutcome::Found(_)) => { + let has_translation = match &response.outcome { + LyricsOutcome::Found(found) => found.doc.translation.is_some(), + _ => false, + }; + if has_translation { + if let Ok(mut t) = TRANSLATION.lock() { + t.enabled = true; + t.language = Some(target); + } + } + apply_result(weak.clone(), Ok(response)); + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::().set_show_translation(has_translation); + }); + if !has_translation { + // The track lacks the target language — the original + // committed above stays; toast + revert (spec §B.4). + translation_unavailable(&weak); + } + } + // NotFound / offline-miss: keep the current view untouched. + Ok(_) => translation_unavailable(&weak), + // Fetch error: NEVER an error state because of translation. + Err(e) => { + log::warn!("[qbz-slint] translation refetch failed: {e}"); + translation_unavailable(&weak); + } + } + }); +} + /// 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 @@ -342,9 +557,10 @@ pub fn on_track_changed(weak: slint::Weak, track: &QueueTrack) { // Offline as data, not lookup (spec §2.2.4): the engine verdict is // read here and travels with the request. offline: crate::offline_mode::engine().is_offline(), - // Default flow fetches original-only (spec §B.1); the translation - // toggle's refetch-with-language is wired by the UI task. - language: None, + // Default flow fetches original-only (spec §B.1). While the + // translation toggle is ON the request rides the resolved target so + // the translation follows across tracks. + language: active_translation_language(), }; let key = request_identity( request.track_id, @@ -385,6 +601,9 @@ pub fn on_track_changed(weak: slint::Weak, track: &QueueTrack) { state.set_provider("".into()); state.set_provider_label("".into()); state.set_error("".into()); + // Hide the toggle while loading — the availability of the NEXT + // track's doc is unknown until its commit. + state.set_translation_available(false); }); } @@ -424,6 +643,7 @@ pub fn on_track_cleared(weak: slint::Weak) { if let Ok(mut doc) = CURRENT_DOC.lock() { *doc = None; } + reset_translation_session(); let _ = weak.upgrade_in_event_loop(|w| { let state = w.global::(); state.set_status(STATUS_IDLE); @@ -437,27 +657,42 @@ pub fn on_track_cleared(weak: slint::Weak) { state.set_provider("".into()); state.set_provider_label("".into()); state.set_error("".into()); + state.set_translation_available(false); + state.set_show_translation(false); }); } /// Map the engine response into `LyricsState` (UI thread push). fn apply_result(weak: slint::Weak, result: Result) { - let (status, items, synced, provider, label, error) = match result { + let (status, items, synced, provider, label, error, translation_available) = match result { Ok(response) => match response.outcome { LyricsOutcome::Found(found) => { + let translation = found.doc.translation.as_deref(); let items: Vec = found .doc .lines .iter() - .map(|line| LyricsLineItem { + .enumerate() + .map(|(i, line)| LyricsLineItem { text: line.text.clone().into(), time_ms: line.time_ms.map(|v| v as i32).unwrap_or(-1), end_ms: line.end_ms.map(|v| v as i32).unwrap_or(-1), has_words: line.words.is_some(), + // 1:1 with the rendered lines — the core filters the + // translation lines the same way as the originals; + // a missing entry degrades to "" (fail soft). + translation_text: translation + .and_then(|t| t.lines.get(i)) + .map(|l| l.text.clone()) + .unwrap_or_default() + .into(), }) .collect(); let synced = found.doc.synced; let provider = found.doc.provider; + // The toggle lights only when Qobuz advertises translations + // for this track (spec §B.2). + let translation_available = !found.doc.translation_langs.is_empty(); if let Ok(mut doc) = CURRENT_DOC.lock() { *doc = Some(found.doc); } @@ -468,13 +703,14 @@ fn apply_result(weak: slint::Weak, result: Result { if let Ok(mut doc) = CURRENT_DOC.lock() { *doc = None; } - (STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new()) + (STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new(), false) } // Typed offline miss (F3) — the view maps it to a translated // string, never a hardcoded message. @@ -482,7 +718,7 @@ fn apply_result(weak: slint::Weak, result: Result { @@ -490,7 +726,7 @@ fn apply_result(weak: slint::Weak, result: Result, result: Result().set_translation_language_options(ModelRc::new( + VecModel::from(vec![ + t("Auto (account language)"), + "English".into(), + "Español".into(), + "Français".into(), + "Deutsch".into(), + "Italiano".into(), + "Português".into(), + "Nederlands".into(), + "日本語".into(), + "Русский".into(), + ]), + )); // Cast picker: per-renderer cap options (#638 fix 4) — option 0 embeds // the live global streaming-quality label, so this also re-reads // ui_prefs (the Settings streaming-quality arm re-pushes on change). @@ -14130,6 +14147,30 @@ fn main() -> Result<(), Box> { lyrics::copy_current_lyrics(&weak); }); } + { + // Translation toggle (Qobuz v10, sidebar floating button). OFF is + // pure UI (no network); ON resolves the target language and refetches + // the current track's lyrics with it — any gap toasts and reverts + // inside lyrics::enable_translation / translation_unavailable. + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_toggle_translation(move || { + if lyrics::translation_enabled() { + lyrics::disable_translation(&weak); + return; + } + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let state = runtime.core().get_queue_state().await; + match state.current_track { + Some(track) => lyrics::enable_translation(weak, &track), + None => lyrics::translation_unavailable(&weak), + } + }); + }); + } { // Settings > Offline lyrics-cache row: stats refresh on section // mount + clear action (F1: stats from the real per-user DB). diff --git a/crates/qbz/src/miniplayer.rs b/crates/qbz/src/miniplayer.rs index 2a6ec7f57..57436f932 100644 --- a/crates/qbz/src/miniplayer.rs +++ b/crates/qbz/src/miniplayer.rs @@ -607,6 +607,9 @@ fn mirror_lyrics_gated(main: &AppWindow, mini: &MiniPlayerWindow) { d.set_active_index(s.get_active_index()); d.set_line_progress(s.get_line_progress()); d.set_fill_anim_ms(s.get_fill_anim_ms()); + // Translation toggle (v10): the mini surface has no button of its own — + // it follows the sidebar's bilingual state through the shared model. + d.set_show_translation(s.get_show_translation()); refresh_mini_segments(&d); // Gate the lines-model copy on (status, line-count) so we don't thrash the