mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge pull request #214 from kilyabin/am-lyrics-improvements
refactor(fs-lyrics): smoother line scrolling + bottom fade for plain lyrics
This commit is contained in:
@@ -13,7 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import type { LrcLine } from '../api/lrclib';
|
import type { LrcLine } from '../api/lrclib';
|
||||||
import type { Track } from '../store/playerStore';
|
import type { Track } from '../store/playerStore';
|
||||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
import { EaseScroller, targetForFraction } from '../utils/springScroll';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
@@ -47,23 +47,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
|||||||
const [activeIdx, setActiveIdx] = useState(-1);
|
const [activeIdx, setActiveIdx] = useState(-1);
|
||||||
const activeIdxRef = useRef(-1);
|
const activeIdxRef = useRef(-1);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const springRef = useRef<SpringScroller | null>(null);
|
const scrollerRef = useRef<EaseScroller | null>(null);
|
||||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||||
const prevWord = useRef({ line: -1, word: -1 });
|
const prevWord = useRef({ line: -1, word: -1 });
|
||||||
const isUserScroll = useRef(false);
|
const isUserScroll = useRef(false);
|
||||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Create/destroy the SpringScroller when the container mounts.
|
|
||||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
containerRef.current = el;
|
||||||
if (el) {
|
scrollerRef.current?.stop();
|
||||||
springRef.current = new SpringScroller(el, 0.1, 0.78);
|
scrollerRef.current = el ? new EaseScroller(el) : null;
|
||||||
} else {
|
|
||||||
springRef.current?.stop();
|
|
||||||
springRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset everything on track change.
|
// Reset everything on track change.
|
||||||
@@ -73,7 +68,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
|||||||
prevWord.current = { line: -1, word: -1 };
|
prevWord.current = { line: -1, word: -1 };
|
||||||
activeIdxRef.current = -1;
|
activeIdxRef.current = -1;
|
||||||
setActiveIdx(-1);
|
setActiveIdx(-1);
|
||||||
springRef.current?.jump(0);
|
scrollerRef.current?.jump(0);
|
||||||
}, [currentTrack?.id]);
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
// Subscribe to playback time — only triggers React setState when line changes.
|
// Subscribe to playback time — only triggers React setState when line changes.
|
||||||
@@ -92,13 +87,13 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
|||||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||||
}, [hasSynced, currentTrack?.id]);
|
}, [hasSynced, currentTrack?.id]);
|
||||||
|
|
||||||
// Spring-scroll active line to ~35% from the top of the container.
|
// Ease-scroll active line to ~35% from the top of the container.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeIdx < 0 || isUserScroll.current) return;
|
if (activeIdx < 0 || isUserScroll.current) return;
|
||||||
const el = lineRefs.current[activeIdx];
|
const el = lineRefs.current[activeIdx];
|
||||||
const box = containerRef.current;
|
const box = containerRef.current;
|
||||||
if (!el || !box || !springRef.current) return;
|
if (!el || !box || !scrollerRef.current) return;
|
||||||
springRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
||||||
}, [activeIdx]);
|
}, [activeIdx]);
|
||||||
|
|
||||||
// Word-sync: imperative DOM updates, zero React re-renders per tick.
|
// Word-sync: imperative DOM updates, zero React re-renders per tick.
|
||||||
@@ -134,8 +129,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
|||||||
}, [useWords, wordLines]);
|
}, [useWords, wordLines]);
|
||||||
|
|
||||||
const handleUserScroll = useCallback(() => {
|
const handleUserScroll = useCallback(() => {
|
||||||
// Stop spring animation so it doesn't fight the user's scroll.
|
scrollerRef.current?.stop();
|
||||||
springRef.current?.stop();
|
|
||||||
isUserScroll.current = true;
|
isUserScroll.current = true;
|
||||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||||
@@ -149,9 +143,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
|||||||
|
|
||||||
if (!currentTrack || loading) return null;
|
if (!currentTrack || loading) return null;
|
||||||
|
|
||||||
|
const isPlain = !hasSynced && !!plainLyrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fsa-lyrics-container"
|
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
|
||||||
ref={setContainerRef}
|
ref={setContainerRef}
|
||||||
onWheel={handleUserScroll}
|
onWheel={handleUserScroll}
|
||||||
onTouchMove={handleUserScroll}
|
onTouchMove={handleUserScroll}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Track } from '../store/playerStore';
|
import type { Track } from '../store/playerStore';
|
||||||
import { SpringScroller, targetForFraction } from '../utils/springScroll';
|
import { EaseScroller, targetForFraction } from '../utils/springScroll';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
@@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) {
|
|||||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const springRef = useRef<SpringScroller | null>(null);
|
const scrollerRef = useRef<EaseScroller | null>(null);
|
||||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
const wordRefs = useRef<HTMLSpanElement[][]>([]);
|
||||||
const prevActive = useRef({ line: -1, word: -1 });
|
const prevActive = useRef({ line: -1, word: -1 });
|
||||||
const isUserScroll = useRef(false);
|
const isUserScroll = useRef(false);
|
||||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Attach/detach SpringScroller when the pane mounts.
|
|
||||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
containerRef.current = el;
|
containerRef.current = el;
|
||||||
springRef.current?.stop();
|
scrollerRef.current?.stop();
|
||||||
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
|
scrollerRef.current = el ? new EaseScroller(el) : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
|
|
||||||
const handleUserScroll = useCallback(() => {
|
const handleUserScroll = useCallback(() => {
|
||||||
springRef.current?.stop();
|
scrollerRef.current?.stop();
|
||||||
isUserScroll.current = true;
|
isUserScroll.current = true;
|
||||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
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) => {
|
const scrollToLine = useCallback((el: HTMLDivElement) => {
|
||||||
if (isUserScroll.current) return;
|
if (isUserScroll.current) return;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (sidebarLyricsStyle === 'apple' && springRef.current) {
|
if (sidebarLyricsStyle === 'apple' && scrollerRef.current) {
|
||||||
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35));
|
||||||
} else {
|
} else {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
@@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) {
|
|||||||
lineRefs.current = [];
|
lineRefs.current = [];
|
||||||
wordRefs.current = [];
|
wordRefs.current = [];
|
||||||
prevActive.current = { line: -1, word: -1 };
|
prevActive.current = { line: -1, word: -1 };
|
||||||
springRef.current?.jump(0);
|
scrollerRef.current?.jump(0);
|
||||||
}, [currentTrack?.id]);
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
|
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
|
||||||
|
|||||||
@@ -3621,6 +3621,12 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade the bottom of the scroll viewport when showing plain lyrics */
|
||||||
|
.fsa-lyrics-container--plain {
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.fsa-plain-line {
|
.fsa-plain-line {
|
||||||
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
|
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
+31
-58
@@ -1,46 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Spring-based scroll animation — iOS / Apple Music feel.
|
* Duration-based ease-out scroll animator.
|
||||||
*
|
*
|
||||||
* Uses a critically-damped spring model driven by rAF:
|
* Animates scrollTop from the current position to the target over a fixed
|
||||||
* velocity += (target − position) × stiffness
|
* duration using a cubic ease-out curve. Calling scrollTo() mid-flight
|
||||||
* velocity *= damping
|
* restarts cleanly from wherever the container currently sits, so fast
|
||||||
* position += velocity
|
* line changes never look jerky or skip.
|
||||||
*
|
|
||||||
* 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 {
|
export class EaseScroller {
|
||||||
private container : HTMLElement;
|
private container : HTMLElement;
|
||||||
private target = 0;
|
private startY = 0;
|
||||||
private velocity = 0;
|
private targetY = 0;
|
||||||
|
private startTime = 0;
|
||||||
private rafId: number | null = null;
|
private rafId: number | null = null;
|
||||||
|
|
||||||
private readonly stiffness : number;
|
private readonly duration: number;
|
||||||
private readonly damping : number;
|
|
||||||
private readonly maxVelocity: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(container: HTMLElement, duration = 650) {
|
||||||
container : HTMLElement,
|
this.container = container;
|
||||||
stiffness = 0.065, // gentle pull
|
this.targetY = container.scrollTop;
|
||||||
damping = 0.84, // smooth settle, no oscillation
|
this.duration = duration;
|
||||||
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) {
|
scrollTo(y: number) {
|
||||||
this.target = Math.max(0, targetY);
|
this.startY = this.container.scrollTop;
|
||||||
if (this.rafId === null) this.tick();
|
this.targetY = Math.max(0, y);
|
||||||
|
this.startTime = performance.now();
|
||||||
|
if (this.rafId === null) this.rafId = requestAnimationFrame(this.tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -48,42 +33,30 @@ export class SpringScroller {
|
|||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
}
|
}
|
||||||
this.velocity = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Teleport without animation (e.g. on track reset). */
|
|
||||||
jump(y: number) {
|
jump(y: number) {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.target = y;
|
|
||||||
this.container.scrollTop = y;
|
this.container.scrollTop = y;
|
||||||
|
this.targetY = y;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tick = () => {
|
private tick = (now: number) => {
|
||||||
const pos = this.container.scrollTop;
|
const t = Math.min((now - this.startTime) / this.duration, 1);
|
||||||
const delta = this.target - pos;
|
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||||
|
this.container.scrollTop = this.startY + (this.targetY - this.startY) * ease;
|
||||||
let v = (this.velocity + delta * this.stiffness) * this.damping;
|
if (t < 1) {
|
||||||
// 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);
|
this.rafId = requestAnimationFrame(this.tick);
|
||||||
|
} else {
|
||||||
|
this.container.scrollTop = this.targetY;
|
||||||
|
this.rafId = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: compute the scroll position that places `el` at `fraction`
|
* Compute the scroll position that places `el` at `fraction` from the top
|
||||||
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style).
|
* of `container` (0 = top edge, 0.35 = Apple Music-style, 0.5 = centre).
|
||||||
*/
|
*/
|
||||||
export function targetForFraction(
|
export function targetForFraction(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
|
|||||||
Reference in New Issue
Block a user