Files
qbz/crates/qbz-slint/ui/shell/LyricsSidebar.slint
T
vicrodh eea336fd49 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
2026-06-10 18:00:29 -06:00

270 lines
11 KiB
Plaintext

// Right-side Lyrics panel.
//
// Rendered as a layout sibling of the content area (see AppShell), so it
// 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 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, 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 {
callback clicked();
width: 28px;
height: 28px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 15px;
height: 15px;
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: 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.sm;
padding-right: Spacing.sm;
padding-top: Spacing.sm;
padding-bottom: Spacing.sm;
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 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;
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 {
alignment: center;
CloseButton {
clicked => {
ShellState.lyrics-open = false;
}
}
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// Body — by LyricsState.status.
Rectangle {
vertical-stretch: 1;
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, 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;
}
}
}
}