feat(fs-player): adaptive dynamic accent color from album cover

Extracts the most vibrant pixel from an 8×8 downscaled album cover via
the Canvas API and applies it as --dynamic-fs-accent on the .fs-player
root element.  All accent-colored FS player elements use
var(--dynamic-fs-accent, var(--accent)) as fallback so the theme accent
is restored automatically when the FS player closes.

Elements updated:
- .fs-track-title (color + text-shadow)
- .fs-btn.active, .fs-btn-heart.active/:hover
- .fs-btn-play and :hover (background + box-shadow)
- .fs-seekbar-played (background + box-shadow)
- .fs-mesh-blob-a/-b (radial-gradient via color-mix at 14%/8% opacity)
- .fs-art-wrap box-shadow glow (color-mix at 70% opacity)
- MicVocal lyrics-toggle inline style

Color safety: WCAG 4.5:1 contrast against near-black FS background
enforced by progressively lightening in HSL space (ensureContrast).

Pure math functions unit-tested with vitest (28 tests, 0 deps beyond
vitest itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 19:14:20 +02:00
parent 63a3bcd0f4
commit 9319c40fde
7 changed files with 757 additions and 20 deletions
+22 -1
View File
@@ -7,6 +7,7 @@ import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
@@ -284,6 +285,25 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Dynamic accent color extracted from the current album cover.
// Applied as --dynamic-fs-accent on the root element so it inherits to all
// children; CSS rules use var(--dynamic-fs-accent, var(--accent)) as fallback.
// Reset to null on track change so the previous color doesn't linger while
// the new one is being extracted.
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
useEffect(() => {
setDynamicAccent(null);
if (!artUrl || !artKey) return;
let cancelled = false;
getCachedUrl(artUrl, artKey).then(blobUrl => {
if (cancelled || !blobUrl) return;
extractCoverColors(blobUrl).then(colors => {
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
});
});
return () => { cancelled = true; };
}, [artKey]); // artKey is stable per track — artUrl would also work
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
@@ -362,6 +382,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
aria-label={t('player.fullscreen')}
data-idle={isIdle}
onMouseMove={handleMouseMove}
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
@@ -445,7 +466,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? 'var(--accent)' : 'rgba(255,255,255,0.35)' }}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
+37 -14
View File
@@ -2917,10 +2917,13 @@
height: 130%;
left: -35%;
bottom: -35%;
background: radial-gradient(ellipse, var(--accent-glow, rgba(120, 80, 255, 0.14)) 0%, transparent 65%);
background: radial-gradient(ellipse,
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
transparent 65%);
filter: blur(55px);
animation: mesh-aura-a 26s ease-in-out infinite;
animation-delay: 350ms;
transition: background 400ms ease-in-out;
}
.fs-mesh-blob-b {
@@ -2928,10 +2931,13 @@
height: 110%;
right: -25%;
top: -25%;
background: radial-gradient(ellipse, var(--accent-dim, rgba(120, 80, 255, 0.08)) 0%, transparent 65%);
background: radial-gradient(ellipse,
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
transparent 65%);
filter: blur(65px);
animation: mesh-aura-b 20s ease-in-out infinite;
animation-delay: 350ms;
transition: background 400ms ease-in-out;
}
/* ── Artist portrait — right half, object-fit: contain ── */
@@ -3043,7 +3049,8 @@
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.07),
0 0 28px var(--accent-glow, var(--accent));
0 0 28px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 30%);
transition: box-shadow 200ms ease-in-out;
}
/* Each layer is absolutely stacked — layers crossfade via opacity */
@@ -3071,13 +3078,14 @@
font-family: var(--font-display);
font-size: clamp(28px, 4.5vw, 68px);
font-weight: 900;
color: var(--accent);
color: var(--dynamic-fs-accent, var(--accent));
margin: 0;
line-height: 1.05;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--accent-glow, var(--accent));
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
transition: color 200ms ease-in-out, text-shadow 200ms ease-in-out;
}
/* Artist name — secondary, below track title */
@@ -3125,7 +3133,7 @@
border-radius: 50%;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
transition: all var(--transition-fast);
transition: all var(--transition-fast), color 200ms ease-in-out, background-color 200ms ease-in-out;
background: transparent;
}
@@ -3136,7 +3144,7 @@
}
.fs-btn.active {
color: var(--accent);
color: var(--dynamic-fs-accent, var(--accent));
}
.fs-btn-sm {
@@ -3147,22 +3155,22 @@
.fs-btn-play {
width: 52px;
height: 52px;
background: var(--accent);
background: var(--dynamic-fs-accent, var(--accent));
color: var(--ctp-crust);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow, var(--accent));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
}
.fs-btn-play:hover {
background: var(--accent);
background: var(--dynamic-fs-accent, var(--accent));
color: var(--ctp-crust);
transform: scale(1.1);
filter: brightness(1.12);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--accent-glow, var(--accent));
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
}
.fs-btn-heart:hover,
.fs-btn-heart.active {
color: var(--color-star-active, var(--accent));
color: var(--dynamic-fs-accent, var(--color-star-active, var(--accent)));
background: rgba(255, 255, 255, 0.08);
}
@@ -3217,9 +3225,10 @@
top: 0;
left: 0;
bottom: 0;
background: var(--accent);
box-shadow: 0 0 15px var(--accent-glow, var(--accent));
background: var(--dynamic-fs-accent, var(--accent));
box-shadow: 0 0 15px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
pointer-events: none;
transition: background-color 200ms ease-in-out, box-shadow 200ms ease-in-out;
}
.fs-seekbar input[type="range"] {
@@ -5478,6 +5487,20 @@
color: var(--ctp-crust);
}
/* Radio card: buttons row always pinned to bottom */
.radio-card {
display: flex;
flex-direction: column;
}
.radio-card .album-card-info {
flex: 1;
display: flex;
flex-direction: column;
}
.radio-card .album-card-artist {
margin-top: auto;
}
/* ─ Alphabet Filter Bar ─ */
.alphabet-filter-bar {
display: flex;
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
srgbToLinear,
relativeLuminance,
contrastRatio,
rgbToHsl,
hslToRgb,
ensureContrast,
} from './dynamicColors';
// ─── srgbToLinear ─────────────────────────────────────────────────────────────
describe('srgbToLinear', () => {
it('maps 0 to 0', () => expect(srgbToLinear(0)).toBe(0));
it('maps 1 to 1', () => expect(srgbToLinear(1)).toBeCloseTo(1));
it('uses the low-end linear formula below 0.04045', () => {
expect(srgbToLinear(0.04)).toBeCloseTo(0.04 / 12.92, 6);
});
it('uses the gamma formula above 0.04045', () => {
expect(srgbToLinear(0.5)).toBeCloseTo(Math.pow((0.5 + 0.055) / 1.055, 2.4), 6);
});
});
// ─── relativeLuminance ────────────────────────────────────────────────────────
describe('relativeLuminance', () => {
it('returns 0 for black (0,0,0)', () => expect(relativeLuminance(0, 0, 0)).toBe(0));
it('returns 1 for white (1,1,1)', () => expect(relativeLuminance(1, 1, 1)).toBeCloseTo(1, 5));
it('weights green highest (0.7152)', () => {
const greenOnly = relativeLuminance(0, 1, 0);
const redOnly = relativeLuminance(1, 0, 0);
const blueOnly = relativeLuminance(0, 0, 1);
expect(greenOnly).toBeGreaterThan(redOnly);
expect(redOnly).toBeGreaterThan(blueOnly);
});
});
// ─── contrastRatio ────────────────────────────────────────────────────────────
describe('contrastRatio', () => {
it('returns 21 for pure black on white', () => {
expect(contrastRatio(0, 1)).toBeCloseTo(21, 1);
});
it('returns 1 when both luminances are equal', () => {
expect(contrastRatio(0.2, 0.2)).toBeCloseTo(1, 5);
});
it('is symmetric', () => {
expect(contrastRatio(0.1, 0.5)).toBeCloseTo(contrastRatio(0.5, 0.1), 10);
});
it('returns ≥ 4.5 for a light accent on near-black (FS bg)', () => {
const accent = relativeLuminance(200 / 255, 160 / 255, 60 / 255);
expect(contrastRatio(accent, 0.01)).toBeGreaterThanOrEqual(4.5);
});
});
// ─── rgbToHsl / hslToRgb round-trip ──────────────────────────────────────────
describe('rgbToHsl / hslToRgb', () => {
const cases: Array<[number, number, number]> = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[128, 128, 128],
[255, 255, 255],
[0, 0, 0],
[200, 100, 50],
];
it.each(cases)('round-trips rgb(%i,%i,%i)', (r, g, b) => {
const [h, s, l] = rgbToHsl(r, g, b);
const [rr, gg, bb] = hslToRgb(h, s, l);
expect(rr).toBeCloseTo(r, -1); // within ±1 due to rounding
expect(gg).toBeCloseTo(g, -1);
expect(bb).toBeCloseTo(b, -1);
});
it('pure grey has saturation 0', () => {
const [, s] = rgbToHsl(128, 128, 128);
expect(s).toBe(0);
});
it('pure red has hue 0°', () => {
const [h] = rgbToHsl(255, 0, 0);
expect(h).toBeCloseTo(0, 0);
});
it('pure green has hue 120°', () => {
const [h] = rgbToHsl(0, 255, 0);
expect(h).toBeCloseTo(120, 0);
});
it('pure blue has hue 240°', () => {
const [h] = rgbToHsl(0, 0, 255);
expect(h).toBeCloseTo(240, 0);
});
});
// ─── ensureContrast ───────────────────────────────────────────────────────────
describe('ensureContrast', () => {
const BG_LUMINANCE = 0.010; // near-black FS player background
const MIN_RATIO = 4.5;
function meetsContrast(rgb: [number, number, number]): boolean {
const l = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
return contrastRatio(l, BG_LUMINANCE) >= MIN_RATIO;
}
it('returns input unchanged if already sufficient', () => {
// White is always sufficient
const result = ensureContrast([255, 255, 255], BG_LUMINANCE, MIN_RATIO);
expect(result).toEqual([255, 255, 255]);
});
it('lightens a very dark color until contrast is met', () => {
const result = ensureContrast([10, 10, 10], BG_LUMINANCE, MIN_RATIO);
expect(meetsContrast(result)).toBe(true);
});
it('lightens a dark saturated color until contrast is met', () => {
// Dark blue — typical for dark album covers
const result = ensureContrast([20, 20, 80], BG_LUMINANCE, MIN_RATIO);
expect(meetsContrast(result)).toBe(true);
});
it('preserves a mid-tone color that already meets the ratio', () => {
// Yellow-ish, high luminance — should already pass
const input: [number, number, number] = [255, 230, 50];
const result = ensureContrast(input, BG_LUMINANCE, MIN_RATIO);
expect(meetsContrast(result)).toBe(true);
});
it('always produces a result with contrast ≥ MIN_RATIO', () => {
// Stress-test with a range of dark, saturated colors
const darkColors: Array<[number, number, number]> = [
[5, 30, 60],
[60, 5, 30],
[30, 60, 5],
[80, 0, 120],
[0, 0, 0],
];
for (const c of darkColors) {
const result = ensureContrast(c, BG_LUMINANCE, MIN_RATIO);
expect(meetsContrast(result)).toBe(true);
}
});
it('handles a very demanding minRatio gracefully (returns white)', () => {
// 21:1 is the theoretical maximum — only black-on-white achieves it.
// Any input should at least not throw and produce some output.
const result = ensureContrast([10, 10, 10], BG_LUMINANCE, 21);
expect(result.length).toBe(3);
result.forEach(c => { expect(c).toBeGreaterThanOrEqual(0); expect(c).toBeLessThanOrEqual(255); });
});
});
+163
View File
@@ -0,0 +1,163 @@
// ─── Dynamic Cover Colors ─────────────────────────────────────────────────────
//
// Extracts a vibrant accent color from an album cover URL using the Canvas API.
// Designed exclusively for the Fullscreen Player — applied as a CSS custom
// property on the .fs-player root, inheriting down to all children.
//
// Guarantees WCAG 4.5:1 contrast against the FS player's near-black background
// by progressively lightening the extracted color in HSL space until the ratio
// is met. Falls back to an empty string (→ CSS falls back to var(--accent)).
export interface CoverColors {
/** CSS color string, e.g. "rgb(200,120,60)". Empty string = use fallback. */
accent: string;
}
// ─── WCAG math (pure — unit-testable) ─────────────────────────────────────────
/** Convert a sRGB channel [0, 1] to linear light. */
export function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/** WCAG relative luminance for linear-light RGB channels [0, 1]. */
export function relativeLuminance(r: number, g: number, b: number): number {
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}
/** WCAG contrast ratio given two relative luminances. Always ≥ 1. */
export function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// ─── HSL helpers (pure) ───────────────────────────────────────────────────────
/** RGB [0255] → HSL [0360, 01, 01]. */
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l < 0.5 ? d / (max + min) : d / (2 - max - min);
let h: number;
switch (max) {
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
case gn: h = ((bn - rn) / d + 2) / 6; break;
default: h = ((rn - gn) / d + 4) / 6;
}
return [h * 360, s, l];
}
function hue2rgb(p: number, q: number, t: number): number {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
/** HSL [0360, 01, 01] → RGB [0255]. */
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
h = h / 360;
if (s === 0) {
const v = Math.round(l * 255);
return [v, v, v];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
];
}
// ─── Contrast enforcement (pure — unit-testable) ──────────────────────────────
/**
* Given an RGB color [0255] and the luminance of the background it will sit
* on, progressively increases HSL lightness in 0.04 steps until the contrast
* ratio reaches `minRatio`. Returns white [255,255,255] as the ultimate
* fallback if even L=1 doesn't suffice (can only happen at extreme minRatio
* values, e.g. 21:1).
*/
export function ensureContrast(
rgb: [number, number, number],
bgLuminance: number,
minRatio: number,
): [number, number, number] {
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
// Already meeting the requirement?
const initialLum = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
if (contrastRatio(initialLum, bgLuminance) >= minRatio) return [...rgb];
// Lighten in steps of 4 % (25 iterations max → terminates)
for (let step = 1; step <= 25; step++) {
const newL = Math.min(1, l + step * 0.04);
const newRgb = hslToRgb(h, s, newL);
const newLum = relativeLuminance(newRgb[0] / 255, newRgb[1] / 255, newRgb[2] / 255);
if (contrastRatio(newLum, bgLuminance) >= minRatio) return newRgb;
}
return [255, 255, 255];
}
// ─── Canvas extraction (requires DOM) ─────────────────────────────────────────
/**
* The FS player mesh background is a very dark near-black.
* luminance 0.010 is a conservative upper bound the color contrast will be
* at least this good.
*/
const FS_BG_LUMINANCE = 0.010;
const MIN_CONTRAST = 4.5;
/**
* Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel
* (highest HSL saturation). Applies `ensureContrast` to guarantee
* WCAG AA readability against the FS player background.
*
* Resolves with `{ accent: '' }` on any error the caller's CSS
* `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent.
*/
export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
if (!imageUrl) return Promise.resolve({ accent: '' });
return new Promise(resolve => {
const img = new Image();
// Blob URLs are same-origin in Tauri WebKit — no crossOrigin needed.
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = 8;
canvas.height = 8;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve({ accent: '' }); return; }
ctx.drawImage(img, 0, 0, 8, 8);
const { data } = ctx.getImageData(0, 0, 8, 8);
// Pick pixel with highest HSL saturation (most vibrant).
let bestSat = -1;
let bestR = 180, bestG = 100, bestB = 50; // warm orange fallback
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const [, s] = rgbToHsl(r, g, b);
if (s > bestSat) { bestSat = s; bestR = r; bestG = g; bestB = b; }
}
const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST);
resolve({ accent: `rgb(${fr},${fg},${fb})` });
} catch {
resolve({ accent: '' });
}
};
img.onerror = () => resolve({ accent: '' });
img.src = imageUrl;
});
}