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
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { getArtistInfo } from '../api/subsonicArtists';
/** Fetches the large artist image for the given artist id, returning '' until
* the request resolves (or when there is no artist id). Falls through silently
* on network failures — the caller should layer a cover-art fallback on top. */
export function useFsArtistPortrait(artistId: string | undefined): string {
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
if (!artistId) return;
let cancelled = false;
getArtistInfo(artistId).then(info => {
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
}).catch(() => {});
return () => { cancelled = true; };
}, [artistId]);
return artistBgUrl;
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { extractCoverColors } from '../utils/dynamicColors';
// Module-level cache: artKey → accent color string.
// Survives track changes so same-album songs reuse the extracted color instantly.
const coverAccentCache = new Map<string, string>();
/** Extract a dominant accent color from the current cover art and cache it by
* artKey. Cache hits return instantly; cache misses fetch the cover blob,
* run extractCoverColors, then cache + apply the result. Keeps the previous
* color visible until extraction completes so the UI doesn't flash to default. */
export function useFsDynamicAccent(artUrl: string, artKey: string): string | null {
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
useEffect(() => {
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
const cached = coverAccentCache.get(artKey);
if (cached) { setDynamicAccent(cached); return; }
let cancelled = false;
let blobUrl = '';
(async () => {
try {
const resp = await fetch(artUrl);
if (cancelled) return;
const blob = await resp.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
const colors = await extractCoverColors(blobUrl);
if (cancelled) return;
if (colors.accent) {
coverAccentCache.set(artKey, colors.accent);
setDynamicAccent(colors.accent);
}
} catch { /* ignore */ } finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artKey]);
return dynamicAccent;
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** Idle-fade system — flips `isIdle` true after 3 s of no user activity.
* Returns the boolean plus a mousemove handler that's throttled to one reset
* per ~200 ms so cleanup/start timers don't fire on every mouse pixel.
* Also resets on key presses and triggers `onEscape` when the user hits Esc. */
export function useFsIdleFade(onEscape: () => void) {
const [isIdle, setIsIdle] = useState(false);
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetIdle = useCallback(() => {
setIsIdle(false);
if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => setIsIdle(true), 3000);
}, []);
const lastMoveTime = useRef(0);
const handleMouseMove = useCallback(() => {
const now = Date.now();
if (now - lastMoveTime.current < 200) return;
lastMoveTime.current = now;
resetIdle();
}, [resetIdle]);
useEffect(() => {
resetIdle();
return () => { if (idleTimer.current) clearTimeout(idleTimer.current); };
}, [resetIdle]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
resetIdle();
if (e.key === 'Escape') onEscape();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onEscape, resetIdle]);
return { isIdle, handleMouseMove };
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
import type { Track } from '../store/playerStoreTypes';
import type { WordLyricsLine } from './useLyrics';
interface Args {
enabled: boolean;
wordLines: WordLyricsLine[] | null;
currentTrack: Track | null;
/** CSS class prefix — `fsa` for Apple Music view, `fsr` for rail view. */
classPrefix: 'fsa' | 'fsr';
}
/** Imperative word-sync DOM updates — toggles the per-word `<span>` classes
* (`<prefix>-lyric-word`, ` played`, ` active`) from a single playback-progress
* subscription without re-rendering React on every tick. Returns the ref array
* the consumer attaches to each word span. */
export function useWordLyricsSync({ enabled, wordLines, currentTrack, classPrefix }: Args) {
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
useEffect(() => {
wordRefs.current = [];
prevWord.current = { line: -1, word: -1 };
}, [currentTrack?.id, enabled]);
useEffect(() => {
if (!enabled || !wordLines) return;
const lines = wordLines;
const baseClass = `${classPrefix}-lyric-word`;
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 = baseClass;
}
if (li >= 0 && wordRefs.current[li]) {
const ws = wordRefs.current[li];
for (let j = 0; j < ws.length; j++) {
ws[j].className = j < wi ? `${baseClass} played` : j === wi ? `${baseClass} active` : baseClass;
}
}
prevWord.current = { line: li, word: wi };
};
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [enabled, wordLines, classPrefix]);
const setWordRef = (lineIdx: number, wordIdx: number) => (el: HTMLSpanElement | null) => {
if (!wordRefs.current[lineIdx]) wordRefs.current[lineIdx] = [];
if (el) wordRefs.current[lineIdx][wordIdx] = el;
};
return { setWordRef };
}