refactor(fullscreen-player): H5 — split FullscreenPlayer.tsx 911 → 228 LOC across 11 files (#668)

* 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.
This commit is contained in:
Frank Stellmacher
2026-05-13 22:52:08 +02:00
committed by GitHub
parent 8ff630cb5c
commit 463d3e0c5b
13 changed files with 754 additions and 703 deletions
+54
View File
@@ -0,0 +1,54 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Music } from 'lucide-react';
import { useCachedUrl } from '../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<Array<{ src: string; id: number; vis: boolean }>>([]);
const counter = useRef(0);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!blobUrl) 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 <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
}
return (
<>
{layers.map(l => (
<img
key={l.id}
src={l.src}
className="fs-art"
style={{ opacity: l.vis ? 1 : 0 }}
onLoad={() => handleLoad(l.id)}
alt=""
decoding="async"
/>
))}
</>
);
});
@@ -0,0 +1,157 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { useLyrics, type WordLyricsLine } from '../../hooks/useLyrics';
import { useWordLyricsSync } from '../../hooks/useWordLyricsSync';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
import type { LrcLine } from '../../api/lrclib';
import type { Track } from '../../store/playerStoreTypes';
import { EaseScroller, targetForFraction } from '../../utils/easeScroll';
// 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.
export 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<LrcLine[]>([]);
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<HTMLDivElement | null>(null);
const scrollerRef = useRef<EaseScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | 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 = [];
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]);
const { setWordRef } = useWordLyricsSync({
enabled: useWords,
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
currentTrack,
classPrefix: 'fsa',
});
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<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[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 (
<div
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onClick={handleClick}
aria-hidden="true"
>
<div className="fsa-lyrics-top-pad" />
{hasSynced && (useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0
? line.words.map((w, j) => (
<span
key={j}
className="fsa-lyric-word"
ref={setWordRef(i, j)}
>{w.text}</span>
))
: (line.text || ' ')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
data-time={line.time}
>
{line.text || ' '}
</div>
))
)}
{!hasSynced && plainLyrics && (
<div className="fsa-plain-lyrics">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="fsa-plain-line">{line || ' '}</p>
))}
</div>
)}
<div className="fsa-lyrics-bottom-pad" />
</div>
);
});
@@ -0,0 +1,77 @@
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<HTMLElement | null>;
}
// 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<HTMLDivElement>(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;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') 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);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
return () => {
clearTimeout(t);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose, triggerRef]);
if (!open) return null;
const accent = accentColor ?? 'var(--accent)';
return (
<div className="fslm-panel" ref={panelRef}>
<div className="fslm-row">
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
<input
type="checkbox"
checked={showLyrics}
onChange={e => setLyrics(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
{(['rail', 'apple'] as const).map(style => (
<button
key={style}
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
onClick={() => setStyle(style)}
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
>
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
</button>
))}
</div>
<div className="fslm-arrow" />
</div>
);
});
@@ -0,0 +1,92 @@
import React, { memo, useCallback, useEffect, useRef } from 'react';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { useLyrics, type WordLyricsLine } from '../../hooks/useLyrics';
import { useWordLyricsSync } from '../../hooks/useWordLyricsSync';
import type { LrcLine } from '../../api/lrclib';
import type { Track } from '../../store/playerStoreTypes';
// 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<LrcLine[]>([]);
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<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[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 (
<div className="fsr-lyrics-overlay" aria-hidden="true">
<div
className="fsr-lyrics-rail"
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
{useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="fsr-lyric-word"
ref={setWordRef(i, j)}
>{w.text}</span>
)) : (line.text || ' ')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.text || ' '}
</div>
))}
</div>
</div>
);
});
@@ -0,0 +1,46 @@
import React, { memo, useRef } from 'react';
import { Moon, Pause, Play, Sunrise } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../../store/playerStore';
import { usePlaybackDelayPress } from '../../hooks/usePlaybackDelayPress';
import { usePlaybackScheduleRemaining } from '../../utils/playbackScheduleFormat';
import PlaybackDelayModal from '../PlaybackDelayModal';
import PlaybackScheduleBadge from '../PlaybackScheduleBadge';
// Play/Pause button (isolated — subscribes to isPlaying only).
export const FsPlayBtn = memo(function FsPlayBtn({
controlsAnchorRef,
}: {
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
return (
<>
<span ref={playSlotRef} className="playback-transport-play-wrap">
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
<button
type="button"
className="fs-btn fs-btn-play"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--fs`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={12} strokeWidth={2.5} />
: <Sunrise size={12} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
</span>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
</>
);
});
@@ -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<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counterRef = useRef(1);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="fs-portrait-wrap" aria-hidden="true">
{layers.map(layer => (
<img
key={layer.id}
src={layer.url}
className="fs-portrait"
style={{ opacity: layer.visible ? 1 : 0 }}
decoding="async"
loading="eager"
alt=""
/>
))}
</div>
);
});
@@ -0,0 +1,89 @@
import React, { memo, useCallback, useEffect, useRef } from 'react';
import { usePlayerStore } from '../../store/playerStore';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
import { formatTime } from '../../utils/fullscreenPlayerHelpers';
// 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<HTMLSpanElement>(null);
const playedRef = useRef<HTMLDivElement>(null);
const bufRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const isDraggingRef = useRef(false);
const pendingSeekRef = useRef<number | null>(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 = formatTime(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 = formatTime(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 = formatTime(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<HTMLInputElement>) => {
previewSeek(parseFloat(e.target.value));
},
[previewSeek]
);
return (
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span ref={timeRef} />
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" ref={bufRef} />
<div className="fs-seekbar-played" ref={playedRef} />
<input
ref={inputRef}
type="range" min={0} max={1} step={0.001}
defaultValue={0}
onChange={handleSeek}
onMouseDown={() => { 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(); }}
onKeyUp={commitSeek}
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
aria-label="seek"
/>
</div>
</div>
);
});