diff --git a/CHANGELOG.md b/CHANGELOG.md index 7156d186..0bed814a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published. * The theme card **What's new** now shows just the latest version's notes instead of the full version history. +### Fullscreen player — Minimal and Immersive styles + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1249](https://github.com/Psychotoxical/psysonic/pull/1249)** + +* **Settings → Appearance → Fullscreen player style** lets you choose **Minimal** (the current view) or **Immersive** — the earlier fullscreen player, with the artist photo/backdrop, a cover-derived accent colour, and rail or Apple-style scrolling lyrics. +* In Immersive, **Show artist photo** and **Photo dimming** are configurable; Apple-style lyrics show the artist image as a dimmed full-screen backdrop. + ## Changed diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 9898ccba..37848618 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -14,7 +14,7 @@ import DevNetworkModeToggle from '@/app/DevNetworkModeToggle'; import { NowPlayingDropdown } from '@/features/nowPlaying'; import QueuePanel from '@/features/queue'; import AppRoutes from './AppRoutes'; -import FullscreenPlayer from '@/features/fullscreenPlayer'; +import FullscreenPlayer, { FullscreenPlayerImmersive } from '@/features/fullscreenPlayer'; import ContextMenu from '@/features/contextMenu/components/ContextMenu'; import SongInfoModal from '@/features/playback/components/SongInfoModal'; import { DownloadFolderModal } from '@/features/offline'; @@ -114,6 +114,7 @@ export function AppShell() { useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); + const fullscreenPlayerStyle = useAuthStore(s => s.fullscreenPlayerStyle); const offlineCtx = useReactiveOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const hasOfflineBrowse = offlineCtx.hasBrowseCapability; @@ -338,7 +339,9 @@ export function AppShell() { {isMobile && !isMobilePlayer && } {!isMobilePlayer && } {isFullscreenOpen && ( - + fullscreenPlayerStyle === 'immersive' + ? + : )} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index f14793cc..2fd762b0 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -409,6 +409,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)', 'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)', 'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)', + 'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)', ], }, { diff --git a/src/features/fullscreenPlayer/components/FsArt.tsx b/src/features/fullscreenPlayer/components/FsArt.tsx new file mode 100644 index 00000000..5187a998 --- /dev/null +++ b/src/features/fullscreenPlayer/components/FsArt.tsx @@ -0,0 +1,59 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Music } from 'lucide-react'; +import { useCachedUrl } from '@/ui/CachedImage'; + +// Album art box — crossfades layers so old art stays visible while new loads. +// Uses 300px thumbnails (portrait fallback uses 500px separately). +// +// Why onLoad instead of new Image() preload: +// React batches setLayers(add invisible) + rAF setLayers(make visible) into one +// commit, so the browser never sees opacity:0 and the CSS transition never fires. +// Using the DOM img's own onLoad guarantees the element was painted at opacity:0 +// before we flip it to 1. +export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) { + // true = show raw fetchUrl immediately as fallback while blob resolves. + // PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit. + // Showing the URL directly avoids the multi-second blank wait. + const blobUrl = useCachedUrl(fetchUrl, cacheKey, true); + + const [layers, setLayers] = useState>([]); + const counter = useRef(0); + const cleanupTimer = useRef | null>(null); + + useEffect(() => { + if (!blobUrl) { + // New track has no cover → drop the old layer so the placeholder shows, + // instead of leaving the previous track's art on screen. + setLayers(prev => (prev.length ? [] : prev)); + return; + } + const id = ++counter.current; + setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]); + }, [blobUrl]); + + const handleLoad = useCallback((id: number) => { + if (cleanupTimer.current) clearTimeout(cleanupTimer.current); + setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id }))); + cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400); + }, []); + + if (layers.length === 0) { + return
; + } + + return ( + <> + {layers.map(l => ( + handleLoad(l.id)} + alt="" + decoding="async" + /> + ))} + + ); +}); diff --git a/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx b/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx new file mode 100644 index 00000000..b4cf48d7 --- /dev/null +++ b/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx @@ -0,0 +1,84 @@ +import React, { memo, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '@/store/authStore'; + +interface Props { + open: boolean; + onClose: () => void; + accentColor: string | null; + triggerRef?: React.RefObject; +} + +// Lyrics settings popover — shown above the mic button. +export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) { + const { t } = useTranslation(); + const showLyrics = useAuthStore(s => s.showFullscreenLyrics); + const lyricsStyle = useAuthStore(s => s.fsLyricsStyle); + const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics); + const setStyle = useAuthStore(s => s.setFsLyricsStyle); + const panelRef = useRef(null); + + // Close on click outside the panel or on Escape. + // Ignore clicks on the trigger button so re-clicking it toggles normally + // instead of outside-handler closing + click re-opening. + useEffect(() => { + if (!open) return; + // Capture phase + stopPropagation so Escape closes only the popover, not the + // whole player: the player's own Escape handler (useFsIdleFade) listens in the + // bubble phase, so stopping propagation here keeps it from firing too. + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.stopPropagation(); + onClose(); + }; + const onMouse = (e: MouseEvent) => { + const target = e.target as Node; + if (panelRef.current?.contains(target)) return; + if (triggerRef?.current?.contains(target)) return; + onClose(); + }; + window.addEventListener('keydown', onKey, true); + const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0); + return () => { + clearTimeout(t); + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('mousedown', onMouse); + }; + }, [open, onClose, triggerRef]); + + if (!open) return null; + + const accent = accentColor ?? 'var(--accent)'; + + return ( +
+
+ {t('player.fsLyricsToggle')} + +
+ +
+ {(['rail', 'apple'] as const).map(style => ( + + ))} +
+ +
+
+ ); +}); diff --git a/src/features/fullscreenPlayer/components/FsLyricsRail.tsx b/src/features/fullscreenPlayer/components/FsLyricsRail.tsx new file mode 100644 index 00000000..745e2b5a --- /dev/null +++ b/src/features/fullscreenPlayer/components/FsLyricsRail.tsx @@ -0,0 +1,91 @@ +import React, { memo, useCallback, useEffect, useRef } from 'react'; +import { usePlayerStore } from '@/features/playback'; +import { useAuthStore } from '@/store/authStore'; +import { useLyrics, type WordLyricsLine, useWordLyricsSync } from '@/features/lyrics'; +import type { LrcLine } from '@/features/lyrics'; +import type { Track } from '@/lib/media/trackTypes'; + +// Classic 5-line rail lyrics (original "Rail" style). +// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh. +export const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) { + const { syncedLines, wordLines, loading } = useLyrics(currentTrack); + const staticOnly = useAuthStore(s => s.lyricsStaticOnly); + + const useWords = !staticOnly && wordLines !== null && wordLines.length > 0; + const lineSrc: LrcLine[] | null = useWords + ? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text })) + : (syncedLines as LrcLine[] | null); + const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0; + + const linesRef = useRef([]); + linesRef.current = hasSynced ? lineSrc! : []; + + const activeIdx = usePlayerStore(s => { + const ls = linesRef.current; + if (ls.length === 0) return -1; + return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1); + }); + + const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); + const seek = usePlayerStore(s => s.seek); + + const slotH = useRef(window.innerHeight * 0.06); + useEffect(() => { + const onResize = () => { slotH.current = window.innerHeight * 0.06; }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const handleLineClick = useCallback((e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('[data-time]'); + if (!target || duration <= 0) return; + seek(parseFloat(target.dataset.time!) / duration); + }, [duration, seek]); + + const { setWordRef } = useWordLyricsSync({ + enabled: useWords, + wordLines: useWords ? (wordLines as WordLyricsLine[]) : null, + currentTrack, + classPrefix: 'fsr', + }); + + if (!currentTrack || loading || !hasSynced) return null; + + const railY = (2 - Math.max(0, activeIdx)) * slotH.current; + + return ( + + ); +}); diff --git a/src/features/fullscreenPlayer/components/FsPortrait.tsx b/src/features/fullscreenPlayer/components/FsPortrait.tsx new file mode 100644 index 00000000..e1625664 --- /dev/null +++ b/src/features/fullscreenPlayer/components/FsPortrait.tsx @@ -0,0 +1,49 @@ +import { memo, useEffect, useRef, useState } from 'react'; + +// Artist portrait — right half, crossfades on track change. +export const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { + const [layers, setLayers] = useState>(() => + url ? [{ url, id: 0, visible: true }] : [] + ); + const counterRef = useRef(1); + const cleanupTimer = useRef | null>(null); + + useEffect(() => { + if (!url) return; + let cancelled = false; + const id = counterRef.current++; + const img = new Image(); + img.onload = img.onerror = () => { + if (cancelled) return; + setLayers(prev => [...prev, { url, id, visible: false }]); + requestAnimationFrame(() => { + if (cancelled) return; + if (cleanupTimer.current) clearTimeout(cleanupTimer.current); + setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); + cleanupTimer.current = setTimeout(() => { + if (!cancelled) setLayers(prev => prev.filter(l => l.id === id)); + }, 1000); + }); + }; + img.src = url; + return () => { cancelled = true; }; + }, [url]); + + if (layers.length === 0) return null; + + return ( + + ); +}); diff --git a/src/features/fullscreenPlayer/components/FsSeekbar.tsx b/src/features/fullscreenPlayer/components/FsSeekbar.tsx new file mode 100644 index 00000000..e9288941 --- /dev/null +++ b/src/features/fullscreenPlayer/components/FsSeekbar.tsx @@ -0,0 +1,89 @@ +import React, { memo, useCallback, useEffect, useRef } from 'react'; +import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback'; +import { formatTrackTime } from '@/lib/format/formatDuration'; + +// Full-width seekbar — imperative DOM updates, zero React re-renders on tick. +export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { + const seek = usePlayerStore(s => s.seek); + const timeRef = useRef(null); + const playedRef = useRef(null); + const bufRef = useRef(null); + const inputRef = useRef(null); + const isDraggingRef = useRef(false); + const pendingSeekRef = useRef(null); + + const previewSeek = useCallback((progress: number) => { + const s = usePlayerStore.getState(); + const p = Math.max(0, Math.min(1, progress)); + pendingSeekRef.current = p; + if (timeRef.current) { + const previewTime = duration > 0 ? p * duration : s.currentTime; + timeRef.current.textContent = formatTrackTime(previewTime); + } + if (playedRef.current) playedRef.current.style.width = `${p * 100}%`; + if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`; + if (inputRef.current) inputRef.current.value = String(p); + }, [duration]); + + const commitSeek = useCallback(() => { + const pending = pendingSeekRef.current; + if (pending === null) return; + pendingSeekRef.current = null; + seek(pending); + }, [seek]); + + useEffect(() => { + const s = getPlaybackProgressSnapshot(); + const pct = s.progress * 100; + if (timeRef.current) timeRef.current.textContent = formatTrackTime(s.currentTime); + if (playedRef.current) playedRef.current.style.width = `${pct}%`; + if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; + if (inputRef.current) inputRef.current.value = String(s.progress); + + return subscribePlaybackProgress(state => { + if (isDraggingRef.current) return; + const p = state.progress * 100; + if (timeRef.current) timeRef.current.textContent = formatTrackTime(state.currentTime); + if (playedRef.current) playedRef.current.style.width = `${p}%`; + if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`; + if (inputRef.current) inputRef.current.value = String(state.progress); + }); + }, []); + + const handleSeek = useCallback( + (e: React.ChangeEvent) => { + previewSeek(parseFloat(e.target.value)); + }, + [previewSeek] + ); + + return ( +
+
+ + {formatTrackTime(duration)} +
+
+
+
+
+ { isDraggingRef.current = true; }} + onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }} + onTouchStart={() => { isDraggingRef.current = true; }} + onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }} + onPointerDown={() => { isDraggingRef.current = true; }} + onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }} + onKeyDown={() => { isDraggingRef.current = true; }} + onKeyUp={() => { isDraggingRef.current = false; commitSeek(); }} + onBlur={() => { isDraggingRef.current = false; commitSeek(); }} + aria-label="Seek" + /> +
+
+ ); +}); diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.test.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.test.tsx new file mode 100644 index 00000000..1facd50f --- /dev/null +++ b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.test.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent } from '@testing-library/react'; + +vi.mock('@/ui/CachedImage', async () => { + const actual = await vi.importActual('@/ui/CachedImage'); + return { ...actual, useCachedUrl: vi.fn((url: string) => (url ? `mock://${url}` : '')) }; +}); + +import FullscreenPlayerImmersive from './FullscreenPlayerImmersive'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { usePlayerStore } from '@/features/playback/store/playerStore'; +import { useAuthStore } from '@/store/authStore'; +import { resetAllStores } from '@/test/helpers/storeReset'; +import { makeTrack } from '@/test/helpers/factories'; +import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri'; + +beforeEach(() => { + resetAllStores(); + const id = useAuthStore.getState().addServer({ + name: 'T', url: 'https://x.test', username: 'u', password: 'p', + }); + useAuthStore.getState().setActiveServer(id); + useAuthStore.setState({ fullscreenPlayerStyle: 'immersive' }); + registerDefaultCoverInvokeHandlers(); + onInvoke('audio_stop', () => undefined); + onInvoke('audio_get_state', () => ({ playing: false })); +}); + +describe('FullscreenPlayerImmersive', () => { + it('renders the labelled fullscreen dialog and close button', () => { + usePlayerStore.setState({ currentTrack: makeTrack() }); + const { getByLabelText } = renderWithProviders( {}} />); + expect(getByLabelText('Fullscreen Player')).toBeInTheDocument(); + expect(getByLabelText('Close Fullscreen')).toBeInTheDocument(); + }); + + it('clicking Close calls the onClose prop', () => { + usePlayerStore.setState({ currentTrack: makeTrack() }); + const onClose = vi.fn(); + const { getByLabelText } = renderWithProviders(); + fireEvent.click(getByLabelText('Close Fullscreen')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('clicking Stop calls the player store stop()', () => { + usePlayerStore.setState({ currentTrack: makeTrack() }); + const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop'); + const { getByLabelText } = renderWithProviders( {}} />); + fireEvent.click(getByLabelText('Stop')); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx new file mode 100644 index 00000000..04b606af --- /dev/null +++ b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx @@ -0,0 +1,259 @@ +import { queueSongStar, playbackCoverArtForAlbum, usePlayerStore } from '@/features/playback'; +import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt'; +import { useAlbumCoverRef, useArtistCoverRef } from '@/cover/useLibraryCoverRef'; +import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; +import { + SkipBack, SkipForward, + ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal, +} from 'lucide-react'; +import { useCachedUrl } from '@/ui/CachedImage'; +import { getCachedBlob } from '@/cover/imageCache'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '@/store/authStore'; +import { useThemeStore } from '@/store/themeStore'; +import { useArtistFanart } from '@/cover/useArtistFanart'; +import { backdropFromConfig } from '@/cover/artistBackdrop'; +import { FsLyricsApple } from './FsLyricsApple'; +import { FsLyricsRail } from './FsLyricsRail'; +import { FsArt } from './FsArt'; +import { FsPortrait } from './FsPortrait'; +import { FsSeekbar } from './FsSeekbar'; +import { FsLyricsMenu } from './FsLyricsMenu'; +import { FsPlayBtn } from './FsPlayBtn'; +import { useFsDynamicAccent } from '@/features/fullscreenPlayer/hooks/useFsDynamicAccent'; +import { useFsIdleFade } from '@/features/fullscreenPlayer/hooks/useFsIdleFade'; +import { useQueueTrackAt } from '@/features/queue'; + +interface FullscreenPlayerProps { + onClose: () => void; +} + +export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { + const { t } = useTranslation(); + const currentTrack = usePlayerStore(s => s.currentTrack); + const repeatMode = usePlayerStore(s => s.repeatMode); + const next = usePlayerStore(s => s.next); + const previous = usePlayerStore(s => s.previous); + const stop = usePlayerStore(s => s.stop); + const toggleRepeat = usePlayerStore(s => s.toggleRepeat); + // Derive isStarred inside the selector so we only re-render when the boolean + // actually flips — not when any unrelated track's star status changes. + const isStarred = usePlayerStore(s => { + const track = s.currentTrack; + if (!track) return false; + return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; + }); + + const toggleStar = useCallback(() => { + if (!currentTrack) return; + queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId); + }, [currentTrack, isStarred]); + + const duration = currentTrack?.duration ?? 0; + + // Album-keyed cover ref so the cover stays stable across track changes within + // the same album — a per-track ref re-keys on `mf-` coverArt and + // reloads/flickers the cover on every song advance (same fix as the Minimal player). + const playbackCoverRef = + useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined; + + const artCover = usePlaybackCoverArt(playbackCoverRef, 300); + const artUrl = artCover.src; + const artKey = artCover.cacheKey; + const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500); + const coverUrl = portraitCover.src; + const coverKey = portraitCover.cacheKey; + // `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. Cache hits return instantly so + // same-album tracks reuse the color without re-fetching. + const dynamicAccent = useFsDynamicAccent(artUrl, artKey); + + // Artist image → portrait on right. Resolved through the shared backdrop + // pipeline (banner / fanart.tv / Navidrome artist cover, in the user's + // configured fullscreen-player source order) — the same source the Minimal + // player uses. Falls back to the album cover when nothing resolves. + const fsBackdropCfg = useThemeStore(s => s.backdrops.fullscreenPlayer); + const fanart = useArtistFanart(currentTrack?.artistId, { + artistName: currentTrack?.artist, + albumTitle: currentTrack?.album, + }); + const artistCoverRef = + useArtistCoverRef(currentTrack?.artistId, undefined, undefined, { libraryResolve: false }) ?? + undefined; + const artistImage = usePlaybackCoverArt(artistCoverRef, 2000, { fullRes: true }); + const artistImgUrl = useCachedUrl(artistImage.src, artistImage.cacheKey, true); + const artistBgUrl = fsBackdropCfg.enabled + ? backdropFromConfig(fsBackdropCfg.sources, { fanart, navidrome: artistImgUrl }).url + : ''; + const portraitUrl = artistBgUrl || resolvedCoverUrl; + const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics); + const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle); + const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait); + const fsPortraitDim = useAuthStore(s => s.fsPortraitDim); + const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple'; + + // Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first + // (thin-state): the next ref resolves from the cache (the prefetch window + // around the current index keeps it warm). + const queueIndex = usePlayerStore(s => s.queueIndex); + const nextTrack = useQueueTrackAt(queueIndex + 1); + const queueServerId = usePlayerStore(s => s.queueServerId); + const activeServerId = useAuthStore(s => s.activeServerId); + useEffect(() => { + if (!nextTrack?.albumId || !nextTrack.coverArt) return; + const { src: url, cacheKey: key } = playbackCoverArtForAlbum( + nextTrack.albumId, + nextTrack.coverArt, + 300, + ); + getCachedBlob(url, key).catch(() => {}); + }, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]); + + // Lyrics settings popover state + const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false); + const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []); + const lyricsMenuTriggerRef = useRef(null); + const fsControlsRef = useRef(null); + + // Idle-fade system — hides controls after 3 s of inactivity; Esc closes. + const { isIdle, handleMouseMove } = useFsIdleFade(onClose); + + const metaParts = useMemo(() => [ + currentTrack?.album, + currentTrack?.year?.toString(), + currentTrack?.suffix?.toUpperCase(), + currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '', + ].filter(Boolean), [currentTrack]); + + return ( +
+ + {/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */} +