feat(slint): lyrics wiring + static sidebar rendering
S3 of the Lyrics epic: the qbz-lyrics engine reaches the Slint shell — per-user cache binding, the track-edge fetch rider, the LyricsState global, and the real sidebar body replacing the placeholder. - Glue (src/lyrics.rs, offline_mode/fav_cache template): process-global LyricsService over the core client lock (read at call time, never cached); init_for_user on ALL session activation paths (browser login, saved-session restore, offline entry) binding the SAME per-user lyrics/lyrics.db Tauri uses; teardown on logout. - Fetch rider on the NOTIFY_LAST_TRACK dedupe edge in refresh_now_playing_meta, next to scrobble/notify — prefetch on every track change regardless of panel visibility (Tauri parity, lyricsStore.ts:545-565) and deliberately NO skip-if-remote (Q7: lyrics follow the QConnect peer). Track->null resets the state. Request carries source kind (qobuz_download counts as Qobuz, real catalog id) + the offline engine verdict; raw title to the engine (F4). - Stale guard (F2): responses echo the request identity and are dropped unless they still match the latest requested track. Duplicate-fetch guard mirrors Tauri's lastFetchedTrackId (skip only while loaded). Parsed doc (native wsync words) held Rust-side for the S4 engine. - LyricsState global: line model (text, time-ms, end-ms, has-words), status (idle/loading/ready/no-lyrics/error/offline), synced, provider + attribution label, header meta, active-index placeholder (-1), and panel-opened (fetch-on-open via conditional-mount init). - LyricsLinesView (shared, miniplayer-ready): 280px-adapted typography per the owner Q3 spec - 13/15px reduced scale, 0.2px letter-spacing, 6px side padding, full-width wrapped text; dimming ladder on the INNER Text (never a root opacity binding); centering spacers from the Flickable's own height; plain mode = static top-aligned at 0.85; fallback-provider attribution line (Qobuz first-party = none). - Sidebar 1:1 header (mic tile 36px, LYRICS overline 11px/1.5px per the alignment standard, title-artist meta, sliders trigger for the S5 flyout, close kept) + state bodies incl. the typed NotAvailableOffline as a translated string (F3). All new strings @tr()-wrapped. Sync engine (position, active line, auto-center, karaoke fill) is S4. cc
This commit is contained in:
11 files changed
+793
-20
No files matched your search
Generated
+1
@@ -5507,6 +5507,7 @@ dependencies = [
|
|||||||
"qbz-credentials",
|
"qbz-credentials",
|
||||||
"qbz-integrations",
|
"qbz-integrations",
|
||||||
"qbz-library",
|
"qbz-library",
|
||||||
|
"qbz-lyrics",
|
||||||
"qbz-media-controls",
|
"qbz-media-controls",
|
||||||
"qbz-mixtape",
|
"qbz-mixtape",
|
||||||
"qbz-models",
|
"qbz-models",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ qbz-offline-cache = { path = "../qbz-offline-cache" }
|
|||||||
qbz-models = { path = "../qbz-models" }
|
qbz-models = { path = "../qbz-models" }
|
||||||
qbz-mixtape = { path = "../qbz-mixtape" }
|
qbz-mixtape = { path = "../qbz-mixtape" }
|
||||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||||
|
qbz-lyrics = { path = "../qbz-lyrics" }
|
||||||
qbz-integrations = { path = "../qbz-integrations" }
|
qbz-integrations = { path = "../qbz-integrations" }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
# DJ-mix sampler thread RNG (rand::rng()), confined to a sync scope — the
|
# DJ-mix sampler thread RNG (rand::rng()), confined to a sync scope — the
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ where
|
|||||||
crate::offline_mode::init_for_user(&dir);
|
crate::offline_mode::init_for_user(&dir);
|
||||||
crate::fav_cache::init_for_user(&dir);
|
crate::fav_cache::init_for_user(&dir);
|
||||||
}
|
}
|
||||||
|
// Lyrics cache (per-user, shared lyrics.db with Tauri).
|
||||||
|
crate::lyrics::init_for_user(core.client(), user_id);
|
||||||
crate::offline_mode::subscription_mark_valid();
|
crate::offline_mode::subscription_mark_valid();
|
||||||
crate::offline_mode::engine().set_offline_session(false);
|
crate::offline_mode::engine().set_offline_session(false);
|
||||||
|
|
||||||
@@ -218,6 +220,8 @@ where
|
|||||||
crate::offline_mode::init_for_user(&dir);
|
crate::offline_mode::init_for_user(&dir);
|
||||||
crate::fav_cache::init_for_user(&dir);
|
crate::fav_cache::init_for_user(&dir);
|
||||||
}
|
}
|
||||||
|
// Lyrics cache (per-user, shared lyrics.db with Tauri).
|
||||||
|
crate::lyrics::init_for_user(core.client(), user_id);
|
||||||
crate::offline_mode::subscription_mark_valid();
|
crate::offline_mode::subscription_mark_valid();
|
||||||
crate::offline_mode::engine().set_offline_session(false);
|
crate::offline_mode::engine().set_offline_session(false);
|
||||||
log::info!("[qbz-slint] restored saved session for user {user_id}");
|
log::info!("[qbz-slint] restored saved session for user {user_id}");
|
||||||
@@ -261,6 +265,7 @@ where
|
|||||||
crate::offline::deactivate().await;
|
crate::offline::deactivate().await;
|
||||||
crate::offline_mode::teardown();
|
crate::offline_mode::teardown();
|
||||||
crate::fav_cache::teardown();
|
crate::fav_cache::teardown();
|
||||||
|
crate::lyrics::teardown();
|
||||||
runtime.deactivate().await?;
|
runtime.deactivate().await?;
|
||||||
log::info!("[qbz-slint] logged out");
|
log::info!("[qbz-slint] logged out");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! Slint-side glue for the shared lyrics engine (`crates/qbz-lyrics`).
|
||||||
|
//!
|
||||||
|
//! The engine (Qobuz-first chain, providers, per-user SQLite cache) is
|
||||||
|
//! frontend-agnostic (ADR-006); this module owns only the process global and
|
||||||
|
//! the bindings, following the `offline_mode.rs` / `fav_cache.rs` template:
|
||||||
|
//!
|
||||||
|
//! - [`init_for_user`] binds the per-user cache on every session activation
|
||||||
|
//! (login, restore, offline entry — next to `offline_mode::init_for_user`);
|
||||||
|
//! [`teardown`] drops it on logout. The cache file is the SAME
|
||||||
|
//! `<user cache dir>/lyrics/lyrics.db` Tauri uses.
|
||||||
|
//! - [`on_track_changed`] is the fetch rider on the `NOTIFY_LAST_TRACK`
|
||||||
|
//! de-duped track-change edge in `playback::refresh_now_playing_meta`
|
||||||
|
//! (the scrobble/notification seam). Tauri prefetches on EVERY track change
|
||||||
|
//! regardless of panel visibility (`lyricsStore.ts:545-565` — "Always
|
||||||
|
//! prefetch lyrics when a new track starts"); same here. Deliberately NO
|
||||||
|
//! skip-if-remote: lyrics follow the QConnect peer's track (Q7).
|
||||||
|
//! - [`on_track_cleared`] resets `LyricsState` when the queue empties
|
||||||
|
//! (track -> null resets the store, `lyricsStore.ts:560-562`).
|
||||||
|
//! - The sidebar's `init` fires `LyricsState.panel-opened()` (conditional
|
||||||
|
//! mount, ADR-010) -> main.rs re-runs the same fetch path for the current
|
||||||
|
//! track; the duplicate-fetch guard makes that a no-op while still loaded
|
||||||
|
//! (Tauri `lastFetchedTrackId` guard, `lyricsStore.ts:352-354`).
|
||||||
|
//!
|
||||||
|
//! Stale-response guard (F2): every spawned fetch captures its request
|
||||||
|
//! identity; the service echoes it back (`request_track_id`/`request_key`)
|
||||||
|
//! and the response is DROPPED unless it still matches the latest requested
|
||||||
|
//! track — a late response can never overwrite the current track's lyrics
|
||||||
|
//! (the documented Tauri race, review §1.6).
|
||||||
|
//!
|
||||||
|
//! The parsed [`LyricsDoc`] (native Qobuz word stamps included) is held
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||||
|
|
||||||
|
use qbz_lyrics::{
|
||||||
|
build_cache_key, LyricsData, LyricsDoc, LyricsOutcome, LyricsProvider, LyricsProviders,
|
||||||
|
LyricsRequest, LyricsResponse, LyricsService, LyricsSourceKind,
|
||||||
|
};
|
||||||
|
use qbz_models::QueueTrack;
|
||||||
|
use qbz_qobuz::{QobuzClient, QobuzLyricsDocument};
|
||||||
|
|
||||||
|
use crate::{AppWindow, LyricsLineItem, LyricsState};
|
||||||
|
|
||||||
|
// `LyricsState.status` values (keep in sync with `ui/state.slint`).
|
||||||
|
const STATUS_IDLE: i32 = 0;
|
||||||
|
const STATUS_LOADING: i32 = 1;
|
||||||
|
const STATUS_READY: i32 = 2;
|
||||||
|
const STATUS_NOT_FOUND: i32 = 3;
|
||||||
|
const STATUS_ERROR: i32 = 4;
|
||||||
|
const STATUS_OFFLINE: i32 = 5;
|
||||||
|
|
||||||
|
/// Process-global lyrics service. Installed on the first session activation;
|
||||||
|
/// the per-user cache handle re-binds via `init_at` on every activation.
|
||||||
|
static SERVICE: OnceLock<Arc<LyricsService>> = 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'`).
|
||||||
|
struct CurrentLyrics {
|
||||||
|
key: String,
|
||||||
|
loaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CURRENT: Mutex<CurrentLyrics> = Mutex::new(CurrentLyrics {
|
||||||
|
key: String::new(),
|
||||||
|
loaded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
static CURRENT_DOC: Mutex<Option<LyricsDoc>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Production providers over the shared core client lock. The lock is read
|
||||||
|
/// at CALL time (never cached), so re-inits of the Qobuz client are always
|
||||||
|
/// picked up; a missing client (pre-login, offline boot) errors out, which
|
||||||
|
/// the chain treats as silent degradation to the external fallbacks.
|
||||||
|
struct SharedClientProviders {
|
||||||
|
client: Arc<tokio::sync::RwLock<Option<QobuzClient>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl LyricsProviders for SharedClientProviders {
|
||||||
|
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
|
||||||
|
let guard = self.client.read().await;
|
||||||
|
let client = guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Qobuz client not initialized".to_string())?;
|
||||||
|
client.get_lyrics(track_id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lrclib(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
artist: &str,
|
||||||
|
duration_secs: Option<u64>,
|
||||||
|
) -> Result<Option<LyricsData>, String> {
|
||||||
|
qbz_lyrics::providers::fetch_lrclib(title, artist, duration_secs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ovh(&self, title: &str, artist: &str) -> Option<LyricsData> {
|
||||||
|
qbz_lyrics::providers::fetch_lyrics_ovh(title, artist).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the per-user lyrics cache — the SAME `lyrics/lyrics.db` under the
|
||||||
|
/// per-user CACHE dir that Tauri's `session_lifecycle.rs:229` uses, so both
|
||||||
|
/// frontends share one cache. Called on every session activation; the first
|
||||||
|
/// call installs the process-global service over the core's client lock
|
||||||
|
/// (one lock per process, so later calls reuse the installed providers).
|
||||||
|
/// 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) {
|
||||||
|
let service = SERVICE
|
||||||
|
.get_or_init(move || {
|
||||||
|
Arc::new(LyricsService::new(Arc::new(SharedClientProviders {
|
||||||
|
client,
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
.clone();
|
||||||
|
let Some(cache_dir) = dirs::cache_dir().map(|d| {
|
||||||
|
d.join("qbz")
|
||||||
|
.join("users")
|
||||||
|
.join(user_id.to_string())
|
||||||
|
}) else {
|
||||||
|
log::error!("[qbz-slint] lyrics cache init: cache directory unavailable");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match service.init_at(&cache_dir).await {
|
||||||
|
Ok(()) => log::info!("[qbz-slint] lyrics cache bound for user {user_id}"),
|
||||||
|
Err(e) => log::error!("[qbz-slint] lyrics cache init failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the per-user cache handle + the in-memory track state on logout.
|
||||||
|
/// Clearing `CURRENT` also invalidates any in-flight fetch (its stale guard
|
||||||
|
/// no longer matches), so a late response from the previous session never
|
||||||
|
/// lands in the next one.
|
||||||
|
pub fn teardown() {
|
||||||
|
if let Ok(mut current) = CURRENT.lock() {
|
||||||
|
current.key.clear();
|
||||||
|
current.loaded = false;
|
||||||
|
}
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = None;
|
||||||
|
}
|
||||||
|
if let Some(service) = SERVICE.get().cloned() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
service.teardown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// have synthetic ids and go straight to the metadata-keyed fallback chain.
|
||||||
|
fn source_kind(track: &QueueTrack) -> LyricsSourceKind {
|
||||||
|
match track.source.as_deref() {
|
||||||
|
Some("local") | Some("ephemeral") | Some("plex") => LyricsSourceKind::NonQobuz,
|
||||||
|
// None | "qobuz" | "qobuz_download"
|
||||||
|
_ => LyricsSourceKind::Qobuz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One identity string for a request/response pair: the track id when the
|
||||||
|
/// Qobuz primary applies, else the metadata cache key. Matches the echo the
|
||||||
|
/// service returns (`request_track_id` / `request_key`).
|
||||||
|
fn request_identity(track_id: Option<u64>, cache_key: &str) -> String {
|
||||||
|
match track_id {
|
||||||
|
Some(id) => format!("id:{id}"),
|
||||||
|
None => format!("key:{cache_key}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_label(provider: LyricsProvider) -> &'static str {
|
||||||
|
match provider {
|
||||||
|
LyricsProvider::Lrclib => "LRCLIB",
|
||||||
|
LyricsProvider::Ovh => "lyrics.ovh",
|
||||||
|
// First-party — no attribution needed (spec §3.5).
|
||||||
|
LyricsProvider::Qobuz => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fetch rider — called inside the `NOTIFY_LAST_TRACK` guard of
|
||||||
|
/// `refresh_now_playing_meta` on every real track-change edge, and from the
|
||||||
|
/// panel-open path for the current track. Fire-and-forget: pushes the
|
||||||
|
/// loading state immediately, resolves through the engine off-loop, and
|
||||||
|
/// commits the response only if the track is still current (F2).
|
||||||
|
pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
|
||||||
|
let Some(service) = SERVICE.get().cloned() else {
|
||||||
|
log::debug!("[qbz-slint] lyrics fetch skipped: service not installed");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let source = source_kind(track);
|
||||||
|
// F4 contract, explicit: the RAW title goes to the engine (fallback
|
||||||
|
// providers match on the unversioned title; Qobuz looks up by id). The
|
||||||
|
// header meta shows the version-enriched display title like the bar.
|
||||||
|
let display_title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||||
|
Some(version) => format!("{} ({version})", track.title),
|
||||||
|
None => track.title.clone(),
|
||||||
|
};
|
||||||
|
let artist = track.artist.clone();
|
||||||
|
let request = LyricsRequest {
|
||||||
|
track_id: (source == LyricsSourceKind::Qobuz).then_some(track.id),
|
||||||
|
source,
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist: artist.clone(),
|
||||||
|
album: (!track.album.is_empty()).then(|| track.album.clone()),
|
||||||
|
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
|
||||||
|
// 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(),
|
||||||
|
};
|
||||||
|
let key = request_identity(
|
||||||
|
request.track_id,
|
||||||
|
&build_cache_key(
|
||||||
|
request.title.trim(),
|
||||||
|
request.artist.trim(),
|
||||||
|
request.duration_secs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Duplicate-fetch guard (Tauri parity, lyricsStore.ts:352-354): skip only
|
||||||
|
// when the SAME track is already loaded; not-found/error states re-fetch
|
||||||
|
// on the next trigger (e.g. panel re-open).
|
||||||
|
{
|
||||||
|
let mut current = CURRENT.lock().expect("lyrics CURRENT lock poisoned");
|
||||||
|
if current.key == key && current.loaded {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.key = key.clone();
|
||||||
|
current.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state + header meta, immediately (the spinner shows even for a
|
||||||
|
// fast cache hit — Tauri does the same).
|
||||||
|
{
|
||||||
|
let display_title = display_title.clone();
|
||||||
|
let artist = artist.clone();
|
||||||
|
let _ = weak.clone().upgrade_in_event_loop(move |w| {
|
||||||
|
let state = w.global::<LyricsState>();
|
||||||
|
state.set_status(STATUS_LOADING);
|
||||||
|
state.set_track_title(display_title.into());
|
||||||
|
state.set_track_artist(artist.into());
|
||||||
|
state.set_lines(ModelRc::new(VecModel::default()));
|
||||||
|
state.set_synced(false);
|
||||||
|
state.set_active_index(-1);
|
||||||
|
state.set_provider("".into());
|
||||||
|
state.set_provider_label("".into());
|
||||||
|
state.set_error("".into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = service.get(request).await;
|
||||||
|
// Stale guard (F2): match the response echo against the LATEST
|
||||||
|
// requested identity; 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply_result(weak, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset everything when the queue empties (track -> null), mirroring the
|
||||||
|
/// Tauri store reset (`lyricsStore.ts:560-562`).
|
||||||
|
pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
|
||||||
|
if let Ok(mut current) = CURRENT.lock() {
|
||||||
|
current.key.clear();
|
||||||
|
current.loaded = false;
|
||||||
|
}
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = None;
|
||||||
|
}
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
let state = w.global::<LyricsState>();
|
||||||
|
state.set_status(STATUS_IDLE);
|
||||||
|
state.set_lines(ModelRc::new(VecModel::default()));
|
||||||
|
state.set_synced(false);
|
||||||
|
state.set_active_index(-1);
|
||||||
|
state.set_track_title("".into());
|
||||||
|
state.set_track_artist("".into());
|
||||||
|
state.set_provider("".into());
|
||||||
|
state.set_provider_label("".into());
|
||||||
|
state.set_error("".into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Ok(response) => match response.outcome {
|
||||||
|
LyricsOutcome::Found(found) => {
|
||||||
|
let items: Vec<LyricsLineItem> = found
|
||||||
|
.doc
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|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(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let synced = found.doc.synced;
|
||||||
|
let provider = found.doc.provider;
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = Some(found.doc);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
STATUS_READY,
|
||||||
|
items,
|
||||||
|
synced,
|
||||||
|
provider.as_str(),
|
||||||
|
provider_label(provider),
|
||||||
|
String::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LyricsOutcome::NotFound => {
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = None;
|
||||||
|
}
|
||||||
|
(STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new())
|
||||||
|
}
|
||||||
|
// Typed offline miss (F3) — the view maps it to a translated
|
||||||
|
// string, never a hardcoded message.
|
||||||
|
LyricsOutcome::NotAvailableOffline => {
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = None;
|
||||||
|
}
|
||||||
|
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
|
*doc = None;
|
||||||
|
}
|
||||||
|
log::warn!("[qbz-slint] lyrics fetch failed: {e}");
|
||||||
|
(STATUS_ERROR, Vec::new(), false, "", "", e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
let state = w.global::<LyricsState>();
|
||||||
|
state.set_lines(ModelRc::new(VecModel::from(items)));
|
||||||
|
state.set_synced(synced);
|
||||||
|
state.set_provider(provider.into());
|
||||||
|
state.set_provider_label(label.into());
|
||||||
|
state.set_error(error.into());
|
||||||
|
state.set_active_index(-1);
|
||||||
|
state.set_status(status);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ mod library_db;
|
|||||||
mod local_library;
|
mod local_library;
|
||||||
mod local_playlist;
|
mod local_playlist;
|
||||||
mod local_library_settings;
|
mod local_library_settings;
|
||||||
|
mod lyrics;
|
||||||
mod media_controls;
|
mod media_controls;
|
||||||
mod locallibrary_prefs;
|
mod locallibrary_prefs;
|
||||||
mod tag_editor;
|
mod tag_editor;
|
||||||
@@ -337,6 +338,9 @@ async fn enter_shell_offline(
|
|||||||
crate::offline_mode::init_for_user(&dir);
|
crate::offline_mode::init_for_user(&dir);
|
||||||
crate::fav_cache::init_for_user(&dir);
|
crate::fav_cache::init_for_user(&dir);
|
||||||
}
|
}
|
||||||
|
// Lyrics cache (per-user, shared file with Tauri) — offline sessions
|
||||||
|
// serve cached lyrics (deviation D3, cache-first offline contract).
|
||||||
|
crate::lyrics::init_for_user(runtime.core().client(), user_id);
|
||||||
crate::offline_mode::engine().set_offline_session(true);
|
crate::offline_mode::engine().set_offline_session(true);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -6397,6 +6401,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lyrics panel open (conditional mount, ADR-010): re-request lyrics for
|
||||||
|
// the current track — a no-op while still loaded (duplicate-fetch guard),
|
||||||
|
// a cache-served fetch otherwise (Tauri parity: load-while-open lands
|
||||||
|
// immediately, lyricsStore.ts:386-389).
|
||||||
|
{
|
||||||
|
let runtime = app_runtime.clone();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window.global::<LyricsState>().on_panel_opened(move || {
|
||||||
|
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::on_track_changed(weak, &track),
|
||||||
|
None => lyrics::on_track_cleared(weak),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Album track search — client-side filter, no backend round-trip.
|
// Album track search — client-side filter, no backend round-trip.
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
|
|||||||
if let Some(mc) = crate::media_controls::handle() {
|
if let Some(mc) = crate::media_controls::handle() {
|
||||||
mc.set_playback(qbz_media_controls::PlaybackStatus::Stopped, None);
|
mc.set_playback(qbz_media_controls::PlaybackStatus::Stopped, None);
|
||||||
}
|
}
|
||||||
|
// Track -> null resets the lyrics state (Tauri parity,
|
||||||
|
// lyricsStore.ts:560-562).
|
||||||
|
crate::lyrics::on_track_cleared(weak.clone());
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
w.global::<NowPlayingState>().set_has_track(false);
|
w.global::<NowPlayingState>().set_has_track(false);
|
||||||
});
|
});
|
||||||
@@ -1361,6 +1364,13 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
|
|||||||
// only an actual track change fires; skip while a remote QConnect renderer
|
// only an actual track change fires; skip while a remote QConnect renderer
|
||||||
// drives playback (matches the Svelte `skipIfRemote`). Fire-and-forget.
|
// drives playback (matches the Svelte `skipIfRemote`). Fire-and-forget.
|
||||||
if NOTIFY_LAST_TRACK.swap(track.id, std::sync::atomic::Ordering::Relaxed) != track.id {
|
if NOTIFY_LAST_TRACK.swap(track.id, std::sync::atomic::Ordering::Relaxed) != track.id {
|
||||||
|
// Lyrics prefetch — third rider on the same de-duped track-change
|
||||||
|
// edge. Tauri prefetches on EVERY track change regardless of panel
|
||||||
|
// visibility (lyricsStore.ts:545-565); same here. Deliberately NOT
|
||||||
|
// inside the skip-if-remote spawn below: lyrics follow the QConnect
|
||||||
|
// peer's track (Q7). Fire-and-forget; the stale-response guard (F2)
|
||||||
|
// lives in `lyrics::on_track_changed`.
|
||||||
|
crate::lyrics::on_track_changed(weak.clone(), &track);
|
||||||
let notify_meta = qbz_media_controls::NotificationMeta {
|
let notify_meta = qbz_media_controls::NotificationMeta {
|
||||||
title: title.clone(),
|
title: title.clone(),
|
||||||
artist: artist.clone(),
|
artist: artist.clone(),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
|||||||
import { Toast } from "primitives/Toast.slint";
|
import { Toast } from "primitives/Toast.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// Re-export the state globals so the Rust layer can populate them.
|
||||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
|
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
|
||||||
|
|
||||||
// Which top-level screen is shown. The app starts on `splash` while it
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// restores a saved session, then resolves to `shell` or `login`.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-sliders-horizontal"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="21" x2="14" y1="4" y2="4" />
|
||||||
|
<line x1="10" x2="3" y1="4" y2="4" />
|
||||||
|
<line x1="21" x2="12" y1="12" y2="12" />
|
||||||
|
<line x1="8" x2="3" y1="12" y2="12" />
|
||||||
|
<line x1="21" x2="16" y1="20" y2="20" />
|
||||||
|
<line x1="12" x2="3" y1="20" y2="20" />
|
||||||
|
<line x1="14" x2="14" y1="2" y2="6" />
|
||||||
|
<line x1="8" x2="8" y1="10" y2="14" />
|
||||||
|
<line x1="16" x2="16" y1="18" y2="22" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,144 @@
|
|||||||
|
// Shared lyrics lines renderer — consumed by the sidebar now, the miniplayer
|
||||||
|
// surface later (spec §3.6: everything arrives via LyricsState + properties;
|
||||||
|
// nothing here references the sidebar, ShellState, or window geometry).
|
||||||
|
//
|
||||||
|
// 1:1 to Tauri's `LyricsLines.svelte` compact mode, ADAPTED to the OWNER's
|
||||||
|
// 280px typography spec (Q3): reduced sizes vs the 340px Tauri column
|
||||||
|
// (13/15px = Tauri's compact "small" tier instead of the 15/17 medium),
|
||||||
|
// friendly 0.2px letter-spacing, minimal 6px side padding (never removed),
|
||||||
|
// the text takes the full remaining width.
|
||||||
|
//
|
||||||
|
// Static rendering only (S3): the S4 sync engine drives
|
||||||
|
// `LyricsState.active-index` and adds auto-center scroll + the karaoke fill;
|
||||||
|
// until then scrolling is a plain Flickable and active-index stays -1.
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { LyricsState } from "../state.slint";
|
||||||
|
|
||||||
|
// One lyric line. FOOTGUN (memory `reference_slint_root_opacity_absolute_position`):
|
||||||
|
// 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 item that corrupts absolute-position / self.y anchoring inside
|
||||||
|
// the Flickable (one-row-pitch drift), exactly the geometry the S4 scroll
|
||||||
|
// math will rely on.
|
||||||
|
component LyricsLineRow inherits VerticalLayout {
|
||||||
|
in property <int> index;
|
||||||
|
in property <string> line-text;
|
||||||
|
in property <length> size-inactive;
|
||||||
|
in property <length> size-active;
|
||||||
|
// 0 = off, 1 = soft, 2 = strong (Tauri default) — S5 wires the pref.
|
||||||
|
in property <int> dimming-mode;
|
||||||
|
|
||||||
|
property <bool> is-active:
|
||||||
|
LyricsState.synced && root.index == LyricsState.active-index;
|
||||||
|
property <bool> is-past:
|
||||||
|
LyricsState.synced
|
||||||
|
&& LyricsState.active-index >= 0
|
||||||
|
&& root.index < LyricsState.active-index;
|
||||||
|
property <int> distance: Math.abs(root.index - LyricsState.active-index);
|
||||||
|
|
||||||
|
// Dimming ladder (Tauri getLineOpacity, LyricsLines.svelte:342-356):
|
||||||
|
// off -> 1.0 all; soft -> 0.6 inactive; strong -> distance 1: 0.5,
|
||||||
|
// 2: 0.35, 3: 0.25, far: 0.15. Unsynced lyrics render the whole list at
|
||||||
|
// 0.85 (LyricsLines.svelte:515-523). With NO active line (-1) the ladder
|
||||||
|
// has no reference point — render full brightness (the S4 engine drives
|
||||||
|
// the index whenever a synced track plays).
|
||||||
|
pure function line-opacity() -> float {
|
||||||
|
if (!LyricsState.synced) {
|
||||||
|
return 0.85;
|
||||||
|
}
|
||||||
|
if (root.is-active) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
if (root.dimming-mode == 0 || LyricsState.active-index < 0) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
if (root.dimming-mode == 1) {
|
||||||
|
return 0.6;
|
||||||
|
}
|
||||||
|
if (root.distance == 1) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
if (root.distance == 2) {
|
||||||
|
return 0.35;
|
||||||
|
}
|
||||||
|
if (root.distance == 3) {
|
||||||
|
return 0.25;
|
||||||
|
}
|
||||||
|
return 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.line-text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
opacity: root.line-opacity();
|
||||||
|
color: root.is-active
|
||||||
|
? Theme.text-primary
|
||||||
|
: (root.is-past ? Theme.text-muted : Theme.text-secondary);
|
||||||
|
font-size: root.is-active ? root.size-active : root.size-inactive;
|
||||||
|
font-weight: root.is-active ? Typography.bold : Typography.medium;
|
||||||
|
// Friendly letter-spacing (Q3): slightly open, never cramped.
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component LyricsLinesView inherits Rectangle {
|
||||||
|
// Owner Q3 layout knobs — 4-6px per side, NEVER removed; the miniplayer
|
||||||
|
// surface later overrides padding/gap without touching display prefs.
|
||||||
|
in property <length> side-padding: 6px;
|
||||||
|
in property <length> line-gap: 12px; // Tauri compact gap parity
|
||||||
|
// 280px-adapted type scale (Q3).
|
||||||
|
in property <length> size-inactive: 13px;
|
||||||
|
in property <length> size-active: 15px;
|
||||||
|
in property <int> dimming-mode: 2; // strong (Tauri default)
|
||||||
|
in property <bool> show-attribution: true;
|
||||||
|
|
||||||
|
// Top/bottom spacers so the first/last SYNCED lines can center once the
|
||||||
|
// S4 auto-scroll lands (Tauri's 40vh spacers, LyricsLines.svelte:510-513).
|
||||||
|
// Fixed-height Rectangles derived from the Flickable's OWN height — Slint
|
||||||
|
// layouts ignore percentage tricks (landing review §7.4), and sibling
|
||||||
|
// geometry would be a binding loop.
|
||||||
|
property <length> spacer-height:
|
||||||
|
Math.max(0px, flick.height / 2 - root.size-active * 1.5);
|
||||||
|
|
||||||
|
flick := Flickable {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
viewport-width: self.width;
|
||||||
|
viewport-height: body.preferred-height;
|
||||||
|
|
||||||
|
body := VerticalLayout {
|
||||||
|
padding-left: root.side-padding;
|
||||||
|
padding-right: root.side-padding;
|
||||||
|
// Unsynced/plain mode is a static top-aligned list (no spacers,
|
||||||
|
// no active line, no scroll-follow — LyricsLines.svelte:515-523).
|
||||||
|
padding-top: LyricsState.synced ? 0px : 10px;
|
||||||
|
padding-bottom: LyricsState.synced ? 0px : 10px;
|
||||||
|
spacing: root.line-gap;
|
||||||
|
|
||||||
|
if LyricsState.synced: Rectangle { height: root.spacer-height; }
|
||||||
|
|
||||||
|
for line[idx] in LyricsState.lines: LyricsLineRow {
|
||||||
|
index: idx;
|
||||||
|
line-text: line.text;
|
||||||
|
size-inactive: root.size-inactive;
|
||||||
|
size-active: root.size-active;
|
||||||
|
dimming-mode: root.dimming-mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if LyricsState.synced: Rectangle { height: root.spacer-height; }
|
||||||
|
|
||||||
|
// Fallback-provider attribution (LRCLIB / lyrics.ovh). Tauri keeps
|
||||||
|
// these in the About modal (AboutModal.svelte:141,144); Slint has
|
||||||
|
// no About surface yet, so the line lives at the end of the scroll.
|
||||||
|
// Qobuz is first-party: provider-label is empty, nothing renders.
|
||||||
|
if root.show-attribution && LyricsState.provider-label != "": Text {
|
||||||
|
text: @tr("Lyrics provided by {}", LyricsState.provider-label);
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
// Right-side Lyrics panel.
|
// Right-side Lyrics panel.
|
||||||
//
|
//
|
||||||
// Rendered as a layout sibling of the content area (see AppShell), so it
|
// Rendered as a layout sibling of the content area (see AppShell), so it
|
||||||
// shrinks the content rather than overlaying it. Closing sets
|
// shrinks the content rather than overlaying it. The 280px column width is
|
||||||
// ShellState.right-panel = 0. The body is a structural placeholder;
|
// owned by AppShell (owner Q3: stays 280px; the typography adapts in
|
||||||
// lyric contents are wired in a later milestone.
|
// LyricsLinesView instead). Closing sets ShellState.lyrics-open = false.
|
||||||
|
//
|
||||||
|
// 1:1 to the Tauri `LyricsSidebar.svelte` header/states (spec §3.1): 36px
|
||||||
|
// mic icon tile + LYRICS overline + "{title} - {artist}" meta + the
|
||||||
|
// display-settings trigger (S5 wires the LyricsControlsFlyout), plus the
|
||||||
|
// Slint-convention close button (deviation D4, homologated with Queue).
|
||||||
|
// Body renders by LyricsState.status: loading / no-lyrics / offline-miss
|
||||||
|
// (typed NotAvailableOffline, F3) / error / the shared lines view.
|
||||||
|
|
||||||
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 { ShellState } from "../state.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
|
import { LyricsLinesView } from "LyricsLinesView.slint";
|
||||||
|
import { ShellState, LyricsState } from "../state.slint";
|
||||||
|
|
||||||
component CloseButton inherits Rectangle {
|
component CloseButton inherits Rectangle {
|
||||||
callback clicked();
|
callback clicked();
|
||||||
@@ -36,30 +45,109 @@ 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).
|
||||||
clip: true;
|
clip: true;
|
||||||
background: Theme.surface-card;
|
background: Theme.surface-card;
|
||||||
|
|
||||||
|
// Conditionally mounted (`if lyrics-open` at the AppShell mount site,
|
||||||
|
// ADR-010) — re-request lyrics for the current track on every open.
|
||||||
|
// Cache-served / no-op while already loaded (Tauri parity: load lands
|
||||||
|
// immediately when opening mid-song, lyricsStore.ts:386-389).
|
||||||
|
init => {
|
||||||
|
LyricsState.panel-opened();
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
// Header row — title + close button.
|
// Header: icon tile + overline/meta block + controls trigger + close.
|
||||||
|
// Taller than the Queue header (56px vs 44px) to fit the Tauri
|
||||||
|
// two-row title block + 36px tile (spec §3.1 rows 3-5).
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: Spacing.md;
|
padding-left: Spacing.sm;
|
||||||
padding-right: Spacing.sm;
|
padding-right: Spacing.sm;
|
||||||
padding-top: Spacing.sm;
|
padding-top: Spacing.sm;
|
||||||
padding-bottom: Spacing.sm;
|
padding-bottom: Spacing.sm;
|
||||||
height: 44px;
|
height: 56px;
|
||||||
Text {
|
spacing: Spacing.sm;
|
||||||
text: @tr("Lyrics");
|
|
||||||
color: Theme.text-primary;
|
// Icon tile — 36px, mic-vocal 18, accent tint.
|
||||||
// Homologated with the Queue / History tab size.
|
VerticalLayout {
|
||||||
font-size: Typography.body;
|
alignment: center;
|
||||||
font-weight: Typography.semibold;
|
Rectangle {
|
||||||
vertical-alignment: center;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/mic-vocal.svg");
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
tint: Theme.accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title block: overline + playing-track meta.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 2px;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
// GroupHeader overline per the alignment standard (ALL-CAPS
|
||||||
|
// 11px / 1.5px letter-spacing — deviation D8 vs Tauri's
|
||||||
|
// 12px/0.1em, forced by the binding standard).
|
||||||
|
text: @tr("LYRICS");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
if LyricsState.track-title != "": Text {
|
||||||
|
text: LyricsState.track-title + " - " + LyricsState.track-artist;
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display-settings trigger — the LyricsControlsFlyout lands in S5.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
HeaderIconButton {
|
||||||
|
icon: @image-url("../assets/icons/sliders-horizontal.svg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
@@ -75,13 +163,77 @@ export component LyricsSidebar inherits Rectangle {
|
|||||||
background: Theme.border-subtle;
|
background: Theme.border-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body — placeholder until lyric contents are wired.
|
// Body — by LyricsState.status.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
Text {
|
clip: true;
|
||||||
text: @tr("No lyrics");
|
|
||||||
color: Theme.text-muted;
|
// Loading: 24px spinner + label (Tauri LyricsSidebar.svelte:68-92).
|
||||||
font-size: Typography.body;
|
if LyricsState.status == 1: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 10px;
|
||||||
|
padding: Spacing.md;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
LoadingSpinner {
|
||||||
|
size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Fetching lyrics...");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No lyrics — idle, not-found, or a ready result that parsed to
|
||||||
|
// zero lines.
|
||||||
|
if LyricsState.status == 0
|
||||||
|
|| LyricsState.status == 3
|
||||||
|
|| (LyricsState.status == 2 && LyricsState.lines.length == 0): VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
padding: Spacing.md;
|
||||||
|
Text {
|
||||||
|
text: @tr("No lyrics available");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline with nothing cached — the typed NotAvailableOffline
|
||||||
|
// outcome (F3: a translated string, never hardcoded EN).
|
||||||
|
if LyricsState.status == 5: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
padding: Spacing.md;
|
||||||
|
Text {
|
||||||
|
text: @tr("Lyrics unavailable offline");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error — surfaced backend text, standard error tone.
|
||||||
|
if LyricsState.status == 4: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
padding: Spacing.md;
|
||||||
|
Text {
|
||||||
|
text: LyricsState.error;
|
||||||
|
color: #e0564f;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics body — the shared lines renderer.
|
||||||
|
if LyricsState.status == 2 && LyricsState.lines.length > 0: LyricsLinesView {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2855,6 +2855,43 @@ export global NowPlayingState {
|
|||||||
callback qconnect-toggle();
|
callback qconnect-toggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One rendered lyrics line. `time-ms`/`end-ms` are milliseconds from track
|
||||||
|
// start (-1 = unknown / plain line). `has-words` flags native Qobuz word
|
||||||
|
// timing held Rust-side (`crate::lyrics::CURRENT_DOC`) — the S4 sync engine
|
||||||
|
// reads it for the word-anchored karaoke fill; word data itself never
|
||||||
|
// crosses into the UI model.
|
||||||
|
export struct LyricsLineItem {
|
||||||
|
text: string,
|
||||||
|
time-ms: int,
|
||||||
|
end-ms: int,
|
||||||
|
has-words: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics panel state. The Rust glue (`crate::lyrics`) pushes fetch results
|
||||||
|
// here on the de-duped track-change edge; the sidebar binds directly. The
|
||||||
|
// S4 sync engine drives `active-index` (placeholder -1 until then).
|
||||||
|
export global LyricsState {
|
||||||
|
// 0 idle · 1 loading · 2 ready · 3 no lyrics · 4 error · 5 offline-unavailable
|
||||||
|
in property <int> status: 0;
|
||||||
|
in property <[LyricsLineItem]> lines: [];
|
||||||
|
in property <bool> synced: false;
|
||||||
|
// Provider id ("qobuz" | "lrclib" | "ovh"); `provider-label` is the
|
||||||
|
// attribution display name — empty for first-party Qobuz (no attribution
|
||||||
|
// needed, spec §3.5).
|
||||||
|
in property <string> provider;
|
||||||
|
in property <string> provider-label;
|
||||||
|
// Playing-track meta for the header ("{title} - {artist}").
|
||||||
|
in property <string> track-title;
|
||||||
|
in property <string> track-artist;
|
||||||
|
// Surfaced backend error text (status == 4) — dynamic content, not chrome.
|
||||||
|
in property <string> error;
|
||||||
|
// Active line index; -1 = none. The S4 sync engine drives it.
|
||||||
|
in property <int> active-index: -1;
|
||||||
|
// Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests
|
||||||
|
// lyrics for the current track (cache-served when already fetched).
|
||||||
|
callback panel-opened();
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// event sink from `session.renderers`.
|
// event sink from `session.renderers`.
|
||||||
export struct QconnectDevice {
|
export struct QconnectDevice {
|
||||||
|
|||||||
Reference in new issue
Block a user