mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
8ff630cb5c
commit
463d3e0c5b
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user