feat(qbz): wire the lyrics translation toggle end to end

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
This commit is contained in:
vicrodh committed 2026-07-22 19:59:47 -06:00
1 parent 7b447fb7e4
commit b4ead674d8
3 files changed
+289 -8

No files matched your search

+245 -8
View File
@@ -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<Arc<LyricsService>> = 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<Arc<tokio::sync::RwLock<Option<QobuzClient>>>> = 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<CurrentLyrics> = 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<String>,
}
static TRANSLATION: Mutex<TranslationSession> = 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<tokio::sync::RwLock<Option<QobuzClient>>>, 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<AppWindow>
});
}
// ---- 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<String> {
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<String> {
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<String> {
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<AppWindow>) {
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LyricsState>().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<AppWindow>) {
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LyricsState>().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<AppWindow>, 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::<LyricsState>().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::<LyricsState>().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<AppWindow>, 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<AppWindow>, 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<AppWindow>) {
if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None;
}
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<LyricsState>();
state.set_status(STATUS_IDLE);
@@ -437,27 +657,42 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
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<AppWindow>, result: Result<LyricsResponse, String>) {
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<LyricsLineItem> = 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<AppWindow>, result: Result<LyricsResponse, Str
provider.as_str(),
provider_label(provider),
String::new(),
translation_available,
)
}
LyricsOutcome::NotFound => {
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<AppWindow>, result: Result<LyricsResponse, Str
if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None;
}
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new())
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new(), false)
}
},
Err(e) => {
@@ -490,7 +726,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
*doc = None;
}
log::warn!("[qbz-slint] lyrics fetch failed: {e}");
(STATUS_ERROR, Vec::new(), false, "", "", e)
(STATUS_ERROR, Vec::new(), false, "", "", e, false)
}
};
let _ = weak.upgrade_in_event_loop(move |w| {
@@ -500,6 +736,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
state.set_provider(provider.into());
state.set_provider_label(label.into());
state.set_error(error.into());
state.set_translation_available(translation_available);
state.set_active_index(-1);
state.set_line_progress(0.0);
state.set_fill_anim_ms(0);
+41
View File
@@ -4962,6 +4962,23 @@ fn reseed_i18n_labels(window: &AppWindow) {
t("Lite"),
t("Off"),
])));
// Lyrics translation-language options (v10): the Auto label is @tr'd,
// the language names are native literals — the same convention as
// `languages` above.
window.global::<LyricsState>().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<dyn std::error::Error>> {
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::<LyricsState>().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).
+3
View File
@@ -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