mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Merge pull request #205 from kilyabin/apple-music-style-lyric
feat(lyrics): Apple Music-style scrolling lyrics with per-style controls
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.11",
|
||||
"version": "1.34.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.11",
|
||||
"version": "1.34.13",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -21,19 +22,197 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window:
|
||||
// activeIdx=0 → railY=+2×slotH (line 0 at slot 2)
|
||||
// activeIdx=2 → railY=0 (line 2 at center)
|
||||
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
|
||||
// ─── 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 FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
|
||||
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);
|
||||
const springRef = useRef<SpringScroller | 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);
|
||||
|
||||
// Create/destroy the SpringScroller when the container mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (el) {
|
||||
springRef.current = new SpringScroller(el, 0.1, 0.78);
|
||||
} else {
|
||||
springRef.current?.stop();
|
||||
springRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset everything on track change.
|
||||
useEffect(() => {
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
prevWord.current = { line: -1, word: -1 };
|
||||
activeIdxRef.current = -1;
|
||||
setActiveIdx(-1);
|
||||
springRef.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(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [hasSynced, currentTrack?.id]);
|
||||
|
||||
// Spring-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 || !springRef.current) return;
|
||||
springRef.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(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
const handleUserScroll = useCallback(() => {
|
||||
// Stop spring animation so it doesn't fight the user's scroll.
|
||||
springRef.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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsa-lyrics-container"
|
||||
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);
|
||||
|
||||
// Static-only hides the FS overlay entirely — the 5-line rail UX is inherently
|
||||
// time-driven; without sync the pane view is the correct surface.
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
@@ -65,8 +244,6 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
// Per-word DOM refs keyed by line index; imperative updates skip re-renders
|
||||
// on progress ticks. Only populated when `useWords` is true.
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
|
||||
|
||||
@@ -78,41 +255,27 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
useEffect(() => {
|
||||
if (!useWords) return;
|
||||
const lines = wordLines as WordLyricsLine[];
|
||||
|
||||
const apply = (time: number) => {
|
||||
let lineIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (time >= lines[i].time) lineIdx = i;
|
||||
else break;
|
||||
}
|
||||
let wordIdx = -1;
|
||||
if (lineIdx >= 0) {
|
||||
const words = lines[lineIdx].words;
|
||||
for (let j = 0; j < words.length; j++) {
|
||||
if (time >= words[j].time) wordIdx = j;
|
||||
else break;
|
||||
}
|
||||
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 === lineIdx && prev.word === wordIdx) return;
|
||||
// Clear previous line's word classes.
|
||||
if (prev.line !== lineIdx && prev.line >= 0 && wordRefs.current[prev.line]) {
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'fs-lyric-word';
|
||||
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';
|
||||
}
|
||||
if (lineIdx >= 0 && wordRefs.current[lineIdx]) {
|
||||
const ws = wordRefs.current[lineIdx];
|
||||
for (let j = 0; j < ws.length; j++) {
|
||||
ws[j].className = j < wordIdx ? 'fs-lyric-word played'
|
||||
: j === wordIdx ? 'fs-lyric-word active'
|
||||
: 'fs-lyric-word';
|
||||
}
|
||||
}
|
||||
prevWord.current = { line: lineIdx, word: wordIdx };
|
||||
prevWord.current = { line: li, word: wi };
|
||||
};
|
||||
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
return unsub;
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
@@ -120,9 +283,9 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fs-lyrics-overlay" aria-hidden="true">
|
||||
<div className="fsr-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fs-lyrics-rail"
|
||||
className="fsr-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
@@ -130,27 +293,25 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
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="fs-lyric-word"
|
||||
className="fsr-lyric-word"
|
||||
ref={el => {
|
||||
if (!wordRefs.current[i]) wordRefs.current[i] = [];
|
||||
if (el) wordRefs.current[i][j] = el;
|
||||
}}
|
||||
>
|
||||
{w.text}
|
||||
</span>
|
||||
>{w.text}</span>
|
||||
)) : (line.text || '\u00A0')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
@@ -316,6 +477,77 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Lyrics settings popover — shown above the mic button ────────────────────
|
||||
interface FsLyricsMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
}
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: 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.
|
||||
// setTimeout(0) defers listener registration past the current click cycle
|
||||
// so the button click that opens the panel doesn't immediately close it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) 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]);
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
@@ -428,8 +660,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
||||
@@ -445,6 +679,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
getCachedUrl(url, key).catch(() => {});
|
||||
}, [nextCoverArt]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -492,6 +730,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
@@ -505,7 +744,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
@@ -516,8 +755,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics overlay — upper-left quadrant, above cluster */}
|
||||
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
@@ -575,16 +817,19 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import type { LrcLine } from '../api/lrclib';
|
||||
@@ -6,21 +6,25 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Track } from '../store/playerStore';
|
||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-sync highlighting is driven imperatively via `usePlayerStore.subscribe`
|
||||
* so the whole lyrics block doesn't re-render on every 500 ms progress tick.
|
||||
* Active-line scroll is still React-state-driven — lines change infrequently.
|
||||
* Apple Music-style scroll: active line scrolls to ~35% from top.
|
||||
* User scrolling pauses auto-scroll for 4 s then resumes.
|
||||
* Word-sync and line highlighting are imperative (no React re-renders per tick).
|
||||
*/
|
||||
export default function LyricsPane({ currentTrack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { syncedLines, wordLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
|
||||
const { staticOnly } = useAuthStore(useShallow(s => ({ staticOnly: s.lyricsStaticOnly })));
|
||||
const { staticOnly, sidebarLyricsStyle } = useAuthStore(useShallow(s => ({
|
||||
staticOnly: s.lyricsStaticOnly,
|
||||
sidebarLyricsStyle: s.sidebarLyricsStyle,
|
||||
})));
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const hasSynced = !staticOnly && !useWords && syncedLines !== null && syncedLines.length > 0;
|
||||
@@ -28,19 +32,52 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const springRef = useRef<SpringScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||
const prevActive = useRef({ line: -1, word: -1 });
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Attach/detach SpringScroller when the pane mounts.
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
springRef.current?.stop();
|
||||
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
|
||||
}, []);
|
||||
|
||||
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
|
||||
const handleUserScroll = useCallback(() => {
|
||||
springRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
}, []);
|
||||
|
||||
// Scroll active line into view.
|
||||
// Apple style: spring-animate to ~35% from top.
|
||||
// Classic style: native scrollIntoView center.
|
||||
const scrollToLine = useCallback((el: HTMLDivElement) => {
|
||||
if (isUserScroll.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (sidebarLyricsStyle === 'apple' && springRef.current) {
|
||||
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [sidebarLyricsStyle]);
|
||||
|
||||
// Reset refs when track changes.
|
||||
useEffect(() => {
|
||||
lineRefs.current = [];
|
||||
wordRefs.current = [];
|
||||
prevActive.current = { line: -1, word: -1 };
|
||||
springRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Imperative tracker for line+word highlighting. Subscribes directly to the
|
||||
// store to skip React render cycles for 500 ms progress ticks.
|
||||
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
|
||||
useEffect(() => {
|
||||
if (!useWords && !hasSynced) return;
|
||||
|
||||
@@ -64,7 +101,6 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const prev = prevActive.current;
|
||||
if (prev.line === lineIdx && prev.word === wordIdx) return;
|
||||
|
||||
// Update line classes.
|
||||
if (prev.line !== lineIdx) {
|
||||
if (prev.line >= 0) {
|
||||
const el = lineRefs.current[prev.line];
|
||||
@@ -74,16 +110,14 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const el = lineRefs.current[lineIdx];
|
||||
if (el) {
|
||||
el.className = lineClass(lineIdx, lineIdx);
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
scrollToLine(el);
|
||||
}
|
||||
}
|
||||
// Reset all word classes on previous line.
|
||||
if (useWords && prev.line >= 0 && wordRefs.current[prev.line]) {
|
||||
for (const w of wordRefs.current[prev.line]) w.className = 'lyrics-word';
|
||||
}
|
||||
}
|
||||
|
||||
// Update word classes inside the active line.
|
||||
if (useWords && lineIdx >= 0 && wordRefs.current[lineIdx]) {
|
||||
const ws = wordRefs.current[lineIdx];
|
||||
for (let j = 0; j < ws.length; j++) {
|
||||
@@ -96,11 +130,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
prevActive.current = { line: lineIdx, word: wordIdx };
|
||||
};
|
||||
|
||||
// Prime once from the current store value.
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
return unsub;
|
||||
}, [useWords, hasSynced, wordLines, syncedLines]);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
|
||||
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
@@ -120,14 +152,18 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
? t('player.lyricsSourceLyricsplus')
|
||||
: null;
|
||||
|
||||
// Static-only + synced or words available → render line list as static text.
|
||||
const renderAsStatic = staticOnly && (
|
||||
(syncedLines !== null && syncedLines.length > 0) ||
|
||||
(wordLines !== null && wordLines.length > 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
<div
|
||||
className="lyrics-pane"
|
||||
ref={setContainerRef}
|
||||
onWheel={handleUserScroll}
|
||||
onTouchMove={handleUserScroll}
|
||||
>
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
||||
|
||||
|
||||
@@ -673,6 +673,16 @@ export const deTranslation = {
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
fsLyricsStyle: 'Liedtext-Stil',
|
||||
fsLyricsStyleRail: 'Schiene',
|
||||
fsLyricsStyleRailDesc: 'Klassische 5-Zeilen-Schiene.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Vollbild-Scrollliste.',
|
||||
sidebarLyricsStyle: 'Text-Scroll-Stil',
|
||||
sidebarLyricsStyleClassic: 'Klassisch',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktive Zeile wird zentriert.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
seekbarWaveform: 'Wellenform',
|
||||
|
||||
@@ -672,6 +672,16 @@ export const enTranslation = {
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Fullscreen Player',
|
||||
fsLyricsStyle: 'Lyrics style',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Classic 5-line sliding rail.',
|
||||
fsLyricsStyleApple: 'Scrolling',
|
||||
fsLyricsStyleAppleDesc: 'Full-screen scrollable list.',
|
||||
sidebarLyricsStyle: 'Lyrics scroll style',
|
||||
sidebarLyricsStyleClassic: 'Classic',
|
||||
sidebarLyricsStyleClassicDesc: 'Scroll active line to center.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
|
||||
@@ -676,6 +676,16 @@ export const esTranslation = {
|
||||
fsShowArtistPortrait: 'Mostrar foto del artista',
|
||||
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
|
||||
fsPortraitDim: 'Oscurecimiento de foto',
|
||||
fsLyricsStyle: 'Estilo de letra',
|
||||
fsLyricsStyleRail: 'Carril',
|
||||
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
|
||||
fsLyricsStyleApple: 'Desplazamiento',
|
||||
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
|
||||
sidebarLyricsStyle: 'Estilo de desplazamiento de letra',
|
||||
sidebarLyricsStyleClassic: 'Clásico',
|
||||
sidebarLyricsStyleClassicDesc: 'La línea activa se centra.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
||||
seekbarStyle: 'Estilo de Barra de Progreso',
|
||||
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
||||
seekbarWaveform: 'Forma de Onda',
|
||||
|
||||
@@ -671,6 +671,16 @@ export const frTranslation = {
|
||||
fsShowArtistPortrait: "Afficher la photo de l'artiste",
|
||||
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
|
||||
fsPortraitDim: "Assombrissement de la photo",
|
||||
fsLyricsStyle: "Style des paroles",
|
||||
fsLyricsStyleRail: "Rail",
|
||||
fsLyricsStyleRailDesc: "Rail glissant classique de 5 lignes.",
|
||||
fsLyricsStyleApple: "Défilement",
|
||||
fsLyricsStyleAppleDesc: "Liste déroulante plein écran.",
|
||||
sidebarLyricsStyle: "Style de défilement des paroles",
|
||||
sidebarLyricsStyleClassic: "Classique",
|
||||
sidebarLyricsStyleClassicDesc: "La ligne active est centrée.",
|
||||
sidebarLyricsStyleApple: "Apple Music-like",
|
||||
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
seekbarWaveform: 'Forme d\'onde',
|
||||
|
||||
@@ -670,6 +670,16 @@ export const nbTranslation = {
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
fsLyricsStyle: 'Tekststil',
|
||||
fsLyricsStyleRail: 'Skinner',
|
||||
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
|
||||
fsLyricsStyleApple: 'Rulling',
|
||||
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
|
||||
sidebarLyricsStyle: 'Rullestil for tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassisk',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
seekbarWaveform: 'Bølgeform',
|
||||
|
||||
@@ -670,6 +670,16 @@ export const nlTranslation = {
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
fsLyricsStyle: 'Tekststijl',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Klassieke 5-regels schuifrail.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Volledig scherm scrolllijst.',
|
||||
sidebarLyricsStyle: 'Scrollstijl voor tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassiek',
|
||||
sidebarLyricsStyleClassicDesc: 'Actieve regel wordt gecentreerd.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
seekbarWaveform: 'Golfvorm',
|
||||
|
||||
@@ -698,6 +698,16 @@ export const ruTranslation = {
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
fsLyricsStyle: 'Стиль отображения текста',
|
||||
fsLyricsStyleRail: 'Рельс',
|
||||
fsLyricsStyleRailDesc: 'Классическая рамка из 5 строк.',
|
||||
fsLyricsStyleApple: 'Прокрутка',
|
||||
fsLyricsStyleAppleDesc: 'Полноэкранный список с прокруткой.',
|
||||
sidebarLyricsStyle: 'Стиль прокрутки текста',
|
||||
sidebarLyricsStyleClassic: 'Классический',
|
||||
sidebarLyricsStyleClassicDesc: 'Активная строка центрируется.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
|
||||
@@ -666,6 +666,16 @@ export const zhTranslation = {
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
fsLyricsStyle: '歌词显示样式',
|
||||
fsLyricsStyleRail: '轨道',
|
||||
fsLyricsStyleRailDesc: '经典5行滑动轨道。',
|
||||
fsLyricsStyleApple: '滚动',
|
||||
fsLyricsStyleAppleDesc: '全屏可滚动列表。',
|
||||
sidebarLyricsStyle: '歌词滚动样式',
|
||||
sidebarLyricsStyleClassic: '经典',
|
||||
sidebarLyricsStyleClassicDesc: '活动行居中显示。',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
|
||||
@@ -1884,6 +1884,40 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.sidebarLyricsStyle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['classic', 'apple'] as const).map(style => {
|
||||
const key = style === 'classic' ? 'Classic' : 'Apple';
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => auth.setSidebarLyricsStyle(style)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 10,
|
||||
border: `2px solid ${auth.sidebarLyricsStyle === style ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: auth.sidebarLyricsStyle === style ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-secondary)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
|
||||
@@ -81,6 +81,10 @@ interface AuthState {
|
||||
*/
|
||||
lyricsStaticOnly: boolean;
|
||||
showFullscreenLyrics: boolean;
|
||||
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
|
||||
fsLyricsStyle: 'rail' | 'apple';
|
||||
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
|
||||
sidebarLyricsStyle: 'classic' | 'apple';
|
||||
showFsArtistPortrait: boolean;
|
||||
/** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */
|
||||
fsPortraitDim: number;
|
||||
@@ -212,6 +216,8 @@ interface AuthState {
|
||||
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
|
||||
setLyricsStaticOnly: (v: boolean) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
|
||||
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
@@ -315,6 +321,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
lyricsMode: 'standard',
|
||||
lyricsStaticOnly: false,
|
||||
showFullscreenLyrics: true,
|
||||
fsLyricsStyle: 'rail',
|
||||
sidebarLyricsStyle: 'classic',
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
@@ -442,6 +450,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
setLyricsMode: (v) => set({ lyricsMode: v }),
|
||||
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
|
||||
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
|
||||
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
|
||||
+325
-83
@@ -2645,8 +2645,9 @@
|
||||
.lyrics-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
/* scroll-behavior intentionally left to auto — JS drives smooth scroll imperatively */
|
||||
padding: 16px 16px 8px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.lyrics-pane-empty {
|
||||
@@ -2667,57 +2668,71 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0 16px;
|
||||
padding: 24px 0 40px; /* top pad lets first line start below centre, bottom lets last line scroll up */
|
||||
}
|
||||
|
||||
.lyrics-line {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
transition: color 0.25s;
|
||||
line-height: 1.55;
|
||||
cursor: default;
|
||||
transform-origin: center center;
|
||||
/* All transitions are compositor-friendly */
|
||||
transition:
|
||||
color 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
text-shadow 350ms ease;
|
||||
transform: scale(0.92);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
transform: scale(0.88);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.lyrics-line.active {
|
||||
color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.lyrics-line:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Word-sync (karaoke) mode — per-word highlight inside the active line.
|
||||
The parent `.lyrics-line.active` colour is overridden: only `.active` / `.played`
|
||||
words carry accent tint, the remaining words stay at the line's base colour. */
|
||||
/* Word-sync: neutralise line colour so only individual words pop */
|
||||
.lyrics-word-synced .lyrics-line {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lyrics-word-synced .lyrics-line.active {
|
||||
color: var(--text-secondary); /* line colour neutralised; words decide */
|
||||
color: var(--text-muted);
|
||||
text-shadow: none;
|
||||
}
|
||||
.lyrics-word-synced .lyrics-line.completed {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lyrics-word {
|
||||
display: inline;
|
||||
transition: color 0.18s ease;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
.lyrics-word.played {
|
||||
color: var(--accent);
|
||||
opacity: 0.65;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.lyrics-word.active {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
@@ -3502,51 +3517,296 @@
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* ── Lyrics overlay — upper-left quadrant, strictly 5 lines tall ── */
|
||||
/* Slot = 6vh → 5 × 6vh = 30vh. JS reads window.innerHeight * 0.06. */
|
||||
.fs-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 15vh; /* pushed down into the quadrant, clear of close button */
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
right: 52%; /* stay out of portrait area */
|
||||
height: 30vh; /* exactly 5 × 6vh — never grows, never overlaps cluster */
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout style; /* isolate reflows from rest of page */
|
||||
/* ── Apple Music-style fullscreen lyrics ─────────────────────────────────────
|
||||
Full-screen scrollable list. Active line scrolls to ~35% from top.
|
||||
User scroll pauses auto-scroll for 4 s. Word-sync via imperative DOM updates. */
|
||||
|
||||
/* Fade first and last slot (each 6/30 = 20% of container) */
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 20%,
|
||||
#000 80%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 20%,
|
||||
#000 80%,
|
||||
transparent 100%
|
||||
);
|
||||
.fsa-lyrics-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
padding: 0 clamp(28px, 5vw, 72px);
|
||||
/* pointer-events on children only — container itself scrollable */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Synced rail — smooth slide, no abrupt jumps */
|
||||
.fs-lyrics-rail {
|
||||
.fsa-lyrics-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Top padding — clears the close button (44 px) + some breathing room */
|
||||
.fsa-lyrics-top-pad {
|
||||
height: 80px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Bottom padding — lets last line scroll to ~35% from top even on short tracks */
|
||||
.fsa-lyrics-bottom-pad {
|
||||
height: 60vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Line base styles ── */
|
||||
.fsa-lyric-line {
|
||||
/* Fixed font-size avoids layout reflow on active-line switch.
|
||||
Visual "size" difference is done via transform: scale below. */
|
||||
font-size: clamp(1.4rem, 3.2vh, 3rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
margin-bottom: 0.45em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
transform-origin: left center;
|
||||
/* All visual changes are compositor-friendly (color, text-shadow, transform) */
|
||||
transition:
|
||||
color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
text-shadow 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(0.9);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fsa-lyric-line:hover {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Past: subtly smaller and more faded */
|
||||
.fsa-lyric-line.fsal-past {
|
||||
color: rgba(255, 255, 255, 0.16);
|
||||
transform: scale(0.86);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Active: full scale, bright, glowing — font-weight snaps (no layout cost on same size) */
|
||||
.fsa-lyric-line.fsal-active {
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
text-shadow:
|
||||
0 0 48px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 25%),
|
||||
0 2px 18px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* Word-sync — active line dims base so only lit words pop */
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.fsa-lyric-word {
|
||||
display: inline;
|
||||
white-space: pre; /* preserve trailing spaces from lyricsplus tokens */
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word.played {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.fsa-lyric-line.fsal-active .fsa-lyric-word.active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 32px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 10%);
|
||||
}
|
||||
|
||||
/* Plain (unsynced) lyrics */
|
||||
.fsa-plain-lyrics {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fsa-plain-line {
|
||||
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
margin: 0 0 0.2em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Gradient fades — top and bottom chrome ──────────────────────────────── */
|
||||
|
||||
/* Top fade: covers close button area; pointer-events none so button stays clickable */
|
||||
.fsa-fade-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(6, 6, 14, 0.92) 0%,
|
||||
rgba(6, 6, 14, 0.5) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Bottom fade: sits above lyrics (same z-index, later in DOM) but under cluster */
|
||||
.fsa-fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 45vh;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(6, 6, 14, 0.55) 40%,
|
||||
rgba(6, 6, 14, 0.88) 72%,
|
||||
rgba(6, 6, 14, 0.96) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout overrides when lyrics mode is active ────────────────────────── */
|
||||
|
||||
/* Hide portrait — lyrics use the full width */
|
||||
.fs-player[data-lyrics] .fs-portrait-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrim is redundant without the portrait — hide it */
|
||||
.fs-player[data-lyrics] .fs-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Lyrics settings popover (above mic button) ─────────────────────────────── */
|
||||
|
||||
/* The panel itself — floated above the mic button.
|
||||
Rendered inside position:relative wrapper (the button wrapper).
|
||||
z-index: 9 sits above the backdrop (8). */
|
||||
.fslm-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
width: 230px;
|
||||
z-index: 9;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
animation: fslmIn 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
@keyframes fslmIn {
|
||||
from { opacity: 0; transform: scale(0.88) translateY(6px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Toggle row */
|
||||
.fslm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fslm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Style selector */
|
||||
.fslm-style-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.fslm-disabled .fslm-style-row,
|
||||
.fslm-style-row.fslm-disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fslm-style-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fslm-style-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fslm-style-active {
|
||||
/* accent colors set inline via accentColor prop */
|
||||
}
|
||||
|
||||
.fslm-style-name {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fslm-style-desc {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down toward the button */
|
||||
.fslm-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ── Classic 5-line rail (Rail style) ───────────────────────────────────────── */
|
||||
|
||||
.fsr-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 15vh;
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
right: 52%;
|
||||
height: 30vh;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout style;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.fsr-lyrics-rail {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
will-change: transform;
|
||||
transition: transform 500ms ease-out;
|
||||
}
|
||||
|
||||
/* Each lyric slot is exactly 6vh — matches JS window.innerHeight * 0.06.
|
||||
Fixed height (not min-height) is intentional: keeps rail math correct
|
||||
regardless of whether the text wraps or not. 2 wrapped lines of 2vh
|
||||
font at 1.4 line-height = 5.6vh, comfortably inside the 6vh slot. */
|
||||
.fs-lyric-line {
|
||||
.fsr-lyric-line {
|
||||
height: 6vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3560,50 +3820,41 @@
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
/* font-weight intentionally NOT transitioned — triggers layout reflow on every frame */
|
||||
transition: color 280ms ease, text-shadow 280ms ease, transform 280ms ease;
|
||||
transform-origin: left center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-lyric-line:hover {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.fsr-lyric-line:hover { color: rgba(255, 255, 255, 0.5); }
|
||||
|
||||
.fs-lyric-line.fsl-past {
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.fsr-lyric-line.fsrl-past { color: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
/* Active: emphasis via scale + glow; font-weight snaps instantly (no layout-reflow transition) */
|
||||
.fs-lyric-line.fsl-active {
|
||||
.fsr-lyric-line.fsrl-active {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
transform: scaleX(1.015); /* subtle compositor-only emphasis, no layout cost */
|
||||
transform: scaleX(1.015);
|
||||
}
|
||||
|
||||
/* Fullscreen word-sync (karaoke). Parent .fsl-active base colour stays dim so
|
||||
only the .active/.played words pop. */
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word {
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-shadow: none;
|
||||
}
|
||||
.fs-lyric-word {
|
||||
|
||||
.fsr-lyric-word {
|
||||
display: inline;
|
||||
/* Preserve the trailing space the lyricsplus API embeds in each syllabus
|
||||
token (`"Gina "`, `"dreams "`, …). Without this the flex formatting
|
||||
context collapses it and words run together. */
|
||||
white-space: pre;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease;
|
||||
}
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word.played {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.fs-lyric-line.fsl-active .fs-lyric-word.active {
|
||||
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.played { color: rgba(255, 255, 255, 0.75); }
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
|
||||
html.no-compositing .fsr-lyrics-rail { will-change: auto; }
|
||||
|
||||
/* ── no-compositing overrides ─────────────────────────────────────────────────
|
||||
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
|
||||
@@ -3648,16 +3899,7 @@ html.no-compositing .fs-portrait-wrap::before {
|
||||
);
|
||||
}
|
||||
|
||||
/* Lyrics overlay: just remove the fade — no replacement needed */
|
||||
html.no-compositing .fs-lyrics-overlay {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* Lyrics rail: remove will-change */
|
||||
html.no-compositing .fs-lyrics-rail {
|
||||
will-change: auto;
|
||||
}
|
||||
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
|
||||
|
||||
/* Mesh blobs: stop animations — in software mode each frame composites surfaces
|
||||
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Spring-based scroll animation — iOS / Apple Music feel.
|
||||
*
|
||||
* Uses a critically-damped spring model driven by rAF:
|
||||
* velocity += (target − position) × stiffness
|
||||
* velocity *= damping
|
||||
* position += velocity
|
||||
*
|
||||
* Tuning:
|
||||
* stiffness 0.04 – 0.10 → lower = slower / more fluid
|
||||
* damping 0.80 – 0.88 → lower = more bounce; higher = overdamped / snappy
|
||||
* maxVelocity → caps initial lurch when target is far away
|
||||
*
|
||||
* A single SpringScroller instance per container avoids fighting rAF loops
|
||||
* when the target changes before the previous animation finishes — calling
|
||||
* scrollTo() mid-flight just updates the target and the running loop picks it up.
|
||||
*/
|
||||
export class SpringScroller {
|
||||
private container : HTMLElement;
|
||||
private target = 0;
|
||||
private velocity = 0;
|
||||
private rafId: number | null = null;
|
||||
|
||||
private readonly stiffness : number;
|
||||
private readonly damping : number;
|
||||
private readonly maxVelocity: number;
|
||||
|
||||
constructor(
|
||||
container : HTMLElement,
|
||||
stiffness = 0.065, // gentle pull
|
||||
damping = 0.84, // smooth settle, no oscillation
|
||||
maxVelocity = 28, // px/frame cap — prevents jarring lurch on large jumps
|
||||
) {
|
||||
this.container = container;
|
||||
this.target = container.scrollTop;
|
||||
this.stiffness = stiffness;
|
||||
this.damping = damping;
|
||||
this.maxVelocity = maxVelocity;
|
||||
}
|
||||
|
||||
scrollTo(targetY: number) {
|
||||
this.target = Math.max(0, targetY);
|
||||
if (this.rafId === null) this.tick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.velocity = 0;
|
||||
}
|
||||
|
||||
/** Teleport without animation (e.g. on track reset). */
|
||||
jump(y: number) {
|
||||
this.stop();
|
||||
this.target = y;
|
||||
this.container.scrollTop = y;
|
||||
}
|
||||
|
||||
private tick = () => {
|
||||
const pos = this.container.scrollTop;
|
||||
const delta = this.target - pos;
|
||||
|
||||
let v = (this.velocity + delta * this.stiffness) * this.damping;
|
||||
// Cap velocity so large distances don't start with a hard jerk.
|
||||
if (v > this.maxVelocity) v = this.maxVelocity;
|
||||
if (v < -this.maxVelocity) v = -this.maxVelocity;
|
||||
this.velocity = v;
|
||||
|
||||
this.container.scrollTop += v;
|
||||
|
||||
const settled = Math.abs(v) < 0.12 && Math.abs(delta) < 0.5;
|
||||
if (settled) {
|
||||
this.container.scrollTop = this.target;
|
||||
this.rafId = null;
|
||||
this.velocity = 0;
|
||||
} else {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: compute the scroll position that places `el` at `fraction`
|
||||
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style).
|
||||
*/
|
||||
export function targetForFraction(
|
||||
container: HTMLElement,
|
||||
el : HTMLElement,
|
||||
fraction = 0.35,
|
||||
): number {
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const eRect = el.getBoundingClientRect();
|
||||
return container.scrollTop + (eRect.top - cRect.top) - cRect.height * fraction + eRect.height / 2;
|
||||
}
|
||||
Reference in New Issue
Block a user