diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 88a35129..9a353569 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,641 +1,30 @@ import { star, unstar } from '../api/subsonicStarRating'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import { getArtistInfo } from '../api/subsonicArtists'; -import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; -import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; +import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { - Play, Pause, SkipBack, SkipForward, - ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal, - Moon, Sunrise, + 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 { extractCoverColors } from '../utils/dynamicColors'; import { useTranslation } from 'react-i18next'; -import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics'; import { useAuthStore } from '../store/authStore'; -import type { LrcLine } from '../api/lrclib'; -import type { Track } from '../store/playerStoreTypes'; -import { EaseScroller, targetForFraction } from '../utils/easeScroll'; -import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; -import PlaybackDelayModal from './PlaybackDelayModal'; -import PlaybackScheduleBadge from './PlaybackScheduleBadge'; -import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; +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'; -function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -// ─── Apple Music-style fullscreen lyrics ───────────────────────────────────── -// Full-screen scrollable list. Active line auto-scrolls to ~35% from top. -// Word-sync runs imperatively (no React re-renders on every time tick). -// User scroll pauses auto-scroll for 4 s then resumes. - -const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTrack: Track | null }) { - const { syncedLines, wordLines, plainLyrics, 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 duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); - const seek = usePlayerStore(s => s.seek); - - const linesRef = useRef([]); - linesRef.current = hasSynced ? lineSrc! : []; - - // React state only for the active line index — changes are infrequent. - const [activeIdx, setActiveIdx] = useState(-1); - const activeIdxRef = useRef(-1); - - const containerRef = useRef(null); - const scrollerRef = useRef(null); - const lineRefs = useRef<(HTMLDivElement | null)[]>([]); - const wordRefs = useRef([]); - const prevWord = useRef({ line: -1, word: -1 }); - const isUserScroll = useRef(false); - const scrollTimer = useRef | null>(null); - - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - containerRef.current = el; - scrollerRef.current?.stop(); - scrollerRef.current = el ? new EaseScroller(el) : null; - }, []); - - // Reset everything on track change. - useEffect(() => { - lineRefs.current = []; - wordRefs.current = []; - prevWord.current = { line: -1, word: -1 }; - activeIdxRef.current = -1; - setActiveIdx(-1); - scrollerRef.current?.jump(0); - }, [currentTrack?.id]); - - // Subscribe to playback time — only triggers React setState when line changes. - useEffect(() => { - if (!hasSynced) return; - const apply = (time: number) => { - const ls = linesRef.current; - if (!ls.length) return; - const idx = ls.reduce((acc, line, i) => time >= line.time ? i : acc, -1); - if (idx !== activeIdxRef.current) { - activeIdxRef.current = idx; - setActiveIdx(idx); - } - }; - apply(getPlaybackProgressSnapshot().currentTime); - return subscribePlaybackProgress(s => apply(s.currentTime)); - }, [hasSynced, currentTrack?.id]); - - // Ease-scroll active line to ~35% from the top of the container. - useEffect(() => { - if (activeIdx < 0 || isUserScroll.current) return; - const el = lineRefs.current[activeIdx]; - const box = containerRef.current; - if (!el || !box || !scrollerRef.current) return; - scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35)); - }, [activeIdx]); - - // Word-sync: imperative DOM updates, zero React re-renders per tick. - useEffect(() => { - wordRefs.current = []; - prevWord.current = { line: -1, word: -1 }; - }, [currentTrack?.id, useWords]); - - useEffect(() => { - if (!useWords) return; - const lines = wordLines as WordLyricsLine[]; - const apply = (time: number) => { - let li = -1; - for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; } - let wi = -1; - if (li >= 0) { - const ws = lines[li].words; - for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; } - } - const prev = prevWord.current; - if (prev.line === li && prev.word === wi) return; - if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line]) - for (const w of wordRefs.current[prev.line]) w.className = 'fsa-lyric-word'; - if (li >= 0 && wordRefs.current[li]) { - const ws = wordRefs.current[li]; - for (let j = 0; j < ws.length; j++) - ws[j].className = j < wi ? 'fsa-lyric-word played' : j === wi ? 'fsa-lyric-word active' : 'fsa-lyric-word'; - } - prevWord.current = { line: li, word: wi }; - }; - apply(getPlaybackProgressSnapshot().currentTime); - return subscribePlaybackProgress(s => apply(s.currentTime)); - }, [useWords, wordLines]); - - const handleUserScroll = useCallback(() => { - scrollerRef.current?.stop(); - isUserScroll.current = true; - if (scrollTimer.current) clearTimeout(scrollTimer.current); - scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000); - }, []); - - const handleClick = 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]); - - if (!currentTrack || loading) return null; - - const isPlain = !hasSynced && !!plainLyrics; - - return ( -