feat(lyrics): active-line color picker, uppercase toggle, bigger L/XL

Add two persisted prefs to the lyrics sidebar display controls:

- activeColor: hex color override for the karaoke/active-line accent.
  Empty string means follow the theme's --accent-primary. Rendered via
  a new --lyrics-active-color CSS custom property set inline on the
  LyricsLines container; all four references to --accent-primary in
  the active-line gradient and glow shadows now fall back through it.
  Popover row shows a native color input plus a 'Theme' clear button
  that appears only when a custom color is set.

- uppercase: render all lines as uppercase in the sidebar. Applied via
  text-transform on .lyrics-lines.compact.uppercase. Switch toggle in
  the popover.

Bump L and XL sidebar sizes by the requested amount:
- L: 17/20 -> 18/21
- XL: 20/24 -> 22/26
(small and medium are unchanged.)

Both new prefs persist per-user in the same localStorage payload via
the existing sanitize/reload flow, and the hex validator rejects
malformed values on read.

cc
This commit is contained in:
vicrodh committed 2026-04-18 12:41:34 -06:00
1 parent 7480df9ede
commit 6b91bd3872
10 files changed
+192 -13

No files matched your search

@@ -10,6 +10,8 @@
setLyricsFont, setLyricsFont,
setLyricsFontSize, setLyricsFontSize,
setLyricsDimming, setLyricsDimming,
setLyricsActiveColor,
setLyricsUppercase,
resetLyricsDisplay, resetLyricsDisplay,
type LyricsFont, type LyricsFont,
type LyricsFontSize, type LyricsFontSize,
@@ -82,6 +84,14 @@
if (match) setLyricsFont(match.value); if (match) setLyricsFont(match.value);
} }
/** Fallback hex shown in the color input when the user hasn't picked one yet. */
const FALLBACK_COLOR = '#8b5cf6';
function handleActiveColorInput(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
setLyricsActiveColor(target.value);
}
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (!open) return; if (!open) return;
const target = event.target as Node; const target = event.target as Node;
@@ -162,6 +172,45 @@
</div> </div>
</div> </div>
<div class="row">
<span class="label">{$t('player.lyricsControls.activeColor')}</span>
<div class="color-wrap">
{#if prefs.activeColor}
<button
type="button"
class="color-clear"
aria-label={$t('player.lyricsControls.useThemeColor')}
title={$t('player.lyricsControls.useThemeColor')}
onclick={() => setLyricsActiveColor('')}
>
{$t('player.lyricsControls.useThemeColor')}
</button>
{/if}
<input
type="color"
class="color-input"
aria-label={$t('player.lyricsControls.activeColor')}
value={prefs.activeColor || FALLBACK_COLOR}
oninput={handleActiveColorInput}
/>
</div>
</div>
<div class="row">
<span class="label">{$t('player.lyricsControls.uppercase')}</span>
<button
type="button"
class="switch"
class:on={prefs.uppercase}
role="switch"
aria-checked={prefs.uppercase}
aria-label={$t('player.lyricsControls.uppercase')}
onclick={() => setLyricsUppercase(!prefs.uppercase)}
>
<span class="switch-thumb"></span>
</button>
</div>
<div class="row"> <div class="row">
<span class="label">{$t('player.lyricsControls.dimming')}</span> <span class="label">{$t('player.lyricsControls.dimming')}</span>
<div class="segmented" role="group" aria-label={$t('player.lyricsControls.dimming')}> <div class="segmented" role="group" aria-label={$t('player.lyricsControls.dimming')}>
@@ -276,6 +325,53 @@
border-radius: 6px; border-radius: 6px;
} }
.color-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.color-input {
width: 36px;
height: 24px;
padding: 0;
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
background: var(--bg-secondary);
cursor: pointer;
overflow: hidden;
}
/* Remove the default inner padding/border of the color input to show a clean swatch. */
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.color-input::-moz-color-swatch {
border: none;
border-radius: 4px;
}
.color-clear {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-family: var(--font-sans);
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.color-clear:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.segmented { .segmented {
display: inline-flex; display: inline-flex;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
+20 -9
View File
@@ -25,6 +25,8 @@
fontMode?: LyricsFont; fontMode?: LyricsFont;
fontSizeMode?: LyricsFontSize; fontSizeMode?: LyricsFontSize;
dimmingMode?: LyricsDimming; dimmingMode?: LyricsDimming;
activeColor?: string;
uppercase?: boolean;
} }
let { let {
@@ -39,7 +41,9 @@
isSynced = false, isSynced = false,
fontMode, fontMode,
fontSizeMode, fontSizeMode,
dimmingMode dimmingMode,
activeColor,
uppercase = false
}: Props = $props(); }: Props = $props();
// Calculate line duration for CSS animation (immersive mode only) // Calculate line duration for CSS animation (immersive mode only)
@@ -162,6 +166,8 @@
class:font-montserrat={compact && fontMode === 'montserrat'} class:font-montserrat={compact && fontMode === 'montserrat'}
class:font-noto-sans={compact && fontMode === 'noto-sans'} class:font-noto-sans={compact && fontMode === 'noto-sans'}
class:font-source-sans-3={compact && fontMode === 'source-sans-3'} class:font-source-sans-3={compact && fontMode === 'source-sans-3'}
class:uppercase={compact && uppercase}
style:--lyrics-active-color={compact && activeColor ? activeColor : null}
bind:this={container} bind:this={container}
> >
{#if lines.length === 0} {#if lines.length === 0}
@@ -369,8 +375,8 @@
text-shadow: text-shadow:
0 1px 3px rgba(0, 0, 0, 0.6), 0 1px 3px rgba(0, 0, 0, 0.6),
0 2px 10px rgba(0, 0, 0, 0.4), 0 2px 10px rgba(0, 0, 0, 0.4),
0 0 40px color-mix(in srgb, var(--accent-primary) 40%, transparent), 0 0 40px color-mix(in srgb, var(--lyrics-active-color, var(--accent-primary)) 40%, transparent),
0 0 80px color-mix(in srgb, var(--accent-primary) 20%, transparent); 0 0 80px color-mix(in srgb, var(--lyrics-active-color, var(--accent-primary)) 20%, transparent);
} }
.lyrics-lines.center .lyrics-line.active { .lyrics-lines.center .lyrics-line.active {
@@ -382,8 +388,8 @@
--progress-pos: calc(var(--line-progress, 0) * 100%); --progress-pos: calc(var(--line-progress, 0) * 100%);
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--accent-primary) 0%, var(--lyrics-active-color, var(--accent-primary)) 0%,
var(--accent-primary) var(--progress-pos), var(--lyrics-active-color, var(--accent-primary)) var(--progress-pos),
var(--text-primary) var(--progress-pos), var(--text-primary) var(--progress-pos),
var(--text-primary) 100% var(--text-primary) 100%
); );
@@ -413,16 +419,21 @@
font-size: 17px; font-size: 17px;
} }
.lyrics-lines.compact.size-large .lyrics-line { .lyrics-lines.compact.size-large .lyrics-line {
font-size: 17px; font-size: 18px;
} }
.lyrics-lines.compact.size-large .lyrics-line.active { .lyrics-lines.compact.size-large .lyrics-line.active {
font-size: 20px; font-size: 21px;
} }
.lyrics-lines.compact.size-xl .lyrics-line { .lyrics-lines.compact.size-xl .lyrics-line {
font-size: 20px; font-size: 22px;
} }
.lyrics-lines.compact.size-xl .lyrics-line.active { .lyrics-lines.compact.size-xl .lyrics-line.active {
font-size: 24px; font-size: 26px;
}
/* Sidebar uppercase override (compact mode only) */
.lyrics-lines.compact.uppercase .lyrics-line {
text-transform: uppercase;
} }
/* Sidebar font family overrides (compact mode only) */ /* Sidebar font family overrides (compact mode only) */
@@ -88,6 +88,8 @@
fontMode={prefs.font} fontMode={prefs.font}
fontSizeMode={prefs.fontSize} fontSizeMode={prefs.fontSize}
dimmingMode={prefs.dimming} dimmingMode={prefs.dimming}
activeColor={prefs.activeColor}
uppercase={prefs.uppercase}
/> />
{/if} {/if}
</div> </div>
+3
View File
@@ -628,6 +628,9 @@
"soft": "Leicht", "soft": "Leicht",
"strong": "Stark" "strong": "Stark"
}, },
"activeColor": "Aktive Farbe",
"useThemeColor": "Design",
"uppercase": "Großbuchstaben",
"reset": "Zurücksetzen" "reset": "Zurücksetzen"
}, },
"noLyrics": "Keine Songtexte verfügbar", "noLyrics": "Keine Songtexte verfügbar",
+3
View File
@@ -630,6 +630,9 @@
"soft": "Soft", "soft": "Soft",
"strong": "Strong" "strong": "Strong"
}, },
"activeColor": "Active color",
"useThemeColor": "Theme",
"uppercase": "Uppercase",
"reset": "Reset" "reset": "Reset"
}, },
"noLyrics": "No lyrics available", "noLyrics": "No lyrics available",
+3
View File
@@ -628,6 +628,9 @@
"soft": "Suave", "soft": "Suave",
"strong": "Fuerte" "strong": "Fuerte"
}, },
"activeColor": "Color karaoke",
"useThemeColor": "Tema",
"uppercase": "Mayúsculas",
"reset": "Reiniciar" "reset": "Reiniciar"
}, },
"noLyrics": "No hay letras disponibles", "noLyrics": "No hay letras disponibles",
+3
View File
@@ -628,6 +628,9 @@
"soft": "Légère", "soft": "Légère",
"strong": "Forte" "strong": "Forte"
}, },
"activeColor": "Couleur active",
"useThemeColor": "Thème",
"uppercase": "Majuscules",
"reset": "Réinitialiser" "reset": "Réinitialiser"
}, },
"noLyrics": "Aucune parole disponible", "noLyrics": "Aucune parole disponible",
+3
View File
@@ -628,6 +628,9 @@
"soft": "Suave", "soft": "Suave",
"strong": "Forte" "strong": "Forte"
}, },
"activeColor": "Cor ativa",
"useThemeColor": "Tema",
"uppercase": "Maiúsculas",
"reset": "Redefinir" "reset": "Redefinir"
}, },
"noLyrics": "Letras não disponíveis", "noLyrics": "Letras não disponíveis",
+24 -2
View File
@@ -23,6 +23,8 @@ import {
setLyricsFont, setLyricsFont,
setLyricsFontSize, setLyricsFontSize,
setLyricsDimming, setLyricsDimming,
setLyricsActiveColor,
setLyricsUppercase,
resetLyricsDisplay, resetLyricsDisplay,
DEFAULT_LYRICS_DISPLAY, DEFAULT_LYRICS_DISPLAY,
STORAGE_KEY STORAGE_KEY
@@ -39,7 +41,9 @@ describe('lyricsDisplayStore', () => {
autoFollow: true, autoFollow: true,
font: 'system', font: 'system',
fontSize: 'medium', fontSize: 'medium',
dimming: 'strong' dimming: 'strong',
activeColor: '',
uppercase: false
}); });
}); });
@@ -52,12 +56,16 @@ describe('lyricsDisplayStore', () => {
setLyricsFont('montserrat'); setLyricsFont('montserrat');
setLyricsFontSize('large'); setLyricsFontSize('large');
setLyricsDimming('soft'); setLyricsDimming('soft');
setLyricsActiveColor('#ff00aa');
setLyricsUppercase(true);
expect(get(lyricsDisplayStore)).toEqual({ expect(get(lyricsDisplayStore)).toEqual({
autoFollow: false, autoFollow: false,
font: 'montserrat', font: 'montserrat',
fontSize: 'large', fontSize: 'large',
dimming: 'soft' dimming: 'soft',
activeColor: '#ff00aa',
uppercase: true
}); });
const persisted = userStorage.getUserItem(STORAGE_KEY); const persisted = userStorage.getUserItem(STORAGE_KEY);
@@ -65,6 +73,20 @@ describe('lyricsDisplayStore', () => {
expect(JSON.parse(persisted as string)).toEqual(get(lyricsDisplayStore)); expect(JSON.parse(persisted as string)).toEqual(get(lyricsDisplayStore));
}); });
it('setLyricsActiveColor rejects invalid hex and falls back to default', () => {
setLyricsActiveColor('#abc'); // too short
expect(get(lyricsDisplayStore).activeColor).toBe('');
setLyricsActiveColor('not-a-color');
expect(get(lyricsDisplayStore).activeColor).toBe('');
setLyricsActiveColor('');
expect(get(lyricsDisplayStore).activeColor).toBe('');
setLyricsActiveColor('#A1B2C3');
expect(get(lyricsDisplayStore).activeColor).toBe('#A1B2C3');
});
it('resetLyricsDisplay returns to defaults and persists', () => { it('resetLyricsDisplay returns to defaults and persists', () => {
setLyricsFontSize('large'); setLyricsFontSize('large');
setLyricsDimming('off'); setLyricsDimming('off');
+35 -2
View File
@@ -26,6 +26,10 @@ export interface LyricsDisplayPrefs {
font: LyricsFont; font: LyricsFont;
fontSize: LyricsFontSize; fontSize: LyricsFontSize;
dimming: LyricsDimming; dimming: LyricsDimming;
/** Hex color `#RRGGBB` for the active (karaoke) line, or empty string to follow the theme accent. */
activeColor: string;
/** Render all lyric lines in uppercase (compact/sidebar mode only). */
uppercase: boolean;
} }
export const STORAGE_KEY = 'qbz-lyrics-display'; export const STORAGE_KEY = 'qbz-lyrics-display';
@@ -34,9 +38,19 @@ export const DEFAULT_LYRICS_DISPLAY: LyricsDisplayPrefs = {
autoFollow: true, autoFollow: true,
font: 'system', font: 'system',
fontSize: 'medium', fontSize: 'medium',
dimming: 'strong' dimming: 'strong',
activeColor: '',
uppercase: false
}; };
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
function isValidActiveColor(v: unknown): v is string {
if (typeof v !== 'string') return false;
if (v === '') return true;
return HEX_COLOR_RE.test(v);
}
const VALID_FONTS: LyricsFont[] = [ const VALID_FONTS: LyricsFont[] = [
'system', 'system',
'line-seed-jp', 'line-seed-jp',
@@ -54,7 +68,9 @@ function sanitize(raw: unknown): LyricsDisplayPrefs {
autoFollow: typeof r.autoFollow === 'boolean' ? r.autoFollow : DEFAULT_LYRICS_DISPLAY.autoFollow, autoFollow: typeof r.autoFollow === 'boolean' ? r.autoFollow : DEFAULT_LYRICS_DISPLAY.autoFollow,
font: VALID_FONTS.includes(r.font as LyricsFont) ? (r.font as LyricsFont) : DEFAULT_LYRICS_DISPLAY.font, font: VALID_FONTS.includes(r.font as LyricsFont) ? (r.font as LyricsFont) : DEFAULT_LYRICS_DISPLAY.font,
fontSize: VALID_SIZES.includes(r.fontSize as LyricsFontSize) ? (r.fontSize as LyricsFontSize) : DEFAULT_LYRICS_DISPLAY.fontSize, fontSize: VALID_SIZES.includes(r.fontSize as LyricsFontSize) ? (r.fontSize as LyricsFontSize) : DEFAULT_LYRICS_DISPLAY.fontSize,
dimming: VALID_DIMMINGS.includes(r.dimming as LyricsDimming) ? (r.dimming as LyricsDimming) : DEFAULT_LYRICS_DISPLAY.dimming dimming: VALID_DIMMINGS.includes(r.dimming as LyricsDimming) ? (r.dimming as LyricsDimming) : DEFAULT_LYRICS_DISPLAY.dimming,
activeColor: isValidActiveColor(r.activeColor) ? r.activeColor : DEFAULT_LYRICS_DISPLAY.activeColor,
uppercase: typeof r.uppercase === 'boolean' ? r.uppercase : DEFAULT_LYRICS_DISPLAY.uppercase
}; };
} }
@@ -106,6 +122,23 @@ export function setLyricsDimming(dimming: LyricsDimming): void {
}); });
} }
export function setLyricsActiveColor(activeColor: string): void {
const safe = isValidActiveColor(activeColor) ? activeColor : DEFAULT_LYRICS_DISPLAY.activeColor;
lyricsDisplayStore.update((p) => {
const next = { ...p, activeColor: safe };
persist(next);
return next;
});
}
export function setLyricsUppercase(uppercase: boolean): void {
lyricsDisplayStore.update((p) => {
const next = { ...p, uppercase };
persist(next);
return next;
});
}
export function resetLyricsDisplay(): void { export function resetLyricsDisplay(): void {
const next = { ...DEFAULT_LYRICS_DISPLAY }; const next = { ...DEFAULT_LYRICS_DISPLAY };
persist(next); persist(next);