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:
vicrodh committed 2026-06-10 16:46:13 -06:00
1 parent d12039371b
commit 5a4dbd87f6
11 files changed
+793 -20

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// 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
// 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;
}
}
}
}
+171 -19
View File
@@ -1,16 +1,25 @@
// Right-side Lyrics panel.
//
// Rendered as a layout sibling of the content area (see AppShell), so it
// shrinks the content rather than overlaying it. Closing sets
// ShellState.right-panel = 0. The body is a structural placeholder;
// lyric contents are wired in a later milestone.
// shrinks the content rather than overlaying it. The 280px column width is
// owned by AppShell (owner Q3: stays 280px; the typography adapts in
// 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 { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.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 {
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 {
// 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).
clip: true;
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 {
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 {
padding-left: Spacing.md;
padding-left: Spacing.sm;
padding-right: Spacing.sm;
padding-top: Spacing.sm;
padding-bottom: Spacing.sm;
height: 44px;
Text {
text: @tr("Lyrics");
color: Theme.text-primary;
// Homologated with the Queue / History tab size.
font-size: Typography.body;
font-weight: Typography.semibold;
vertical-alignment: center;
height: 56px;
spacing: Spacing.sm;
// Icon tile — 36px, mic-vocal 18, accent tint.
VerticalLayout {
alignment: center;
Rectangle {
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;
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 {
alignment: center;
@@ -75,13 +163,77 @@ export component LyricsSidebar inherits Rectangle {
background: Theme.border-subtle;
}
// Body — placeholder until lyric contents are wired.
// Body — by LyricsState.status.
Rectangle {
vertical-stretch: 1;
Text {
text: @tr("No lyrics");
color: Theme.text-muted;
font-size: Typography.body;
clip: true;
// Loading: 24px spinner + label (Tauri LyricsSidebar.svelte:68-92).
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%;
}
}
}
+37
View File
@@ -2855,6 +2855,43 @@ export global NowPlayingState {
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
// event sink from `session.renderers`.
export struct QconnectDevice {