New QbzSelect row (Auto (account language) + en es fr de it pt nl ja ru,
the owner-approved list) after the Size row; language names are native
literals, the same convention as the Appearance dropdown. The options
array lives on LyricsState (reseeded from Rust on language change) and
the choice persists per-user in lyrics_prefs.json as translation_language
("auto" or an ISO 639-1 code), sanitized on load (unknown -> auto) and
round-tripped through apply_to_ui/persist_from_ui like the other prefs.
Flyout popup height grows by the new row (and the comment now counts the
lite-fill row it had missed).
km
368 lines
15 KiB
Plaintext
368 lines
15 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, ArtworkActions, AppearanceState, TooltipState } from "../state.slint";
|
|
|
|
// Floating translate toggle (Qobuz v10, owner decision 2026-07-22): circular
|
|
// button anchored bottom-right of the lyrics body, overlaying it — mobile
|
|
// parity (the 文A button on iOS/Android). Off = dim, on = accent. Tooltip
|
|
// rides the shared TooltipState channel like the bar's IconButton.
|
|
component TranslateButton inherits Rectangle {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 18px;
|
|
background: LyricsState.show-translation
|
|
? Theme.accent.with-alpha(0.15)
|
|
: Theme.surface-card;
|
|
border-width: 1px;
|
|
border-color: LyricsState.show-translation ? Theme.accent : Theme.border-muted;
|
|
drop-shadow-blur: 12px;
|
|
drop-shadow-color: Theme.card-shadow;
|
|
drop-shadow-offset-y: 3px;
|
|
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/languages.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: LyricsState.show-translation
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
LyricsState.toggle-translation();
|
|
}
|
|
changed has-hover => {
|
|
if (self.has-hover) {
|
|
TooltipState.text = LyricsState.show-translation
|
|
? @tr("Hide lyrics translation")
|
|
: @tr("Show lyrics translation");
|
|
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
|
|
TooltipState.anchor-y = root.absolute-position.y;
|
|
TooltipState.show = true;
|
|
} else {
|
|
TooltipState.show = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: ShellState.app-background-active
|
|
? Theme.surface-card.with-alpha(AppearanceState.app-background-surface-alpha)
|
|
: 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;
|
|
|
|
// 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+30+44+22+22+30+34 =
|
|
// 264, incl. lite-fill + translation language) +
|
|
// spacing 12 x 8 = 96.
|
|
height: 388px;
|
|
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;
|
|
spacing: 12px;
|
|
Text {
|
|
text: @tr("No lyrics available");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
horizontal-alignment: center;
|
|
wrap: word-wrap;
|
|
}
|
|
// Crowd-source nudge — LRCLIB is community-contributed, so a
|
|
// miss is an invitation to add them. Small, with a clickable
|
|
// link to the LRCLIB submission tool.
|
|
VerticalLayout {
|
|
spacing: 2px;
|
|
Text {
|
|
text: @tr("Missing lyrics? You can contribute them to LRCLIB:");
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
horizontal-alignment: center;
|
|
wrap: word-wrap;
|
|
}
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
link-ta := TouchArea {
|
|
width: link-text.preferred-width;
|
|
height: link-text.preferred-height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
ArtworkActions.open-in-browser("https://lrclibup.boidu.dev/");
|
|
}
|
|
link-text := Text {
|
|
text: "lrclibup.boidu.dev";
|
|
color: link-ta.has-hover ? Theme.accent-hover : Theme.accent;
|
|
font-size: 10px;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
// Floating translate toggle — bottom-right overlay of the lyrics
|
|
// body (mobile parity; NOT in the header, NOT in the flyout).
|
|
// Mounted only while the current doc advertises translations;
|
|
// otherwise the sidebar renders exactly as before.
|
|
if LyricsState.translation-available: TranslateButton {
|
|
x: parent.width - self.width - 12px;
|
|
y: parent.height - self.height - 12px;
|
|
}
|
|
}
|
|
|
|
// Source attribution — a tiny line pinned at the very bottom, always
|
|
// shown once lyrics are present (incl. Qobuz). "Lyrics source:" in
|
|
// muted + the provider name in accent. Smallest legible size.
|
|
if LyricsState.status == 2 && LyricsState.lines.length > 0: HorizontalLayout {
|
|
alignment: start;
|
|
padding-left: Spacing.sm;
|
|
padding-right: Spacing.sm;
|
|
padding-top: 3px;
|
|
padding-bottom: 5px;
|
|
spacing: 3px;
|
|
Text {
|
|
text: @tr("Lyrics source") + ":";
|
|
color: Theme.text-muted;
|
|
font-size: 9px;
|
|
vertical-alignment: center;
|
|
}
|
|
Text {
|
|
text: LyricsState.provider == "lrclib" ? "LRCLIB"
|
|
: LyricsState.provider == "ovh" ? "lyrics.ovh"
|
|
: "Qobuz";
|
|
color: Theme.accent;
|
|
font-size: 9px;
|
|
font-weight: Typography.semibold;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|