mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
463d3e0c5b
* 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.
158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
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>
|
||
);
|
||
});
|