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>