feature: lyric smooth scrolling using new arch

todo: var `nextUpdate`'s assignment when backtracking
This commit is contained in:
alikia2x (寒寒) 2024-10-24 00:44:17 +08:00
parent 0c315972c1
commit c7da9fd5ea
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
4 changed files with 197 additions and 181 deletions

View File

@ -7,7 +7,6 @@
export let line: ScriptItem; export let line: ScriptItem;
export let index: number; export let index: number;
export let debugMode: Boolean; export let debugMode: Boolean;
export let initPos: LyricPos;
let ref: HTMLDivElement; let ref: HTMLDivElement;
let time = 0; let time = 0;
@ -21,25 +20,18 @@
let springX: Spring | undefined = undefined; let springX: Spring | undefined = undefined;
let isCurrentLyric = false; let isCurrentLyric = false;
$: {
if (initPos) {
positionX = initPos.x;
positionY = initPos.y;
}
}
function updateY(timestamp: number) { function updateY(timestamp: number) {
if (lastUpdateY === undefined) { if (lastUpdateY === undefined) {
lastUpdateY = timestamp; lastUpdateY = new Date().getTime();
} }
if (springY === undefined) return; if (springY === undefined) return;
time = (timestamp - lastUpdateY) / 1000; time = (new Date().getTime() - lastUpdateY) / 1000;
springY.update(time); springY.update(time);
positionY = springY.getCurrentPosition(); positionY = springY.getCurrentPosition();
if (!springY.arrived()) { if (!springY.arrived()) {
requestAnimationFrame(updateY); requestAnimationFrame(updateY);
} }
lastUpdateY = timestamp; lastUpdateY = new Date().getTime();
} }
function updateX(timestamp: number) { function updateX(timestamp: number) {
@ -47,7 +39,7 @@
lastUpdateX = timestamp; lastUpdateX = timestamp;
} }
if (springX === undefined) return; if (springX === undefined) return;
time = (timestamp - lastUpdateX) / 1000; time = (new Date().getTime() - lastUpdateX) / 1000;
springX.update(time); springX.update(time);
positionX = springX.getCurrentPosition(); positionX = springX.getCurrentPosition();
if (!springX.arrived()) { if (!springX.arrived()) {
@ -77,16 +69,18 @@
}; };
export const update = (pos: LyricPos, delay: number = 0) => { export const update = (pos: LyricPos, delay: number = 0) => {
if (lastPosX === undefined) { if (lastPosX === undefined || lastPosY === undefined) {
lastPosX = pos.x; lastPosX = pos.x;
}
if (lastPosY === undefined) {
lastPosY = pos.y; lastPosY = pos.y;
} }
springY = createSpring(lastPosY, pos.y, 0.12, 0.7, delay); springY = createSpring(lastPosY, pos.y, .126, .85, delay);
springX = createSpring(lastPosX, pos.x, 0.12, 0.7, delay); springX = createSpring(lastPosX, pos.x, .126, .85, delay);
lastUpdateY = new Date().getTime();
lastUpdateX = new Date().getTime();
requestAnimationFrame(updateY); requestAnimationFrame(updateY);
requestAnimationFrame(updateX); requestAnimationFrame(updateX);
lastPosX = pos.x;
lastPosY = pos.y;
}; };
export const getInfo = () => { export const getInfo = () => {
@ -109,7 +103,7 @@
<div style="transform: translateY({positionY}px) translateX({positionX}px);" class="absolute z-50" bind:this={ref}> <div style="transform: translateY({positionY}px) translateX({positionX}px);" class="absolute z-50" bind:this={ref}>
{#if debugMode} {#if debugMode}
<span class="text-lg absolute -translate-y-7">Line idx: {index}</span> <span class="text-lg absolute -translate-y-7">Line idx: {index}, duration: {(line.end - line.start).toFixed(3)}</span>
{/if} {/if}
<span class={`text-white text-5xl font-semibold text-shadow-lg ${!isCurrentLyric && 'opacity-50'} `}> <span class={`text-white text-5xl font-semibold text-shadow-lg ${!isCurrentLyric && 'opacity-50'} `}>
{line.text} {line.text}

View File

@ -3,7 +3,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { ScriptItem } from '$lib/lyrics/type'; import type { ScriptItem } from '$lib/lyrics/type';
import LyricLine from './lyricLine.svelte'; import LyricLine from './lyricLine.svelte';
import type { LyricPos } from './type'; import type { LyricLayout, LyricPos } from './type';
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
// Props // Props
export let originalLyrics: LrcJsonData; export let originalLyrics: LrcJsonData;
@ -14,23 +15,28 @@
let lyricLines: ScriptItem[] = []; let lyricLines: ScriptItem[] = [];
let lyricExists = false; let lyricExists = false;
let lyricsContainer: HTMLDivElement | null; let lyricsContainer: HTMLDivElement | null;
let lyricLayouts: LyricLayout[] = [];
let debugMode = false; let debugMode = false;
let showTranslation = false; let showTranslation = false;
// Exlpaination:
// The hot module reloading makes the each lyric component position to be re-initialized,
// which causes the lyrics are all at {x: 0, y: 0},
// instead of the value calculated in the initLyricComponents.
// So, we need to store the initial position of each lyric component and restore it.
let lyricComponentInitPos = Array<LyricPos>(lyricLines.length).fill({ x: 0, y: 0 });
// References to lyric elements // References to lyric elements
let lyricElements: HTMLDivElement[] = []; let lyricElements: HTMLDivElement[] = [];
let lyricComponents: LyricLine[] = []; let lyricComponents: LyricLine[] = [];
let lyricTopList: number[] = [];
let nextUpdate = 0;
const marginY = 48;
$: getLyricIndex = createLyricsSearcher(originalLyrics);
function initLyricComponents() { function initLyricComponents() {
initLyricTopList();
for (let i = 0; i < lyricComponents.length; i++) {
lyricComponents[i].init({ x: 0, y: lyricTopList[i] });
}
}
function initLyricTopList() {
let cumulativeHeight = 0; let cumulativeHeight = 0;
const marginY = 48;
for (let i = 0; i < lyricLines.length; i++) { for (let i = 0; i < lyricLines.length; i++) {
const c = lyricComponents[i]; const c = lyricComponents[i];
lyricElements.push(c.getRef()); lyricElements.push(c.getRef());
@ -38,10 +44,37 @@
const elementHeight = e.getBoundingClientRect().height; const elementHeight = e.getBoundingClientRect().height;
const elementTargetTop = cumulativeHeight; const elementTargetTop = cumulativeHeight;
cumulativeHeight += elementHeight + marginY; cumulativeHeight += elementHeight + marginY;
lyricComponentInitPos[i] = { x: 0, y: elementTargetTop }; lyricTopList.push(elementTargetTop);
} }
} }
function computeLayout(progress: number) {
if (!originalLyrics.scripts) return;
const currentLyricIndex = getLyricIndex(progress);
const currentLyricDuration =
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
const relativeOrigin = lyricTopList[currentLyricIndex];
for (let i = 0; i < lyricElements.length; i++) {
const currentLyricComponent = lyricComponents[i];
lyricLayouts[i] = {
blur: 0,
scale: 1,
pos: {
y: lyricTopList[i] - relativeOrigin,
x: 0
}
};
let delay = 0;
if (i <= currentLyricIndex) {
delay = 0;
} else {
delay = 0.013 + Math.min(Math.min(currentLyricDuration, 0.3), 0.075 * (i - currentLyricIndex));
}
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
}
nextUpdate = originalLyrics.scripts[currentLyricIndex + 1].start;
}
$: { $: {
if (originalLyrics && originalLyrics.scripts) { if (originalLyrics && originalLyrics.scripts) {
lyricExists = true; lyricExists = true;
@ -55,6 +88,12 @@
} }
} }
$: {
if (lyricsContainer && lyricComponents.length > 0) {
if (progress >= nextUpdate) computeLayout(progress);
}
}
onMount(() => { onMount(() => {
// Initialize // Initialize
if (localStorage.getItem('debugMode') == null) { if (localStorage.getItem('debugMode') == null) {
@ -72,13 +111,7 @@
bind:this={lyricsContainer} bind:this={lyricsContainer}
> >
{#each lyricLines as lyric, i} {#each lyricLines as lyric, i}
<LyricLine <LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} />
line={lyric}
index={i}
bind:this={lyricComponents[i]}
{debugMode}
initPos={lyricComponentInitPos[i]}
/>
{/each} {/each}
<div class="relative w-full h-[50rem]"></div> <div class="relative w-full h-[50rem]"></div>
</div> </div>

View File

@ -1,158 +1,147 @@
import { getVelocity } from "./derivative"; import { getVelocity } from './derivative';
/** MIT License github.com/pushkine/ */ /** MIT License github.com/pushkine/ */
export interface SpringParams { export interface SpringParams {
mass: number; // = 1.0 mass: number; // = 1.0
damping: number; // = 10.0 damping: number; // = 10.0
stiffness: number; // = 100.0 stiffness: number; // = 100.0
soft: boolean; // = false soft: boolean; // = false
} }
type seconds = number; type seconds = number;
export class Spring { export class Spring {
private currentPosition = 0; private currentPosition = 0;
private targetPosition = 0; private targetPosition = 0;
private currentTime = 0; private currentTime = 0;
private params: Partial<SpringParams> = {}; private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number; private currentSolver: (t: seconds) => number;
private getV: (t: seconds) => number; private getV: (t: seconds) => number;
private getV2: (t: seconds) => number; private getV2: (t: seconds) => number;
private queueParams: private queueParams:
| (Partial<SpringParams> & { | (Partial<SpringParams> & {
time: number; time: number;
}) })
| undefined; | undefined;
private queuePosition: private queuePosition:
| { | {
time: number; time: number;
position: number; position: number;
} }
| undefined; | undefined;
constructor(currentPosition = 0) { constructor(currentPosition = 0) {
this.targetPosition = currentPosition; this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition; this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition; this.currentSolver = () => this.targetPosition;
this.getV = () => 0; this.getV = () => 0;
this.getV2 = () => 0; this.getV2 = () => 0;
} }
private resetSolver() { private resetSolver() {
const curV = this.getV(this.currentTime); const curV = this.getV(this.currentTime);
this.currentTime = 0; this.currentTime = 0;
this.currentSolver = solveSpring( this.currentSolver = solveSpring(this.currentPosition, curV, this.targetPosition, 0, this.params);
this.currentPosition, this.getV = getVelocity(this.currentSolver);
curV, this.getV2 = getVelocity(this.getV);
this.targetPosition, }
0, arrived() {
this.params, return (
); Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.getV = getVelocity(this.currentSolver); this.getV(this.currentTime) < 0.01 &&
this.getV2 = getVelocity(this.getV); this.getV2(this.currentTime) < 0.01 &&
} this.queueParams === undefined &&
arrived() { this.queuePosition === undefined
return ( );
Math.abs(this.targetPosition - this.currentPosition) < 0.01 && }
this.getV(this.currentTime) < 0.01 && setPosition(targetPosition: number) {
this.getV2(this.currentTime) < 0.01 && this.targetPosition = targetPosition;
this.queueParams === undefined && this.currentPosition = targetPosition;
this.queuePosition === undefined this.currentSolver = () => this.targetPosition;
); this.getV = () => 0;
} this.getV2 = () => 0;
setPosition(targetPosition: number) { }
this.targetPosition = targetPosition; update(delta = 0) {
this.currentPosition = targetPosition; this.currentTime += delta;
this.currentSolver = () => this.targetPosition; this.currentPosition = this.currentSolver(this.currentTime);
this.getV = () => 0; if (this.queueParams) {
this.getV2 = () => 0; this.queueParams.time -= delta;
} if (this.queueParams.time <= 0) {
update(delta = 0) { this.updateParams({
this.currentTime += delta; ...this.queueParams
this.currentPosition = this.currentSolver(this.currentTime); });
if (this.queueParams) { }
this.queueParams.time -= delta; }
if (this.queueParams.time <= 0) { if (this.queuePosition) {
this.updateParams({ this.queuePosition.time -= delta;
...this.queueParams, if (this.queuePosition.time <= 0) {
}); this.setTargetPosition(this.queuePosition.position);
} }
} }
if (this.queuePosition) { if (this.arrived()) {
this.queuePosition.time -= delta; this.setPosition(this.targetPosition);
if (this.queuePosition.time <= 0) { }
this.setTargetPosition(this.queuePosition.position); }
} updateParams(params: Partial<SpringParams>, delay = 0) {
} if (delay > 0) {
if (this.arrived()) { this.queueParams = {
this.setPosition(this.targetPosition); ...(this.queuePosition ?? {}),
} ...params,
} time: delay
updateParams(params: Partial<SpringParams>, delay = 0) { };
if (delay > 0) { } else {
this.queueParams = { this.queuePosition = undefined;
...(this.queuePosition ?? {}), this.params = {
...params, ...this.params,
time: delay, ...params
}; };
} else { this.resetSolver();
this.queuePosition = undefined; }
this.params = { }
...this.params, setTargetPosition(targetPosition: number, delay = 0) {
...params, if (delay > 0) {
}; this.queuePosition = {
this.resetSolver(); ...(this.queuePosition ?? {}),
} position: targetPosition,
} time: delay
setTargetPosition(targetPosition: number, delay = 0) { };
if (delay > 0) { } else {
this.queuePosition = { this.queuePosition = undefined;
...(this.queuePosition ?? {}), this.targetPosition = targetPosition;
position: targetPosition, this.resetSolver();
time: delay, }
}; }
} else { getCurrentPosition() {
this.queuePosition = undefined; return this.currentPosition;
this.targetPosition = targetPosition; }
this.resetSolver();
}
}
getCurrentPosition() {
return this.currentPosition;
}
} }
function solveSpring( function solveSpring(
from: number, from: number,
velocity: number, velocity: number,
to: number, to: number,
delay: seconds = 0, delay: seconds = 0,
params?: Partial<SpringParams>, params?: Partial<SpringParams>
): (t: seconds) => number { ): (t: seconds) => number {
const soft = params?.soft ?? false; const soft = params?.soft ?? false;
const stiffness = params?.stiffness ?? 100; const stiffness = params?.stiffness ?? 100;
const damping = params?.damping ?? 10; const damping = params?.damping ?? 10;
const mass = params?.mass ?? 1; const mass = params?.mass ?? 1;
const delta = to - from; const delta = to - from;
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) { if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
const angular_frequency = -Math.sqrt(stiffness / mass); const angular_frequency = -Math.sqrt(stiffness / mass);
const leftover = -angular_frequency * delta - velocity; const leftover = -angular_frequency * delta - velocity;
return (t: seconds) => { return (t: seconds) => {
t -= delay; t -= delay;
if (t < 0) return from; if (t < 0) return from;
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency); return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
}; };
} }
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0); const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
const leftover = const leftover = (damping * delta - 2.0 * mass * velocity) / damping_frequency;
(damping * delta - 2.0 * mass * velocity) / damping_frequency; const dfm = (0.5 * damping_frequency) / mass;
const dfm = (0.5 * damping_frequency) / mass; const dm = -(0.5 * damping) / mass;
const dm = -(0.5 * damping) / mass; return (t: seconds) => {
return (t: seconds) => { t -= delay;
t -= delay; if (t < 0) return from;
if (t < 0) return from; return to - (Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) * Math.E ** (t * dm);
return ( };
to -
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
Math.E ** (t * dm)
);
};
} }

View File

@ -214,7 +214,7 @@
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/> <NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> <!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
<audio <audio
bind:this={audioPlayer} bind:this={audioPlayer}