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:
10 files changed
+192
-13
No files matched your search
@@ -10,6 +10,8 @@
|
||||
setLyricsFont,
|
||||
setLyricsFontSize,
|
||||
setLyricsDimming,
|
||||
setLyricsActiveColor,
|
||||
setLyricsUppercase,
|
||||
resetLyricsDisplay,
|
||||
type LyricsFont,
|
||||
type LyricsFontSize,
|
||||
@@ -82,6 +84,14 @@
|
||||
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) {
|
||||
if (!open) return;
|
||||
const target = event.target as Node;
|
||||
@@ -162,6 +172,45 @@
|
||||
</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">
|
||||
<span class="label">{$t('player.lyricsControls.dimming')}</span>
|
||||
<div class="segmented" role="group" aria-label={$t('player.lyricsControls.dimming')}>
|
||||
@@ -276,6 +325,53 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
fontMode?: LyricsFont;
|
||||
fontSizeMode?: LyricsFontSize;
|
||||
dimmingMode?: LyricsDimming;
|
||||
activeColor?: string;
|
||||
uppercase?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -39,7 +41,9 @@
|
||||
isSynced = false,
|
||||
fontMode,
|
||||
fontSizeMode,
|
||||
dimmingMode
|
||||
dimmingMode,
|
||||
activeColor,
|
||||
uppercase = false
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate line duration for CSS animation (immersive mode only)
|
||||
@@ -162,6 +166,8 @@
|
||||
class:font-montserrat={compact && fontMode === 'montserrat'}
|
||||
class:font-noto-sans={compact && fontMode === 'noto-sans'}
|
||||
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}
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
@@ -369,8 +375,8 @@
|
||||
text-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.6),
|
||||
0 2px 10px rgba(0, 0, 0, 0.4),
|
||||
0 0 40px color-mix(in srgb, var(--accent-primary) 40%, transparent),
|
||||
0 0 80px color-mix(in srgb, var(--accent-primary) 20%, 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(--lyrics-active-color, var(--accent-primary)) 20%, transparent);
|
||||
}
|
||||
|
||||
.lyrics-lines.center .lyrics-line.active {
|
||||
@@ -382,8 +388,8 @@
|
||||
--progress-pos: calc(var(--line-progress, 0) * 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-primary) 0%,
|
||||
var(--accent-primary) var(--progress-pos),
|
||||
var(--lyrics-active-color, var(--accent-primary)) 0%,
|
||||
var(--lyrics-active-color, var(--accent-primary)) var(--progress-pos),
|
||||
var(--text-primary) var(--progress-pos),
|
||||
var(--text-primary) 100%
|
||||
);
|
||||
@@ -413,16 +419,21 @@
|
||||
font-size: 17px;
|
||||
}
|
||||
.lyrics-lines.compact.size-large .lyrics-line {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.lyrics-lines.compact.size-large .lyrics-line.active {
|
||||
font-size: 20px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.lyrics-lines.compact.size-xl .lyrics-line {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.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) */
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
fontMode={prefs.font}
|
||||
fontSizeMode={prefs.fontSize}
|
||||
dimmingMode={prefs.dimming}
|
||||
activeColor={prefs.activeColor}
|
||||
uppercase={prefs.uppercase}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -628,6 +628,9 @@
|
||||
"soft": "Leicht",
|
||||
"strong": "Stark"
|
||||
},
|
||||
"activeColor": "Aktive Farbe",
|
||||
"useThemeColor": "Design",
|
||||
"uppercase": "Großbuchstaben",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"noLyrics": "Keine Songtexte verfügbar",
|
||||
|
||||
@@ -630,6 +630,9 @@
|
||||
"soft": "Soft",
|
||||
"strong": "Strong"
|
||||
},
|
||||
"activeColor": "Active color",
|
||||
"useThemeColor": "Theme",
|
||||
"uppercase": "Uppercase",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"noLyrics": "No lyrics available",
|
||||
|
||||
@@ -628,6 +628,9 @@
|
||||
"soft": "Suave",
|
||||
"strong": "Fuerte"
|
||||
},
|
||||
"activeColor": "Color karaoke",
|
||||
"useThemeColor": "Tema",
|
||||
"uppercase": "Mayúsculas",
|
||||
"reset": "Reiniciar"
|
||||
},
|
||||
"noLyrics": "No hay letras disponibles",
|
||||
|
||||
@@ -628,6 +628,9 @@
|
||||
"soft": "Légère",
|
||||
"strong": "Forte"
|
||||
},
|
||||
"activeColor": "Couleur active",
|
||||
"useThemeColor": "Thème",
|
||||
"uppercase": "Majuscules",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"noLyrics": "Aucune parole disponible",
|
||||
|
||||
@@ -628,6 +628,9 @@
|
||||
"soft": "Suave",
|
||||
"strong": "Forte"
|
||||
},
|
||||
"activeColor": "Cor ativa",
|
||||
"useThemeColor": "Tema",
|
||||
"uppercase": "Maiúsculas",
|
||||
"reset": "Redefinir"
|
||||
},
|
||||
"noLyrics": "Letras não disponíveis",
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
setLyricsFont,
|
||||
setLyricsFontSize,
|
||||
setLyricsDimming,
|
||||
setLyricsActiveColor,
|
||||
setLyricsUppercase,
|
||||
resetLyricsDisplay,
|
||||
DEFAULT_LYRICS_DISPLAY,
|
||||
STORAGE_KEY
|
||||
@@ -39,7 +41,9 @@ describe('lyricsDisplayStore', () => {
|
||||
autoFollow: true,
|
||||
font: 'system',
|
||||
fontSize: 'medium',
|
||||
dimming: 'strong'
|
||||
dimming: 'strong',
|
||||
activeColor: '',
|
||||
uppercase: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,12 +56,16 @@ describe('lyricsDisplayStore', () => {
|
||||
setLyricsFont('montserrat');
|
||||
setLyricsFontSize('large');
|
||||
setLyricsDimming('soft');
|
||||
setLyricsActiveColor('#ff00aa');
|
||||
setLyricsUppercase(true);
|
||||
|
||||
expect(get(lyricsDisplayStore)).toEqual({
|
||||
autoFollow: false,
|
||||
font: 'montserrat',
|
||||
fontSize: 'large',
|
||||
dimming: 'soft'
|
||||
dimming: 'soft',
|
||||
activeColor: '#ff00aa',
|
||||
uppercase: true
|
||||
});
|
||||
|
||||
const persisted = userStorage.getUserItem(STORAGE_KEY);
|
||||
@@ -65,6 +73,20 @@ describe('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', () => {
|
||||
setLyricsFontSize('large');
|
||||
setLyricsDimming('off');
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface LyricsDisplayPrefs {
|
||||
font: LyricsFont;
|
||||
fontSize: LyricsFontSize;
|
||||
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';
|
||||
@@ -34,9 +38,19 @@ export const DEFAULT_LYRICS_DISPLAY: LyricsDisplayPrefs = {
|
||||
autoFollow: true,
|
||||
font: 'system',
|
||||
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[] = [
|
||||
'system',
|
||||
'line-seed-jp',
|
||||
@@ -54,7 +68,9 @@ function sanitize(raw: unknown): LyricsDisplayPrefs {
|
||||
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,
|
||||
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 {
|
||||
const next = { ...DEFAULT_LYRICS_DISPLAY };
|
||||
persist(next);
|
||||
|
||||
Reference in new issue
Block a user