mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user