ref: started refactoring the dynamic lyrics
add: initialization of lyric lines
This commit is contained in:
parent
ba31bc4b98
commit
d34ac176c0
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,4 +8,5 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
data/pending
|
||||
data/pending
|
||||
.vscode
|
@ -2,7 +2,7 @@
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||
import type { LrcJsonData } from '$lib/lyrics/parser';
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||
import nextUpdate from '$lib/state/nextUpdate';
|
||||
import truncate from '$lib/truncate';
|
||||
@ -20,15 +20,13 @@
|
||||
let showTranslation = false;
|
||||
if (localStorage.getItem('debugMode') == null) {
|
||||
localStorage.setItem('debugMode', 'false');
|
||||
}
|
||||
else {
|
||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === "true";
|
||||
} else {
|
||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||
}
|
||||
if (localStorage.getItem('showTranslation') == null) {
|
||||
localStorage.setItem('showTranslation', 'false');
|
||||
}
|
||||
else {
|
||||
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === "true";
|
||||
} else {
|
||||
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === 'true';
|
||||
}
|
||||
let currentLyricIndex = -1;
|
||||
let currentPositionIndex = -1;
|
||||
@ -47,18 +45,16 @@
|
||||
$: refs = _refs.filter(Boolean);
|
||||
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||
|
||||
|
||||
// handle KeyDown event
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||
debugMode = !debugMode;
|
||||
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||
}
|
||||
else if (e.key === 't') {
|
||||
} else if (e.key === 't') {
|
||||
showTranslation = !showTranslation;
|
||||
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||
setTimeout(() => {
|
||||
scrollToLyric(refs[currentPositionIndex])
|
||||
scrollToLyric(refs[currentPositionIndex]);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
@ -67,7 +63,7 @@
|
||||
function extractTranslateValue(s: string): string | null {
|
||||
const regex = /translateY\((-?\d*px)\)/;
|
||||
let arr = regex.exec(s);
|
||||
return arr==null ? null : arr[1];
|
||||
return arr == null ? null : arr[1];
|
||||
}
|
||||
|
||||
// Helper function to get CSS class for a lyric based on its index and progress
|
||||
@ -80,7 +76,7 @@
|
||||
|
||||
// Function to move the lyrics up smoothly
|
||||
async function moveToNextLine(h: number) {
|
||||
console.debug(new Date().getTime() , 'moveToNextLine', h);
|
||||
console.debug(new Date().getTime(), 'moveToNextLine', h);
|
||||
// the line that's going to process (like a pointer)
|
||||
// by default, it's "the next line" after the lift
|
||||
let processingLineIndex = currentPositionIndex + 2;
|
||||
@ -88,14 +84,17 @@
|
||||
// modify translateY of all lines in viewport one by one to lift them up
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
const lyric = refs[i];
|
||||
lyric.style.transition =
|
||||
`transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||
lyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||
font-size 200ms ease, scale 250ms ease`;
|
||||
lyric.style.transform = `translateY(${-h}px)`;
|
||||
processingLineIndex = i;
|
||||
await sleep(75);
|
||||
const twoLinesAhead = refs[i - 2];
|
||||
if (lyricsContainer && twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break;
|
||||
if (
|
||||
lyricsContainer &&
|
||||
twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height
|
||||
)
|
||||
break;
|
||||
}
|
||||
|
||||
if (refs.length - processingLineIndex < 3) {
|
||||
@ -200,7 +199,7 @@
|
||||
|
||||
const currentLyric = refs[currentPositionIndex];
|
||||
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
||||
|
||||
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const offset = Math.abs(i - currentPositionIndex);
|
||||
let blurRadius = Math.min(offset * 1.25, 16);
|
||||
@ -222,8 +221,7 @@
|
||||
const element = refs[i];
|
||||
if (element.getBoundingClientRect().top < 0) {
|
||||
min = i;
|
||||
}
|
||||
else if (element.getBoundingClientRect().bottom < 0) {
|
||||
} else if (element.getBoundingClientRect().bottom < 0) {
|
||||
max = i;
|
||||
return [min, max];
|
||||
}
|
||||
@ -232,13 +230,14 @@
|
||||
|
||||
// Main function that control's lyrics update during playing
|
||||
// triggered by nextUpdate's update
|
||||
async function lyricsUpdate(){
|
||||
async function lyricsUpdate() {
|
||||
if (
|
||||
currentPositionIndex < 0 ||
|
||||
currentPositionIndex === currentAnimationIndex ||
|
||||
$userAdjustingProgress === true ||
|
||||
scrolling
|
||||
) return;
|
||||
)
|
||||
return;
|
||||
|
||||
const currentLyric = refs[currentPositionIndex];
|
||||
const currentLyricRect = currentLyric.getBoundingClientRect();
|
||||
@ -269,8 +268,7 @@
|
||||
currentAnimationIndex = currentPositionIndex;
|
||||
}
|
||||
|
||||
|
||||
nextUpdate.subscribe(lyricsUpdate)
|
||||
nextUpdate.subscribe(lyricsUpdate);
|
||||
|
||||
// Process while user is adjusting progress
|
||||
userAdjustingProgress.subscribe((adjusting) => {
|
||||
@ -304,63 +302,77 @@
|
||||
});
|
||||
|
||||
function lyricClick(lyricIndex: number) {
|
||||
if (player===null || originalLyrics.scripts === undefined) return;
|
||||
if (player === null || originalLyrics.scripts === undefined) return;
|
||||
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||
player.play()
|
||||
player.play();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
{#if debugMode && lyricsContainer}
|
||||
<div
|
||||
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
||||
px-2 rounded-xl text-white">
|
||||
<p>
|
||||
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
||||
AnimationIndex:{currentAnimationIndex}
|
||||
NextUpdate: {$nextUpdate}
|
||||
Progress: {progress.toFixed(2)}
|
||||
scrollPosition: {lyricsContainer.scrollTop}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div {...$$restProps}>
|
||||
{#if debugMode && lyricsContainer}
|
||||
<div
|
||||
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
||||
px-2 rounded-xl text-white"
|
||||
>
|
||||
<p>
|
||||
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
||||
AnimationIndex:{currentAnimationIndex}
|
||||
NextUpdate: {$nextUpdate}
|
||||
Progress: {progress.toFixed(2)}
|
||||
scrollPosition: {lyricsContainer.scrollTop}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
||||
<div
|
||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
||||
<div
|
||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
||||
bind:this={lyricsContainer}
|
||||
on:scroll={scrollHandler}
|
||||
>
|
||||
{#each lyrics as lyric, i}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div bind:this={_refs[i]} class="relative h-fit text-shadow-lg" on:click={() => {lyricClick(i)}} >
|
||||
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
||||
<span class="previous-lyric !text-lg !absolute !-translate-y-12">{i}
|
||||
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
||||
tY: {extractTranslateValue(refs[i].style.transform)}
|
||||
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<p class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}>
|
||||
{#if originalLyrics.scripts[i].singer}
|
||||
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
||||
bind:this={lyricsContainer}
|
||||
on:scroll={scrollHandler}
|
||||
>
|
||||
{#each lyrics as lyric, i}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={_refs[i]}
|
||||
class="relative h-fit text-shadow-lg"
|
||||
on:click={() => {
|
||||
lyricClick(i);
|
||||
}}
|
||||
>
|
||||
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
||||
<span class="previous-lyric !text-lg !absolute !-translate-y-12"
|
||||
>{i}
|
||||
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
||||
tY: {extractTranslateValue(refs[i].style.transform)}
|
||||
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
||||
</span>
|
||||
{/if}
|
||||
{lyric}
|
||||
</p>
|
||||
{#if originalLyrics.scripts[i].translation && showTranslation}
|
||||
<div class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}>{originalLyrics.scripts[i].translation}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="relative w-full h-[50rem]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}
|
||||
>
|
||||
{#if originalLyrics.scripts[i].singer}
|
||||
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
||||
{/if}
|
||||
{lyric}
|
||||
</p>
|
||||
{#if originalLyrics.scripts[i].translation && showTranslation}
|
||||
<div
|
||||
class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}
|
||||
>
|
||||
{originalLyrics.scripts[i].translation}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="relative w-full h-[50rem]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style>
|
||||
@ -376,10 +388,10 @@
|
||||
|
||||
.lyrics {
|
||||
mask-image: linear-gradient(
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 2rem,
|
||||
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 2rem,
|
||||
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@ -389,7 +401,7 @@
|
||||
bottom: 50%;
|
||||
transform: translateY(calc(50%)) translateX(-3rem);
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: rgba(255,255,255,.15);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
|
117
src/lib/components/lyrics/lyricLine.svelte
Normal file
117
src/lib/components/lyrics/lyricLine.svelte
Normal file
@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import createSpring from '$lib/graphics/spring';
|
||||
import type { ScriptItem } from '$lib/lyrics/type';
|
||||
import type { LyricPos } from './type';
|
||||
import type { Spring } from '$lib/graphics/spring/spring';
|
||||
|
||||
export let line: ScriptItem;
|
||||
export let index: number;
|
||||
export let debugMode: Boolean;
|
||||
export let initPos: LyricPos;
|
||||
let ref: HTMLDivElement;
|
||||
|
||||
let time = 0;
|
||||
let positionX: number = 0;
|
||||
let positionY: number = 0;
|
||||
let lastPosX: number | undefined = undefined;
|
||||
let lastPosY: number | undefined = undefined;
|
||||
let lastUpdateY: number | undefined = undefined;
|
||||
let lastUpdateX: number | undefined = undefined;
|
||||
let springY: Spring | undefined = undefined;
|
||||
let springX: Spring | undefined = undefined;
|
||||
let isCurrentLyric = false;
|
||||
|
||||
$: {
|
||||
if (initPos) {
|
||||
positionX = initPos.x;
|
||||
positionY = initPos.y;
|
||||
}
|
||||
}
|
||||
|
||||
function updateY(timestamp: number) {
|
||||
if (lastUpdateY === undefined) {
|
||||
lastUpdateY = timestamp;
|
||||
}
|
||||
if (springY === undefined) return;
|
||||
time = (timestamp - lastUpdateY) / 1000;
|
||||
springY.update(time);
|
||||
positionY = springY.getCurrentPosition();
|
||||
if (!springY.arrived()) {
|
||||
requestAnimationFrame(updateY);
|
||||
}
|
||||
lastUpdateY = timestamp;
|
||||
}
|
||||
|
||||
function updateX(timestamp: number) {
|
||||
if (lastUpdateX === undefined) {
|
||||
lastUpdateX = timestamp;
|
||||
}
|
||||
if (springX === undefined) return;
|
||||
time = (timestamp - lastUpdateX) / 1000;
|
||||
springX.update(time);
|
||||
positionX = springX.getCurrentPosition();
|
||||
if (!springX.arrived()) {
|
||||
requestAnimationFrame(updateX);
|
||||
}
|
||||
lastUpdateX = timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the x position of the element, **with no animation**
|
||||
* @param {number} pos - X offset, in pixels
|
||||
*/
|
||||
export const setX = (pos: number) => {
|
||||
positionX = pos;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the y position of the element, **with no animation**
|
||||
* @param {number} pos - Y offset, in pixels
|
||||
*/
|
||||
export const setY = (pos: number) => {
|
||||
positionY = pos;
|
||||
};
|
||||
|
||||
export const setCurrent = (isCurrent: boolean) => {
|
||||
isCurrentLyric = isCurrent;
|
||||
};
|
||||
|
||||
export const update = (pos: LyricPos, delay: number = 0) => {
|
||||
if (lastPosX === undefined) {
|
||||
lastPosX = pos.x;
|
||||
}
|
||||
if (lastPosY === undefined) {
|
||||
lastPosY = pos.y;
|
||||
}
|
||||
springY = createSpring(lastPosY, pos.y, 0.12, 0.7, delay);
|
||||
springX = createSpring(lastPosX, pos.x, 0.12, 0.7, delay);
|
||||
requestAnimationFrame(updateY);
|
||||
requestAnimationFrame(updateX);
|
||||
};
|
||||
|
||||
export const getInfo = () => {
|
||||
return {
|
||||
x: positionX,
|
||||
y: positionY,
|
||||
isCurrent: isCurrentLyric
|
||||
};
|
||||
};
|
||||
|
||||
export const init = (pos: LyricPos) => {
|
||||
lastPosX = pos.x;
|
||||
lastPosY = pos.y;
|
||||
positionX = pos.x;
|
||||
positionY = pos.y;
|
||||
};
|
||||
|
||||
export const getRef = () => ref;
|
||||
</script>
|
||||
|
||||
<div style="transform: translateY({positionY}px) translateX({positionX}px);" class="absolute z-50" bind:this={ref}>
|
||||
{#if debugMode}
|
||||
<span class="text-lg absolute -translate-y-7">Line idx: {index}</span>
|
||||
{/if}
|
||||
<span class={`text-white text-5xl font-semibold text-shadow-lg ${!isCurrentLyric && 'opacity-50'} `}>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
4
src/lib/components/lyrics/type.d.ts
vendored
Normal file
4
src/lib/components/lyrics/type.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface LyricPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
86
src/lib/components/newLyrics.svelte
Normal file
86
src/lib/components/newLyrics.svelte
Normal file
@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import { onMount } from 'svelte';
|
||||
import LyricsLine from './lyrics/lyricLine.svelte';
|
||||
import type { ScriptItem } from 'lrc-parser-ts';
|
||||
import LyricLine from './lyrics/lyricLine.svelte';
|
||||
import type { LyricPos } from './lyrics/type';
|
||||
|
||||
// Props
|
||||
export let originalLyrics: LrcJsonData;
|
||||
export let progress: number;
|
||||
export let player: HTMLAudioElement | null;
|
||||
|
||||
// States
|
||||
let lyricLines: ScriptItem[] = [];
|
||||
let lyricExists = false;
|
||||
let lyricsContainer: HTMLDivElement | null;
|
||||
let debugMode = 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
|
||||
let lyricElements: HTMLDivElement[] = [];
|
||||
let lyricComponents: LyricLine[] = [];
|
||||
|
||||
function initLyricComponents() {
|
||||
let cumulativeHeight = 0;
|
||||
const marginY = 48;
|
||||
for (let i = 0; i < lyricLines.length; i++) {
|
||||
const c = lyricComponents[i];
|
||||
lyricElements.push(c.getRef());
|
||||
const e = lyricElements[i];
|
||||
const elementHeight = e.getBoundingClientRect().height;
|
||||
const elementTargetTop = cumulativeHeight;
|
||||
cumulativeHeight += elementHeight + marginY;
|
||||
lyricComponentInitPos[i] = { x: 0, y: elementTargetTop };
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (originalLyrics && originalLyrics.scripts) {
|
||||
lyricExists = true;
|
||||
lyricLines = originalLyrics.scripts!;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (lyricComponents.length > 0) {
|
||||
initLyricComponents();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Initialize
|
||||
if (localStorage.getItem('debugMode') == null) {
|
||||
localStorage.setItem('debugMode', 'false');
|
||||
} else {
|
||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if originalLyrics && originalLyrics.scripts}
|
||||
<div
|
||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||
text-left no-scrollbar overflow-y-auto z-[1] pt-16"
|
||||
bind:this={lyricsContainer}
|
||||
>
|
||||
{#each lyricLines as lyric, i}
|
||||
<LyricLine
|
||||
line={lyric}
|
||||
index={i}
|
||||
bind:this={lyricComponents[i]}
|
||||
{debugMode}
|
||||
initPos={lyricComponentInitPos[i]}
|
||||
/>
|
||||
{/each}
|
||||
<div class="relative w-full h-[50rem]"></div>
|
||||
</div>
|
||||
{/if}
|
8
src/lib/graphics/spring/derivative.ts
Normal file
8
src/lib/graphics/spring/derivative.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export function derivative(f: (x: number) => number) {
|
||||
const h = 0.001;
|
||||
return (x: number) => (f(x + h) - f(x - h)) / (2 * h);
|
||||
}
|
||||
|
||||
export function getVelocity(f: (t: number) => number): (t: number) => number {
|
||||
return derivative(f);
|
||||
}
|
17
src/lib/graphics/spring/index.ts
Normal file
17
src/lib/graphics/spring/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Spring } from './spring';
|
||||
|
||||
export default function createSpring(
|
||||
from: number,
|
||||
to: number,
|
||||
bounce: number,
|
||||
duration: number,
|
||||
delaySeconds: number = 0,
|
||||
) {
|
||||
const mass = 1;
|
||||
const stiffness = Math.pow((Math.PI * 2) / duration, 2);
|
||||
const damping = bounce >= 0 ? ((1 - bounce) * (4 * Math.PI)) / duration : ((1 + bounce) * (4 * Math.PI)) / duration;
|
||||
const spring = new Spring(from);
|
||||
spring.updateParams({ mass, stiffness, damping });
|
||||
spring.setTargetPosition(to, delaySeconds);
|
||||
return spring;
|
||||
}
|
158
src/lib/graphics/spring/spring.ts
Normal file
158
src/lib/graphics/spring/spring.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { getVelocity } from "./derivative";
|
||||
|
||||
/** MIT License github.com/pushkine/ */
|
||||
export interface SpringParams {
|
||||
mass: number; // = 1.0
|
||||
damping: number; // = 10.0
|
||||
stiffness: number; // = 100.0
|
||||
soft: boolean; // = false
|
||||
}
|
||||
|
||||
type seconds = number;
|
||||
|
||||
export class Spring {
|
||||
private currentPosition = 0;
|
||||
private targetPosition = 0;
|
||||
private currentTime = 0;
|
||||
private params: Partial<SpringParams> = {};
|
||||
private currentSolver: (t: seconds) => number;
|
||||
private getV: (t: seconds) => number;
|
||||
private getV2: (t: seconds) => number;
|
||||
private queueParams:
|
||||
| (Partial<SpringParams> & {
|
||||
time: number;
|
||||
})
|
||||
| undefined;
|
||||
private queuePosition:
|
||||
| {
|
||||
time: number;
|
||||
position: number;
|
||||
}
|
||||
| undefined;
|
||||
constructor(currentPosition = 0) {
|
||||
this.targetPosition = currentPosition;
|
||||
this.currentPosition = this.targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.getV = () => 0;
|
||||
this.getV2 = () => 0;
|
||||
}
|
||||
private resetSolver() {
|
||||
const curV = this.getV(this.currentTime);
|
||||
this.currentTime = 0;
|
||||
this.currentSolver = solveSpring(
|
||||
this.currentPosition,
|
||||
curV,
|
||||
this.targetPosition,
|
||||
0,
|
||||
this.params,
|
||||
);
|
||||
this.getV = getVelocity(this.currentSolver);
|
||||
this.getV2 = getVelocity(this.getV);
|
||||
}
|
||||
arrived() {
|
||||
return (
|
||||
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
||||
this.getV(this.currentTime) < 0.01 &&
|
||||
this.getV2(this.currentTime) < 0.01 &&
|
||||
this.queueParams === undefined &&
|
||||
this.queuePosition === undefined
|
||||
);
|
||||
}
|
||||
setPosition(targetPosition: number) {
|
||||
this.targetPosition = targetPosition;
|
||||
this.currentPosition = targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.getV = () => 0;
|
||||
this.getV2 = () => 0;
|
||||
}
|
||||
update(delta = 0) {
|
||||
this.currentTime += delta;
|
||||
this.currentPosition = this.currentSolver(this.currentTime);
|
||||
if (this.queueParams) {
|
||||
this.queueParams.time -= delta;
|
||||
if (this.queueParams.time <= 0) {
|
||||
this.updateParams({
|
||||
...this.queueParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.queuePosition) {
|
||||
this.queuePosition.time -= delta;
|
||||
if (this.queuePosition.time <= 0) {
|
||||
this.setTargetPosition(this.queuePosition.position);
|
||||
}
|
||||
}
|
||||
if (this.arrived()) {
|
||||
this.setPosition(this.targetPosition);
|
||||
}
|
||||
}
|
||||
updateParams(params: Partial<SpringParams>, delay = 0) {
|
||||
if (delay > 0) {
|
||||
this.queueParams = {
|
||||
...(this.queuePosition ?? {}),
|
||||
...params,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.params = {
|
||||
...this.params,
|
||||
...params,
|
||||
};
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
setTargetPosition(targetPosition: number, delay = 0) {
|
||||
if (delay > 0) {
|
||||
this.queuePosition = {
|
||||
...(this.queuePosition ?? {}),
|
||||
position: targetPosition,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.targetPosition = targetPosition;
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
getCurrentPosition() {
|
||||
return this.currentPosition;
|
||||
}
|
||||
}
|
||||
|
||||
function solveSpring(
|
||||
from: number,
|
||||
velocity: number,
|
||||
to: number,
|
||||
delay: seconds = 0,
|
||||
params?: Partial<SpringParams>,
|
||||
): (t: seconds) => number {
|
||||
const soft = params?.soft ?? false;
|
||||
const stiffness = params?.stiffness ?? 100;
|
||||
const damping = params?.damping ?? 10;
|
||||
const mass = params?.mass ?? 1;
|
||||
const delta = to - from;
|
||||
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
|
||||
const angular_frequency = -Math.sqrt(stiffness / mass);
|
||||
const leftover = -angular_frequency * delta - velocity;
|
||||
return (t: seconds) => {
|
||||
t -= delay;
|
||||
if (t < 0) return from;
|
||||
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
|
||||
};
|
||||
}
|
||||
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
|
||||
const leftover =
|
||||
(damping * delta - 2.0 * mass * velocity) / damping_frequency;
|
||||
const dfm = (0.5 * damping_frequency) / mass;
|
||||
const dm = -(0.5 * damping) / mass;
|
||||
return (t: seconds) => {
|
||||
t -= delay;
|
||||
if (t < 0) return from;
|
||||
return (
|
||||
to -
|
||||
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
|
||||
Math.E ** (t * dm)
|
||||
);
|
||||
};
|
||||
}
|
0
src/lib/lyrics/lrc/index.ts
Normal file
0
src/lib/lyrics/lrc/index.ts
Normal file
@ -16,6 +16,8 @@ import {
|
||||
tok,
|
||||
type Token
|
||||
} from 'typescript-parsec';
|
||||
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
|
||||
import type { IDTag } from './type';
|
||||
|
||||
|
||||
interface ParserScriptItem {
|
||||
@ -26,45 +28,6 @@ interface ParserScriptItem {
|
||||
singer?: string;
|
||||
}
|
||||
|
||||
export interface ScriptItem extends ParserScriptItem {
|
||||
end: number;
|
||||
chorus?: string;
|
||||
}
|
||||
|
||||
export interface ScriptWordsItem {
|
||||
start: number;
|
||||
end: number;
|
||||
beginIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export interface LrcMetaData {
|
||||
ar?: string;
|
||||
ti?: string;
|
||||
al?: string;
|
||||
au?: string;
|
||||
length?: string;
|
||||
offset?: string;
|
||||
tool?: string;
|
||||
ve?: string;
|
||||
}
|
||||
|
||||
export interface ParsedLrc extends LrcMetaData {
|
||||
scripts?: ParserScriptItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface LrcJsonData extends LrcMetaData {
|
||||
scripts?: ScriptItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface IDTag {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
function convertTimeToMs({
|
||||
mins,
|
||||
secs,
|
11
src/lib/lyrics/lrc/type.d.ts
vendored
Normal file
11
src/lib/lyrics/lrc/type.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
export interface ParserScriptItem {
|
||||
start: number;
|
||||
text: string;
|
||||
words?: ScriptWordsItem[];
|
||||
translation?: string;
|
||||
singer?: string;
|
||||
}
|
||||
|
||||
export interface IDTag {
|
||||
[key: string]: string;
|
||||
}
|
21
src/lib/lyrics/ttml/index.ts
Normal file
21
src/lib/lyrics/ttml/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import { parseTTML as ttmlParser } from './parser';
|
||||
import type { LyricLine } from './ttml-types';
|
||||
export * from './writer';
|
||||
export type * from './ttml-types';
|
||||
|
||||
export function parseTTML(text: string) {
|
||||
let lyrics: LrcJsonData;
|
||||
const lyricLines = ttmlParser(text).lyricLines;
|
||||
lyrics = {
|
||||
scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => {
|
||||
return {
|
||||
text: value.words.map((word) => word.word).join(''),
|
||||
start: value.startTime / 1000,
|
||||
end: value.endTime / 1000,
|
||||
translation: value.translatedLyric || undefined
|
||||
};
|
||||
})
|
||||
};
|
||||
return lyrics;
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 解析 TTML 歌词文档到歌词数组的解析器
|
||||
* 用于解析从 Apple Music 来的歌词文件,且扩展并支持翻译和音译文本。
|
||||
* Parser for TTML lyrics.
|
||||
* Used to parse lyrics files from Apple Music,
|
||||
* and extended to support translation and pronounciation of text.
|
||||
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
|
||||
*/
|
||||
|
||||
@ -22,7 +23,7 @@ function parseTimespan(timeSpan: string): number {
|
||||
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
||||
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
||||
}
|
||||
throw new TypeError(`时间戳字符串解析失败:${timeSpan}`);
|
||||
throw new TypeError(`Failed to parse time stamp:${timeSpan}`);
|
||||
}
|
||||
|
||||
export function parseTTML(ttmlText: string): TTMLLyric {
|
@ -1,9 +1,3 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 用于将内部歌词数组对象导出成 TTML 格式的模块
|
||||
* 但是可能会有信息会丢失
|
||||
*/
|
||||
|
||||
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
||||
|
||||
function msToTimestamp(timeMS: number): string {
|
||||
@ -61,7 +55,7 @@ export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
||||
return span;
|
||||
}
|
||||
|
||||
const ttRoot = doc.createElement("tt");
|
||||
const ttRoot = doc.createElement("code");
|
||||
|
||||
ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml");
|
||||
ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata");
|
36
src/lib/lyrics/type.d.ts
vendored
Normal file
36
src/lib/lyrics/type.d.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
import type { ParserScriptItem } from "./lrc/type";
|
||||
|
||||
export interface ScriptItem extends ParserScriptItem {
|
||||
end: number;
|
||||
chorus?: string;
|
||||
}
|
||||
|
||||
export interface ScriptWordsItem {
|
||||
start: number;
|
||||
end: number;
|
||||
beginIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export interface LrcMetaData {
|
||||
ar?: string;
|
||||
ti?: string;
|
||||
al?: string;
|
||||
au?: string;
|
||||
length?: string;
|
||||
offset?: string;
|
||||
tool?: string;
|
||||
ve?: string;
|
||||
}
|
||||
|
||||
export interface ParsedLrc extends LrcMetaData {
|
||||
scripts?: ParserScriptItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface LrcJsonData extends LrcMetaData {
|
||||
scripts?: ScriptItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export * from "./parser";
|
||||
export * from "./writer";
|
||||
export type * from "./ttml-types";
|
@ -8,12 +8,14 @@
|
||||
import extractFileName from '$lib/extractFileName';
|
||||
import localforage from 'localforage';
|
||||
import { writable } from 'svelte/store';
|
||||
import lrcParser, { type LrcJsonData } from '$lib/lyrics/parser';
|
||||
import lrcParser from '$lib/lyrics/lrc/parser';
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||
import { onMount } from 'svelte';
|
||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||
import { parseTTML, type LyricLine, type LyricWord } from '$lib/ttml';
|
||||
import { parseTTML, type LyricLine } from '$lib/lyrics/ttml';
|
||||
import NewLyrics from '$lib/components/newLyrics.svelte';
|
||||
|
||||
const audioId = $page.params.id;
|
||||
let audioPlayer: HTMLAudioElement | null = null;
|
||||
@ -101,17 +103,7 @@
|
||||
const f = file as File;
|
||||
f.text().then((lr) => {
|
||||
if (f.name.endsWith('.ttml')) {
|
||||
const lyricLines = parseTTML(lr).lyricLines;
|
||||
originalLyrics = {
|
||||
scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => {
|
||||
return {
|
||||
text: value.words.map(word => word.word).join(''),
|
||||
start: value.startTime / 1000,
|
||||
end: value.endTime / 1000,
|
||||
translation: value.translatedLyric || undefined
|
||||
};
|
||||
})
|
||||
};
|
||||
originalLyrics = parseTTML(lr);
|
||||
for (const line of originalLyrics.scripts!) {
|
||||
lyricsText.push(line.text);
|
||||
}
|
||||
@ -220,7 +212,9 @@
|
||||
{hasLyrics}
|
||||
/>
|
||||
|
||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} />
|
||||
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||
|
||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" />
|
||||
|
||||
<audio
|
||||
bind:this={audioPlayer}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import { parseLRC } from '$lib/lyrics/parser';
|
||||
import { parseLRC } from '$lib/lyrics/lrc/parser';
|
||||
|
||||
describe('LRC parser test', () => {
|
||||
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
||||
|
Loading…
Reference in New Issue
Block a user