import { queueSongStar } from '../store/pendingStarSync'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { playbackCoverArtForId } from '../utils/playback/playbackServer'; import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal, } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useCachedUrl } from './CachedImage'; import { getCachedBlob } from '../utils/imageCache'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple'; import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail'; import { FsArt } from './fullscreenPlayer/FsArt'; import { FsPortrait } from './fullscreenPlayer/FsPortrait'; import { FsSeekbar } from './fullscreenPlayer/FsSeekbar'; import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu'; import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn'; import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent'; import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait'; import { useFsIdleFade } from '../hooks/useFsIdleFade'; 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, isStarred]); const duration = currentTrack?.duration ?? 0; // 300px for the small art box; 500px for the right-side portrait fallback. const artCover = usePlaybackCoverArt(currentTrack?.coverArt, 300); const artUrl = artCover.src; const artKey = artCover.cacheKey; const portraitCover = usePlaybackCoverArt(currentTrack?.coverArt, 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. Falls back to cover art. const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId); 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. // Selector returns only the coverArt id, so it only re-runs on actual changes. const nextCoverArt = usePlayerStore(s => { const q = s.queue; const idx = s.queueIndex; return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null; }); const queueServerId = usePlayerStore(s => s.queueServerId); const activeServerId = useAuthStore(s => s.activeServerId); useEffect(() => { if (!nextCoverArt) return; const { src: url, cacheKey: key } = playbackCoverArtForId(nextCoverArt, 300); getCachedBlob(url, key).catch(() => {}); }, [nextCoverArt, 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) */}