Files
qbz/crates/qbz-slint/ui/shell/LyricsLinesView.slint
T
vicrodh 5a4dbd87f6 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
2026-06-10 16:46:13 -06:00

145 lines
6.1 KiB
Plaintext

// 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;
}
}
}
}