From 463d3e0c5b6e52cdf748a8456b58136c942b2bf5 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 22:52:08 +0200 Subject: [PATCH] =?UTF-8?q?refactor(fullscreen-player):=20H5=20=E2=80=94?= =?UTF-8?q?=20split=20FullscreenPlayer.tsx=20911=20=E2=86=92=20228=20LOC?= =?UTF-8?q?=20across=2011=20files=20(#668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(fullscreen-player): H5 — extract FsLyricsApple + FsLyricsRail + useWordLyricsSync The two lyrics views become own files under components/fullscreenPlayer/. Their identical word-sync imperative DOM-update useEffect (only differing in the .fsa-/.fsr- class prefix) collapses into the shared useWordLyricsSync hook with a classPrefix arg. FullscreenPlayer.tsx: 911 → 613 LOC. * refactor(fullscreen-player): H5 — extract FsArt + FsPortrait + FsSeekbar The three visual subcomponents move into own files. formatTime moves into utils/fullscreenPlayerHelpers.ts so FsSeekbar keeps using it. FullscreenPlayer.tsx: 613 → 421 LOC. * refactor(fullscreen-player): H5 — extract FsLyricsMenu + FsPlayBtn The lyrics-settings popover and the isolated play/pause button move into own files. FullscreenPlayer drops the now-unused lucide-react icons (Play/Pause/Moon/Sunrise/Music) and the PlaybackDelayModal + PlaybackScheduleBadge imports — both used only by FsPlayBtn now. FullscreenPlayer.tsx: 421 → 304 LOC. * refactor(fullscreen-player): H5 — extract useFsDynamicAccent + useFsArtistPortrait + useFsIdleFade Pulls three state+effect islands out of FullscreenPlayer: - useFsDynamicAccent owns the cover-blob fetch + extractCoverColors call plus the module-level artKey → accent cache that makes same-album song switches instant. - useFsArtistPortrait fetches getArtistInfo().largeImageUrl for the right- side portrait, returning '' until resolved (or when no artistId). - useFsIdleFade flips isIdle true after 3 s of inactivity, exposes a throttled mousemove handler, and binds Escape to the provided callback. FullscreenPlayer.tsx: 304 → 228 LOC. --- src/components/FullscreenPlayer.tsx | 723 +----------------- src/components/fullscreenPlayer/FsArt.tsx | 54 ++ .../fullscreenPlayer/FsLyricsApple.tsx | 157 ++++ .../fullscreenPlayer/FsLyricsMenu.tsx | 77 ++ .../fullscreenPlayer/FsLyricsRail.tsx | 92 +++ src/components/fullscreenPlayer/FsPlayBtn.tsx | 46 ++ .../fullscreenPlayer/FsPortrait.tsx | 49 ++ src/components/fullscreenPlayer/FsSeekbar.tsx | 89 +++ src/hooks/useFsArtistPortrait.ts | 19 + src/hooks/useFsDynamicAccent.ts | 43 ++ src/hooks/useFsIdleFade.ts | 40 + src/hooks/useWordLyricsSync.ts | 62 ++ src/utils/fullscreenPlayerHelpers.ts | 6 + 13 files changed, 754 insertions(+), 703 deletions(-) create mode 100644 src/components/fullscreenPlayer/FsArt.tsx create mode 100644 src/components/fullscreenPlayer/FsLyricsApple.tsx create mode 100644 src/components/fullscreenPlayer/FsLyricsMenu.tsx create mode 100644 src/components/fullscreenPlayer/FsLyricsRail.tsx create mode 100644 src/components/fullscreenPlayer/FsPlayBtn.tsx create mode 100644 src/components/fullscreenPlayer/FsPortrait.tsx create mode 100644 src/components/fullscreenPlayer/FsSeekbar.tsx create mode 100644 src/hooks/useFsArtistPortrait.ts create mode 100644 src/hooks/useFsDynamicAccent.ts create mode 100644 src/hooks/useFsIdleFade.ts create mode 100644 src/hooks/useWordLyricsSync.ts create mode 100644 src/utils/fullscreenPlayerHelpers.ts 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 ( -