mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
@@ -1,641 +1,30 @@
|
|||||||
import { star, unstar } from '../api/subsonicStarRating';
|
import { star, unstar } from '../api/subsonicStarRating';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||||
import { getArtistInfo } from '../api/subsonicArtists';
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
|
||||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward,
|
SkipBack, SkipForward,
|
||||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
|
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||||
Moon, Sunrise,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { getCachedBlob } from '../utils/imageCache';
|
import { getCachedBlob } from '../utils/imageCache';
|
||||||
import { extractCoverColors } from '../utils/dynamicColors';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import type { LrcLine } from '../api/lrclib';
|
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
|
||||||
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
|
import { FsArt } from './fullscreenPlayer/FsArt';
|
||||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
|
||||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
|
||||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
|
||||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
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<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 wordRefs = useRef<HTMLSpanElement[][]>([]);
|
|
||||||
const prevWord = useRef({ line: -1, word: -1 });
|
|
||||||
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 = [];
|
|
||||||
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<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={el => {
|
|
||||||
if (!wordRefs.current[i]) wordRefs.current[i] = [];
|
|
||||||
if (el) wordRefs.current[i][j] = el;
|
|
||||||
}}
|
|
||||||
>{w.text}</span>
|
|
||||||
))
|
|
||||||
: (line.text || '\u00A0')}
|
|
||||||
</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 || '\u00A0'}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasSynced && plainLyrics && (
|
|
||||||
<div className="fsa-plain-lyrics">
|
|
||||||
{plainLyrics.split('\n').map((line, i) => (
|
|
||||||
<p key={i} className="fsa-plain-line">{line || '\u00A0'}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="fsa-lyrics-bottom-pad" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Classic 5-line rail lyrics (original "Rail" style) ──────────────────────
|
|
||||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
|
||||||
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 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, 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 = 'fsr-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 ? 'fsr-lyric-word played' : j === wi ? 'fsr-lyric-word active' : 'fsr-lyric-word';
|
|
||||||
}
|
|
||||||
prevWord.current = { line: li, word: wi };
|
|
||||||
};
|
|
||||||
apply(getPlaybackProgressSnapshot().currentTime);
|
|
||||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
|
||||||
}, [useWords, wordLines]);
|
|
||||||
|
|
||||||
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={el => {
|
|
||||||
if (!wordRefs.current[i]) wordRefs.current[i] = [];
|
|
||||||
if (el) wordRefs.current[i][j] = el;
|
|
||||||
}}
|
|
||||||
>{w.text}</span>
|
|
||||||
)) : (line.text || '\u00A0')}
|
|
||||||
</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 || '\u00A0'}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 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.
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Add a new invisible layer whenever the blob URL changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!blobUrl) return;
|
|
||||||
const id = ++counter.current;
|
|
||||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
|
||||||
}, [blobUrl]);
|
|
||||||
|
|
||||||
// Called by the DOM <img> once it has painted at opacity:0 — now safe to transition.
|
|
||||||
// Cancel any pending cleanup timer so a stale setTimeout from a previous layer
|
|
||||||
// cannot remove the layer we are making visible right now.
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Artist portrait — right half, crossfades on track change ─────────────────
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Full-width seekbar — imperative DOM updates, zero React re-renders on tick ─
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Lyrics settings popover — shown above the mic button ────────────────────
|
|
||||||
interface FsLyricsMenuProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
accentColor: string | null;
|
|
||||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
|
||||||
}
|
|
||||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: FsLyricsMenuProps) {
|
|
||||||
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}>
|
|
||||||
{/* Toggle row */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Style selector — dimmed when lyrics are off */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Main component ────────────────────────────────────────────────────────────
|
|
||||||
interface FullscreenPlayerProps {
|
interface FullscreenPlayerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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>();
|
|
||||||
|
|
||||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
@@ -676,55 +65,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||||
|
|
||||||
// Dynamic accent color extracted from the current album cover.
|
// Dynamic accent color extracted from the current album cover, applied as
|
||||||
// Applied as --dynamic-fs-accent on the root element so it inherits to all
|
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||||
// children; CSS rules use var(--dynamic-fs-accent, var(--accent)) as fallback.
|
// same-album tracks reuse the color without re-fetching.
|
||||||
// Reset to null on track change so the previous color doesn't linger while
|
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||||
// the new one is being extracted.
|
|
||||||
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// On cover change: hit cache for instant result, or fetch → extract → cache.
|
|
||||||
// Cache hit avoids re-fetching for same-album tracks. Reset only when uncached.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
|
|
||||||
const cached = coverAccentCache.get(artKey);
|
|
||||||
if (cached) { setDynamicAccent(cached); return; }
|
|
||||||
// No cache hit — keep the previous color visible until extraction completes.
|
|
||||||
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; };
|
|
||||||
}, [artKey]);
|
|
||||||
|
|
||||||
// Artist image → portrait on right. Falls back to cover art.
|
// Artist image → portrait on right. Falls back to cover art.
|
||||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||||
useEffect(() => {
|
|
||||||
setArtistBgUrl('');
|
|
||||||
const artistId = currentTrack?.artistId;
|
|
||||||
if (!artistId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
getArtistInfo(artistId).then(info => {
|
|
||||||
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
|
|
||||||
}).catch(() => {});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [currentTrack?.artistId]);
|
|
||||||
|
|
||||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||||
@@ -752,38 +99,8 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Idle-fade system — hides controls after 3 s of inactivity
|
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
|
||||||
const [isIdle, setIsIdle] = useState(false);
|
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Throttled wrapper for mousemove — avoids clearing/setting timeouts on every pixel.
|
|
||||||
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') onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [onClose, resetIdle]);
|
|
||||||
|
|
||||||
const metaParts = useMemo(() => [
|
const metaParts = useMemo(() => [
|
||||||
currentTrack?.album,
|
currentTrack?.album,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export 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')}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user