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:
3 files changed
+289
-8
No files matched your search
+245
-8
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user