feat(slint): lyrics controls flyout, per-user prefs, cache settings row

Sidebar header gains the controls flyout (auto-follow, font size on the
280px-adapted scale, dimming, active color, uppercase, copy lyrics,
reset). Every setting persists per user via the new lyrics_prefs store
(loaded on all session activation paths, applied into LyricsState,
Tauri defaults). Settings gains the lyrics cache row with honest per-user
stats and clear (the F1 fix shipped in qbz-lyrics). Parity and @tr sweep
over the sidebar surfaces per the review inventory.

Closes the S1-S5 implementation run of the Lyrics epic: Qobuz-first
word-synced lyrics (real per-word timestamps - richer than Tauri's
line-level estimation), LRCLIB/lyrics.ovh fallback, shared per-user cache
with additive wsync column, 30Hz sync engine with word-anchored karaoke
fill, auto-center scroll, and QConnect peer-following.

cc
This commit is contained in:
vicrodh committed 2026-06-10 18:00:29 -06:00
1 parent 5f9fd5bdd6
commit eea336fd49
9 files changed
+867 -43

No files matched your search

@@ -19,7 +19,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Palette } from "../foundation/palette.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { OfflineManagerActions, OfflineModeActions, OfflineState, SettingsState } from "../state.slint";
import { LyricsState, OfflineManagerActions, OfflineModeActions, OfflineState, SettingsState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { SettingRow } from "SettingRow.slint";
@@ -74,9 +74,11 @@ export component OfflineSettings inherits VerticalLayout {
spacing: 4px;
// Re-seed the toggle states from the engine store on every mount of
// this section (same lazy-load hook LocalLibrarySettings uses).
// this section (same lazy-load hook LocalLibrarySettings uses); also
// refresh the lyrics-cache stats for the LYRICS group below.
init => {
OfflineModeActions.load();
LyricsState.cache-refresh();
}
GroupHeader { text: @tr("OFFLINE MODE"); }
@@ -162,4 +164,27 @@ export component OfflineSettings inherits VerticalLayout {
}
}
}
Rectangle { height: 12px; }
Divider { }
Rectangle { height: 12px; }
// Lyrics cache (Tauri Settings > Storage row, SettingsView.svelte:6539-
// 6562). Stats come from the REAL per-user lyrics.db via qbz_lyrics
// cache_stats — fix-forward F1 (Tauri measured a stale global path).
GroupHeader { text: @tr("LYRICS"); }
SettingRow {
label: @tr("Clear lyrics cache");
description: LyricsState.cache-loaded
? @tr("{} entries using {}", LyricsState.cache-entries-text, LyricsState.cache-size)
: "";
SecondaryButton {
label: @tr("Clear");
danger: true;
clicked => {
LyricsState.cache-clear();
}
}
}
}
@@ -0,0 +1,216 @@
// Lyrics display-settings flyout panel — 1:1 to Tauri's
// `LyricsControlsPopover.svelte` (268px popover below-right of the sliders
// trigger), ADAPTED to the Slint flyout idiom (alignment standard "Modals &
// flyouts": padding 14px / spacing 12px, toggle rows = label
// `horizontal-stretch: 1` + control trailing, footer actions right-aligned
// `alignment: end` with the shared auto-width SecondaryButton; selects are
// `QbzSelect` with explicit menu-width — never pill option buttons, ADR-008,
// so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects).
//
// Row order is Tauri's exactly: Auto-follow / Font / Size / Active color /
// Uppercase / Dimming / footer (Copy lyrics + Reset).
//
// Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint
// has no native color-picker input); the "Theme" reset link shows only while
// a custom color is set (exact Tauri chip semantics). Q6 RESOLVED SHIPPED —
// the font row is included: the four non-system families are bundled TTFs
// embedded at compile time (see LyricsLinesView.slint).
//
// This component is the popup CONTENT; the PopupWindow shell (explicit
// height + close-policy, the documented footgun) lives at the anchor site in
// LyricsSidebar. Every mutation writes the LyricsState in-out pref directly
// (live preview) and fires prefs-changed() so Rust persists.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzTextLink } from "../primitives/QbzTextLink.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LyricsState } from "../state.slint";
// One selectable color swatch (D6 palette). Ring-highlighted while it is the
// active custom color.
component ColorSwatch inherits Rectangle {
in property <color> swatch;
property <bool> selected:
LyricsState.use-custom-color && LyricsState.custom-color == root.swatch;
width: 18px;
height: 18px;
border-radius: 9px;
background: root.swatch;
border-width: root.selected ? 2px : (ta.has-hover ? 1px : 0px);
border-color: root.selected ? Theme.text-primary : Theme.border-muted;
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LyricsState.use-custom-color = true;
LyricsState.custom-color = root.swatch;
LyricsState.prefs-changed();
}
}
}
component RowLabel inherits Text {
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
export component LyricsControlsPanel inherits Rectangle {
// Copy gate — Tauri canCopy: loaded && !error && lines > 0
// (LyricsSidebar.svelte:35).
property <bool> can-copy:
LyricsState.status == 2 && LyricsState.lines.length > 0;
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 24px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding: 14px;
spacing: 12px;
// Auto-follow.
HorizontalLayout {
height: 22px;
spacing: 8px;
RowLabel { text: @tr("Auto-follow"); }
QbzToggle {
checked: LyricsState.auto-follow;
toggled(v) => {
LyricsState.auto-follow = v;
LyricsState.prefs-changed();
}
}
}
// Font (Q6 shipped — embedded families).
HorizontalLayout {
height: 30px;
spacing: 8px;
RowLabel { text: @tr("Font"); }
QbzSelect {
sm: true;
menu-width: 178px;
options: [
@tr("System"),
@tr("LINE Seed JP"),
@tr("Montserrat"),
@tr("Noto Sans"),
@tr("Source Sans 3"),
];
current-index: LyricsState.font-index;
selected(i) => {
LyricsState.font-index = i;
LyricsState.prefs-changed();
}
}
}
// Size (Tauri segmented S/M/L/XL -> select, flyout idiom).
HorizontalLayout {
height: 30px;
spacing: 8px;
RowLabel { text: @tr("Size"); }
QbzSelect {
sm: true;
menu-width: 120px;
options: [@tr("S"), @tr("M"), @tr("L"), @tr("XL")];
current-index: LyricsState.size-index;
selected(i) => {
LyricsState.size-index = i;
LyricsState.prefs-changed();
}
}
}
// Active color — label (+ "Theme" reset while custom), swatch palette
// underneath, right-aligned on the control grid (D6).
VerticalLayout {
height: 44px;
spacing: 8px;
HorizontalLayout {
spacing: 8px;
RowLabel { text: @tr("Active color"); }
if LyricsState.use-custom-color: QbzTextLink {
label: @tr("Theme");
clicked => {
LyricsState.use-custom-color = false;
LyricsState.prefs-changed();
}
}
}
HorizontalLayout {
alignment: end;
spacing: 6px;
ColorSwatch { swatch: #8b5cf6; }
ColorSwatch { swatch: #ec4899; }
ColorSwatch { swatch: #ef4444; }
ColorSwatch { swatch: #f97316; }
ColorSwatch { swatch: #eab308; }
ColorSwatch { swatch: #22c55e; }
ColorSwatch { swatch: #06b6d4; }
ColorSwatch { swatch: #3b82f6; }
}
}
// Uppercase.
HorizontalLayout {
height: 22px;
spacing: 8px;
RowLabel { text: @tr("Uppercase"); }
QbzToggle {
checked: LyricsState.uppercase;
toggled(v) => {
LyricsState.uppercase = v;
LyricsState.prefs-changed();
}
}
}
// Dimming (Tauri segmented Off/Soft/Strong -> select).
HorizontalLayout {
height: 30px;
spacing: 8px;
RowLabel { text: @tr("Dimming"); }
QbzSelect {
sm: true;
menu-width: 130px;
options: [@tr("Off"), @tr("Soft"), @tr("Strong")];
current-index: LyricsState.dimming-mode;
selected(i) => {
LyricsState.dimming-mode = i;
LyricsState.prefs-changed();
}
}
}
// Footer: Copy lyrics (gated) + Reset, right-aligned (standard).
HorizontalLayout {
height: 34px;
alignment: end;
spacing: 8px;
SecondaryButton {
label: @tr("Copy lyrics");
enabled: root.can-copy;
clicked => {
LyricsState.copy-lyrics();
}
}
SecondaryButton {
label: @tr("Reset");
clicked => {
LyricsState.reset-prefs();
}
}
}
}
}
@@ -33,6 +33,21 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LyricsState } from "../state.slint";
// Q6 (SHIPPED): Tauri's four non-system lyric font families, embedded into
// the binary at compile time (slint-build default EmbedAllResources for Rust
// output -> RegisterCustomFontByMemory at window setup — the same mechanism
// as the app-wide Inter imports in app.slint). Family names inside the TTFs
// match Tauri's CSS stacks exactly: "LINE Seed JP" / "Montserrat" /
// "Noto Sans" / "Source Sans 3" (verified via fc-scan). The variable-font
// weight axis IS applied at raster time (femtovg's swash scaler receives the
// fontique normalized coords), so Montserrat's Thin-default fvar is harmless.
// "System" = empty font-family -> the window default (Inter), 1:1 with
// Tauri's 'system' stack.
import "../../../../static/fonts/LINESeedJP-Regular.ttf";
import "../../../../static/fonts/Montserrat-VariableFont_wght.ttf";
import "../../../../static/fonts/NotoSans-VariableFont_wdth,wght.ttf";
import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf";
// 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
@@ -44,8 +59,18 @@ component LyricsLineRow inherits VerticalLayout {
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.
// 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5).
in property <int> dimming-mode;
// S5 display prefs: uppercase transform, font family ("" = window
// default), karaoke fill / active color (theme accent or D6 swatch).
in property <bool> uppercase;
in property <string> font-name;
in property <color> active-color;
// Tauri renders uppercase via CSS text-transform; Slint transforms the
// string (display only — the engine still matches on the raw text).
property <string> display-text:
root.uppercase ? root.line-text.to-uppercase() : root.line-text;
// Active-row geometry for the view's auto-center scroll. y/height are
// LAYOUT-relative (self.y within the body VerticalLayout) so the
@@ -103,7 +128,7 @@ component LyricsLineRow inherits VerticalLayout {
}
base := Text {
text: root.line-text;
text: root.display-text;
wrap: word-wrap;
opacity: root.line-opacity();
// Active base = the DIM (unsung) layer under the karaoke overlay
@@ -111,6 +136,7 @@ component LyricsLineRow inherits VerticalLayout {
color: root.is-active
? Theme.text-secondary
: (root.is-past ? Theme.text-muted : Theme.text-secondary);
font-family: root.font-name;
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.
@@ -134,9 +160,10 @@ component LyricsLineRow inherits VerticalLayout {
y: 0;
width: base.width;
height: base.height;
text: root.line-text;
text: root.display-text;
wrap: word-wrap;
color: Theme.accent;
color: root.active-color;
font-family: root.font-name;
font-size: root.size-active;
font-weight: Typography.bold;
letter-spacing: 0.2px;
@@ -150,14 +177,20 @@ export component LyricsLinesView inherits Rectangle {
// 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).
// 280px-adapted type scale (Q3). The sidebar maps the persisted S/M/L/XL
// pref onto these; the miniplayer surface later keeps the defaults
// (Tauri plumbs NO display prefs there, spec §3.6).
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;
// Auto-center the active line (S5 wires the persisted pref; Tauri
// default true, lyricsDisplayStore.ts:37-44).
// Auto-center the active line (persisted pref; Tauri default true,
// lyricsDisplayStore.ts:37-44).
in property <bool> auto-follow: true;
// S5 display prefs (defaults = Tauri's).
in property <bool> uppercase: false;
in property <string> font-name: ""; // "" = window default font
in property <color> active-color: Theme.accent;
// ---- Auto-center scroll state ----
// Last index the view centered on; -1 = nothing yet -> next center is
@@ -243,6 +276,9 @@ export component LyricsLinesView inherits Rectangle {
size-inactive: root.size-inactive;
size-active: root.size-active;
dimming-mode: root.dimming-mode;
uppercase: root.uppercase;
font-name: root.font-name;
active-color: root.active-color;
report-active-geometry(report-idx, row-y, row-h) => {
root.center-active(report-idx, row-y, row-h);
}
+62 -33
View File
@@ -7,18 +7,22 @@
//
// 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
// display-settings trigger opening the LyricsControlsFlyout (S5), 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.
// (typed NotAvailableOffline, F3) / error / the shared lines view, which
// receives the persisted display prefs (size scale / dimming / uppercase /
// font / active color / auto-follow) from LyricsState.
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 { IconButton } from "../primitives/BarControls.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { LyricsLinesView } from "LyricsLinesView.slint";
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
import { ShellState, LyricsState } from "../state.slint";
component CloseButton inherits Rectangle {
@@ -45,33 +49,6 @@ 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).
@@ -142,11 +119,38 @@ export component LyricsSidebar inherits Rectangle {
}
}
// Display-settings trigger — the LyricsControlsFlyout lands in S5.
// Display-settings trigger + the controls flyout. IconButton
// (PlayerBar primitive) for the shared tooltip channel — Tauri's
// aria/tooltip 'Display settings'. PopupWindow footgun handled:
// explicit width/height + close-policy; right edge aligned with
// the close button's right edge (Tauri: right: 8px), 8px below
// the trigger (Tauri: top calc(100% + 8px)).
VerticalLayout {
alignment: center;
HeaderIconButton {
icon: @image-url("../assets/icons/sliders-horizontal.svg");
Rectangle {
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/sliders-horizontal.svg");
icon-size: 16px;
btn-width: 32px;
tooltip: @tr("Display settings");
clicked => {
controls-flyout.show();
}
}
controls-flyout := PopupWindow {
// Right-align with the close button right edge:
// trigger 32px + spacing 8px + close 28px - width.
x: 32px + 8px + 28px - 260px;
y: 32px + 8px;
width: 260px;
// padding 28 + rows (22+30+30+44+22+30+34 = 212) +
// spacing 12 x 6 = 72.
height: 312px;
close-policy: PopupClosePolicy.close-on-click-outside;
LyricsControlsPanel { }
}
}
}
VerticalLayout {
@@ -230,10 +234,35 @@ export component LyricsSidebar inherits Rectangle {
}
}
// Lyrics body — the shared lines renderer.
// Lyrics body — the shared lines renderer, fed the persisted
// display prefs. Size matrix = the 280px-adapted scale (Q3):
// the S3 13/15px values are the 'medium' tier, with Tauri's
// relative steps (compact matrix, LyricsLines.svelte:769-792)
// applied around it: S 11/13 · M 13/15 · L 16/19 · XL 20/24.
if LyricsState.status == 2 && LyricsState.lines.length > 0: LyricsLinesView {
width: 100%;
height: 100%;
size-inactive: LyricsState.size-index == 0 ? 11px
: LyricsState.size-index == 2 ? 16px
: LyricsState.size-index == 3 ? 20px
: 13px;
size-active: LyricsState.size-index == 0 ? 13px
: LyricsState.size-index == 2 ? 19px
: LyricsState.size-index == 3 ? 24px
: 15px;
dimming-mode: LyricsState.dimming-mode;
auto-follow: LyricsState.auto-follow;
uppercase: LyricsState.uppercase;
// Family names as registered by the embedded TTFs; "" =
// window default = Tauri's 'system' option.
font-name: LyricsState.font-index == 1 ? "LINE Seed JP"
: LyricsState.font-index == 2 ? "Montserrat"
: LyricsState.font-index == 3 ? "Noto Sans"
: LyricsState.font-index == 4 ? "Source Sans 3"
: "";
active-color: LyricsState.use-custom-color
? LyricsState.custom-color
: Theme.accent;
}
}
}
+38
View File
@@ -2901,6 +2901,44 @@ export global LyricsState {
// 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();
// ---- Display prefs (S5) -------------------------------------------------
// 1:1 to Tauri's lyricsDisplayStore (defaults lyricsDisplayStore.ts:37-44),
// persisted per-user as JSON by `crate::lyrics_prefs` and seeded on every
// session activation. The flyout mutates these in-out props directly and
// fires `prefs-changed()`; Rust reads them back and persists.
in-out property <bool> auto-follow: true;
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
// (Q6 SHIPPED: the four families are bundled TTFs embedded at compile time
// in LyricsLinesView.slint; "System" = empty font-family -> window default).
in-out property <int> font-index: 0;
// 0 S · 1 M · 2 L · 3 XL — Tauri default 'medium'. Px mapping (the
// 280px-adapted scale, Q3) lives in LyricsSidebar.
in-out property <int> size-index: 1;
// 0 off · 1 soft · 2 strong (Tauri default 'strong').
in-out property <int> dimming-mode: 2;
in-out property <bool> uppercase: false;
// Active (karaoke fill) color. `use-custom-color: false` = follow the theme
// accent (Tauri activeColor: ''). The palette swatches set both (deviation
// D6: swatches instead of a native color input — Slint has none).
in-out property <bool> use-custom-color: false;
in-out property <color> custom-color: #8b5cf6;
// Fired by the flyout after ANY pref mutation -> Rust persists the set.
callback prefs-changed();
// Footer actions: join current lines -> clipboard (+ toast) / restore the
// Tauri defaults (+ persist + re-seed).
callback copy-lyrics();
callback reset-prefs();
// ---- Lyrics cache row (Settings > Offline, S5) --------------------------
// Stats read from the REAL per-user lyrics.db via qbz_lyrics cache_stats
// (fix-forward F1 — Tauri measured a stale global path). Pre-formatted in
// Rust; `cache-loaded` gates the description line.
in property <bool> cache-loaded: false;
in property <string> cache-entries-text;
in property <string> cache-size;
callback cache-refresh();
callback cache-clear();
}
// One row in the QConnect device picker (a session renderer). Populated by the