merge: branch 'ref-lyric' into dev
This commit is contained in:
parent
4b108d81fb
commit
d0c216fdaf
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ node_modules
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
data/pending
|
data/pending
|
||||||
|
.vscode
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "2.0.3",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
@ -29,3 +29,10 @@ h2 {
|
|||||||
.text-shadow-none {
|
.text-shadow-none {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
162
src/lib/components/lyrics/lyricLine.svelte
Normal file
162
src/lib/components/lyrics/lyricLine.svelte
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const scaleCurrentLine = viewportWidth > 640 ? 1.02 : 1.045 ;
|
||||||
|
|
||||||
|
export let line: ScriptItem;
|
||||||
|
export let index: number;
|
||||||
|
export let debugMode: Boolean;
|
||||||
|
export let lyricClick: Function;
|
||||||
|
|
||||||
|
let ref: HTMLDivElement;
|
||||||
|
let clickMask: HTMLSpanElement;
|
||||||
|
|
||||||
|
let time = 0;
|
||||||
|
let positionX: number = 0;
|
||||||
|
let positionY: number = 0;
|
||||||
|
let scale = 1;
|
||||||
|
let opacity = 1;
|
||||||
|
let stopped = false;
|
||||||
|
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;
|
||||||
|
|
||||||
|
function updateY(timestamp: number) {
|
||||||
|
if (lastUpdateY === undefined) {
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
if (springY === undefined) return;
|
||||||
|
time = (new Date().getTime() - lastUpdateY) / 1000;
|
||||||
|
springY.update(time);
|
||||||
|
positionY = springY.getCurrentPosition();
|
||||||
|
if (!springY.arrived() && !stopped) {
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
}
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateX(timestamp: number) {
|
||||||
|
if (lastUpdateX === undefined) {
|
||||||
|
lastUpdateX = timestamp;
|
||||||
|
}
|
||||||
|
if (springX === undefined) return;
|
||||||
|
time = (new Date().getTime() - 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;
|
||||||
|
opacity = isCurrent ? 1 : 0.36;
|
||||||
|
scale = isCurrent ? scaleCurrentLine : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setBlur = (blur: number) => {
|
||||||
|
ref.style.filter = `blur(${blur}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = (pos: LyricPos, delay: number = 0) => {
|
||||||
|
if (lastPosX === undefined || lastPosY === undefined) {
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
}
|
||||||
|
springX!.setTargetPosition(pos.x, delay);
|
||||||
|
springY!.setTargetPosition(pos.y, delay);
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
lastUpdateX = new Date().getTime();
|
||||||
|
stopped = false;
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
requestAnimationFrame(updateX);
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
springX = createSpring(pos.x, pos.x, 0.126, 0.8);
|
||||||
|
springY = createSpring(pos.y, pos.y, 0.126, 0.8);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRef = () => ref;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
style="transform: translate3d({positionX}px, {positionY}px, 0); scale: {scale};
|
||||||
|
transition-property: scale, opacity; transition-duration: 0.5s; transition-timing-function: ease-in-out; opacity: {opacity};
|
||||||
|
transform-origin: center left;"
|
||||||
|
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||||
|
bind:this={ref}
|
||||||
|
on:touchstart={() => {
|
||||||
|
clickMask.style.backgroundColor = "rgba(255,255,255,.3)";
|
||||||
|
}}
|
||||||
|
on:touchend={() => {
|
||||||
|
clickMask.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-2.75rem)] h-full
|
||||||
|
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)]" bind:this={clickMask}>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute -translate-y-7">
|
||||||
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4`}>
|
||||||
|
{line.text}
|
||||||
|
</span>
|
||||||
|
{#if line.translation}
|
||||||
|
<br />
|
||||||
|
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300`}>
|
||||||
|
{line.translation}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
@ -2,9 +2,11 @@
|
|||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
import type { LrcJsonData } from '$lib/lyrics/LRCparser';
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
|
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||||
import nextUpdate from '$lib/state/nextUpdate';
|
import nextUpdate from '$lib/state/nextUpdate';
|
||||||
import truncate from '$lib/utils/truncate';
|
import truncate from '$lib/truncate';
|
||||||
|
import { blur } from 'svelte/transition';
|
||||||
|
|
||||||
// Component input properties
|
// Component input properties
|
||||||
export let lyrics: string[];
|
export let lyrics: string[];
|
||||||
@ -18,15 +20,13 @@
|
|||||||
let showTranslation = false;
|
let showTranslation = false;
|
||||||
if (localStorage.getItem('debugMode') == null) {
|
if (localStorage.getItem('debugMode') == null) {
|
||||||
localStorage.setItem('debugMode', 'false');
|
localStorage.setItem('debugMode', 'false');
|
||||||
}
|
} else {
|
||||||
else {
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === "true";
|
|
||||||
}
|
}
|
||||||
if (localStorage.getItem('showTranslation') == null) {
|
if (localStorage.getItem('showTranslation') == null) {
|
||||||
localStorage.setItem('showTranslation', 'false');
|
localStorage.setItem('showTranslation', 'false');
|
||||||
}
|
} else {
|
||||||
else {
|
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === 'true';
|
||||||
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === "true";
|
|
||||||
}
|
}
|
||||||
let currentLyricIndex = -1;
|
let currentLyricIndex = -1;
|
||||||
let currentPositionIndex = -1;
|
let currentPositionIndex = -1;
|
||||||
@ -45,18 +45,16 @@
|
|||||||
$: refs = _refs.filter(Boolean);
|
$: refs = _refs.filter(Boolean);
|
||||||
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
|
|
||||||
|
|
||||||
// handle KeyDown event
|
// handle KeyDown event
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||||
debugMode = !debugMode;
|
debugMode = !debugMode;
|
||||||
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||||
}
|
} else if (e.key === 't') {
|
||||||
else if (e.key === 't') {
|
|
||||||
showTranslation = !showTranslation;
|
showTranslation = !showTranslation;
|
||||||
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToLyric(refs[currentPositionIndex])
|
scrollToLyric(refs[currentPositionIndex]);
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,7 +63,7 @@
|
|||||||
function extractTranslateValue(s: string): string | null {
|
function extractTranslateValue(s: string): string | null {
|
||||||
const regex = /translateY\((-?\d*px)\)/;
|
const regex = /translateY\((-?\d*px)\)/;
|
||||||
let arr = regex.exec(s);
|
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
|
// Helper function to get CSS class for a lyric based on its index and progress
|
||||||
@ -78,7 +76,7 @@
|
|||||||
|
|
||||||
// Function to move the lyrics up smoothly
|
// Function to move the lyrics up smoothly
|
||||||
async function moveToNextLine(h: number) {
|
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)
|
// the line that's going to process (like a pointer)
|
||||||
// by default, it's "the next line" after the lift
|
// by default, it's "the next line" after the lift
|
||||||
let processingLineIndex = currentPositionIndex + 2;
|
let processingLineIndex = currentPositionIndex + 2;
|
||||||
@ -86,21 +84,24 @@
|
|||||||
// modify translateY of all lines in viewport one by one to lift them up
|
// modify translateY of all lines in viewport one by one to lift them up
|
||||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
const lyric = refs[i];
|
const lyric = refs[i];
|
||||||
lyric.style.transition =
|
lyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||||
`transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease,
|
|
||||||
font-size 200ms ease, scale 250ms ease`;
|
font-size 200ms ease, scale 250ms ease`;
|
||||||
lyric.style.transform = `translateY(${-h}px)`;
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
processingLineIndex = i;
|
processingLineIndex = i;
|
||||||
await sleep(75);
|
await sleep(75);
|
||||||
const twoLinesAhead = refs[i - 2];
|
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) {
|
if (refs.length - processingLineIndex < 3) {
|
||||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
const lyric = refs[i];
|
const lyric = refs[i];
|
||||||
lyric.style.transition =
|
lyric.style.transition =
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
'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)`;
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
processingLineIndex = i;
|
processingLineIndex = i;
|
||||||
await sleep(75);
|
await sleep(75);
|
||||||
@ -199,9 +200,13 @@
|
|||||||
const currentLyric = refs[currentPositionIndex];
|
const currentLyric = refs[currentPositionIndex];
|
||||||
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
for (let i = 0; i < refs.length; i++) {
|
||||||
const offset = Math.abs(i - currentPositionIndex);
|
const offset = Math.abs(i - currentPositionIndex);
|
||||||
const blurRadius = Math.min(offset * 0.96, 16);
|
let blurRadius = Math.min(offset * 1.25, 16);
|
||||||
|
const rect = refs[i].getBoundingClientRect();
|
||||||
|
if (rect.top + rect.height < 0 || rect.top > lyricsContainer.getBoundingClientRect().height) {
|
||||||
|
blurRadius = 0;
|
||||||
|
}
|
||||||
if (refs[i]) {
|
if (refs[i]) {
|
||||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||||
}
|
}
|
||||||
@ -209,15 +214,30 @@
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getViewportRange() {
|
||||||
|
let min = 0;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const element = refs[i];
|
||||||
|
if (element.getBoundingClientRect().top < 0) {
|
||||||
|
min = i;
|
||||||
|
} else if (element.getBoundingClientRect().bottom < 0) {
|
||||||
|
max = i;
|
||||||
|
return [min, max];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main function that control's lyrics update during playing
|
// Main function that control's lyrics update during playing
|
||||||
// triggered by nextUpdate's update
|
// triggered by nextUpdate's update
|
||||||
async function lyricsUpdate(){
|
async function lyricsUpdate() {
|
||||||
if (
|
if (
|
||||||
currentPositionIndex < 0 ||
|
currentPositionIndex < 0 ||
|
||||||
currentPositionIndex === currentAnimationIndex ||
|
currentPositionIndex === currentAnimationIndex ||
|
||||||
$userAdjustingProgress === true ||
|
$userAdjustingProgress === true ||
|
||||||
scrolling
|
scrolling
|
||||||
) return;
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const currentLyric = refs[currentPositionIndex];
|
const currentLyric = refs[currentPositionIndex];
|
||||||
const currentLyricRect = currentLyric.getBoundingClientRect();
|
const currentLyricRect = currentLyric.getBoundingClientRect();
|
||||||
@ -227,18 +247,20 @@
|
|||||||
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
||||||
|
|
||||||
// prepare current line
|
// prepare current line
|
||||||
currentLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
currentLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
|
|
||||||
|
// prepare past lines
|
||||||
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
||||||
refs[i].style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
refs[i].style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
}
|
}
|
||||||
|
await sleep(75);
|
||||||
if (currentPositionIndex + 1 < refs.length) {
|
if (currentPositionIndex + 1 < refs.length) {
|
||||||
const nextLyric = refs[currentPositionIndex + 1];
|
const nextLyric = refs[currentPositionIndex + 1];
|
||||||
nextLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
nextLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
await moveToNextLine(offsetHeight);
|
await moveToNextLine(offsetHeight);
|
||||||
@ -246,8 +268,7 @@
|
|||||||
currentAnimationIndex = currentPositionIndex;
|
currentAnimationIndex = currentPositionIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextUpdate.subscribe(lyricsUpdate);
|
||||||
nextUpdate.subscribe(lyricsUpdate)
|
|
||||||
|
|
||||||
// Process while user is adjusting progress
|
// Process while user is adjusting progress
|
||||||
userAdjustingProgress.subscribe((adjusting) => {
|
userAdjustingProgress.subscribe((adjusting) => {
|
||||||
@ -281,19 +302,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function lyricClick(lyricIndex: number) {
|
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.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
player.play()
|
player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeyDown} />
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
{#if debugMode && lyricsContainer}
|
<div {...$$restProps}>
|
||||||
|
{#if debugMode && lyricsContainer}
|
||||||
<div
|
<div
|
||||||
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
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">
|
px-2 rounded-xl text-white"
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
||||||
AnimationIndex:{currentAnimationIndex}
|
AnimationIndex:{currentAnimationIndex}
|
||||||
@ -302,10 +324,9 @@
|
|||||||
scrollPosition: {lyricsContainer.scrollTop}
|
scrollPosition: {lyricsContainer.scrollTop}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
||||||
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
|
||||||
<div
|
<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
|
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"
|
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
||||||
@ -315,29 +336,43 @@
|
|||||||
{#each lyrics as lyric, i}
|
{#each lyrics as lyric, i}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div bind:this={_refs[i]} class="relative h-fit text-shadow-lg" on:click={() => {lyricClick(i)}} >
|
<div
|
||||||
|
bind:this={_refs[i]}
|
||||||
|
class="relative h-fit text-shadow-lg"
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
||||||
<span class="previous-lyric !text-lg !absolute !-translate-y-12">{i}
|
<span class="previous-lyric !text-lg !absolute !-translate-y-12"
|
||||||
|
>{i}
|
||||||
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
||||||
tY: {extractTranslateValue(refs[i].style.transform)}
|
tY: {extractTranslateValue(refs[i].style.transform)}
|
||||||
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}>
|
<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}
|
{#if originalLyrics.scripts[i].singer}
|
||||||
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{lyric}
|
{lyric}
|
||||||
</p>
|
</p>
|
||||||
{#if originalLyrics.scripts[i].translation && showTranslation}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="relative w-full h-[50rem]"></div>
|
<div class="relative w-full h-[50rem]"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!--suppress CssUnusedSymbol -->
|
<!--suppress CssUnusedSymbol -->
|
||||||
<style>
|
<style>
|
||||||
@ -346,9 +381,9 @@
|
|||||||
--lyric-mobile-line-height: 2.4rem;
|
--lyric-mobile-line-height: 2.4rem;
|
||||||
--lyric-mobile-margin: 1.5rem 0;
|
--lyric-mobile-margin: 1.5rem 0;
|
||||||
--lyric-mobile-font-weight: 600;
|
--lyric-mobile-font-weight: 600;
|
||||||
--lyric-desktop-font-size: 3.5rem;
|
--lyric-desktop-font-size: 3rem;
|
||||||
--lyric-desktop-line-height: 4.5rem;
|
--lyric-desktop-line-height: 4.5rem;
|
||||||
--lyric-desktop-margin: 1.75rem 0;
|
--lyric-desktop-margin: 2.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics {
|
.lyrics {
|
||||||
@ -366,7 +401,7 @@
|
|||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
transform: translateY(calc(50%)) translateX(-3rem);
|
transform: translateY(calc(50%)) translateX(-3rem);
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
background: rgba(255,255,255,.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
260
src/lib/components/lyrics/newLyrics.svelte
Normal file
260
src/lib/components/lyrics/newLyrics.svelte
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ScriptItem } from '$lib/lyrics/type';
|
||||||
|
import LyricLine from './lyricLine.svelte';
|
||||||
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
|
|
||||||
|
// constants
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const marginY = viewportWidth > 640 ? 36 : 0 ;
|
||||||
|
const currentLyrictTop = viewportHeight * 0.02;
|
||||||
|
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||||
|
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// 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 nextUpdate = 0;
|
||||||
|
let lastProgress = 0;
|
||||||
|
let showTranslation = false;
|
||||||
|
let scrollEventAdded = false;
|
||||||
|
let scrolling = false;
|
||||||
|
let scrollingTimeout: Timer;
|
||||||
|
let lastY: number; // For tracking touch movements
|
||||||
|
let lastTime: number; // For tracking time between touch moves
|
||||||
|
let velocityY = 0; // Vertical scroll velocity
|
||||||
|
let inertiaFrame: number; // For storing the requestAnimationFrame reference
|
||||||
|
|
||||||
|
// References to lyric elements
|
||||||
|
let lyricElements: HTMLDivElement[] = [];
|
||||||
|
let lyricComponents: LyricLine[] = [];
|
||||||
|
let lyricTopList: number[] = [];
|
||||||
|
|
||||||
|
let currentLyricIndex: number;
|
||||||
|
|
||||||
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
|
|
||||||
|
$: {
|
||||||
|
currentLyricIndex = getLyricIndex(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLyricComponents() {
|
||||||
|
initLyricTopList();
|
||||||
|
for (let i = 0; i < lyricComponents.length; i++) {
|
||||||
|
lyricComponents[i].init({ x: 0, y: lyricTopList[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLyricTopList() {
|
||||||
|
let cumulativeHeight = 0;
|
||||||
|
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;
|
||||||
|
lyricTopList.push(elementTargetTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayout() {
|
||||||
|
if (!originalLyrics.scripts) return;
|
||||||
|
const currentLyricDuration =
|
||||||
|
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
|
||||||
|
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyrictTop;
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
let delay = 0;
|
||||||
|
if (i <= currentLyricIndex) {
|
||||||
|
delay = 0;
|
||||||
|
} else {
|
||||||
|
delay = 0.013 + Math.min(Math.min(currentLyricDuration, 0.1), 0.075 * (i - currentLyricIndex));
|
||||||
|
}
|
||||||
|
const offset = Math.abs(i - currentLyricIndex);
|
||||||
|
let blurRadius = Math.min(offset * 1.7, 16);
|
||||||
|
currentLyricComponent.setBlur(blurRadius);
|
||||||
|
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (originalLyrics && originalLyrics.scripts) {
|
||||||
|
lyricExists = true;
|
||||||
|
lyricLines = originalLyrics.scripts!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricComponents.length > 0) {
|
||||||
|
initLyricComponents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(deltaY: number) {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
const currentY = currentLyricComponent.getInfo().y;
|
||||||
|
currentLyricComponent.setBlur(0);
|
||||||
|
currentLyricComponent.stop();
|
||||||
|
currentLyricComponent.setY(currentY - deltaY);
|
||||||
|
}
|
||||||
|
scrolling = true;
|
||||||
|
//if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||||
|
scrollingTimeout = setTimeout(() => {
|
||||||
|
scrolling = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch start event
|
||||||
|
function handleTouchStart(event: TouchEvent) {
|
||||||
|
lastY = event.touches[0].clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch move event
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
const currentY = event.touches[0].clientY;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const deltaY = lastY - currentY; // Calculate vertical swipe distance
|
||||||
|
const deltaTime = currentTime - lastTime;
|
||||||
|
|
||||||
|
// Calculate the scroll velocity (change in Y over time)
|
||||||
|
if (deltaTime > 0) {
|
||||||
|
velocityY = deltaY / deltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll(deltaY); // Simulate the scroll event
|
||||||
|
lastY = currentY; // Update lastY for the next move event
|
||||||
|
lastTime = currentTime; // Update the lastTime for the next move event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch end event
|
||||||
|
function handleTouchEnd() {
|
||||||
|
// Start inertia scrolling based on the velocity
|
||||||
|
function inertiaScroll() {
|
||||||
|
if (Math.abs(velocityY) < minVelocity) {
|
||||||
|
cancelAnimationFrame(inertiaFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleScroll(velocityY * 16); // Multiply by frame time (16ms) to get smooth scroll
|
||||||
|
velocityY *= deceleration; // Apply deceleration to velocity
|
||||||
|
inertiaFrame = requestAnimationFrame(inertiaScroll); // Continue scrolling in next frame
|
||||||
|
}
|
||||||
|
inertiaScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricsContainer && !scrollEventAdded) {
|
||||||
|
// Wheel event for desktop
|
||||||
|
lyricsContainer.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const deltaY = e.deltaY;
|
||||||
|
handleScroll(deltaY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Touch events for mobile
|
||||||
|
lyricsContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
lyricsContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
lyricsContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
scrollEventAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricsContainer && lyricComponents.length > 0) {
|
||||||
|
if (progress >= nextUpdate - 0.5 && !scrolling) {
|
||||||
|
console.log("computeLayout")
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
if (Math.abs(lastProgress - progress) > 0) {
|
||||||
|
scrolling = false;
|
||||||
|
}
|
||||||
|
if (lastProgress - progress > 0) {
|
||||||
|
computeLayout();
|
||||||
|
nextUpdate = progress;
|
||||||
|
} else {
|
||||||
|
const lyricLength = originalLyrics.scripts!.length;
|
||||||
|
const currentEnd = originalLyrics.scripts![currentLyricIndex].end;
|
||||||
|
const nextStart = originalLyrics.scripts![Math.min(currentLyricIndex + 1, lyricLength - 1)].start;
|
||||||
|
if (currentEnd !== nextStart) {
|
||||||
|
nextUpdate = currentEnd;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nextUpdate = nextStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastProgress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const isCurrent = i == currentLyricIndex;
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
currentLyricComponent.setCurrent(isCurrent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Initialize
|
||||||
|
if (localStorage.getItem('debugMode') == null) {
|
||||||
|
localStorage.setItem('debugMode', 'false');
|
||||||
|
} else {
|
||||||
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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') {
|
||||||
|
showTranslation = !showTranslation;
|
||||||
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lyricClick(lyricIndex: number) {
|
||||||
|
if (player === null || originalLyrics.scripts === undefined) return;
|
||||||
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute">
|
||||||
|
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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-[46vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar z-[1] pt-16 overflow-hidden"
|
||||||
|
bind:this={lyricsContainer}
|
||||||
|
>
|
||||||
|
{#each lyricLines as lyric, i}
|
||||||
|
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
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;
|
||||||
|
}
|
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;
|
||||||
|
}
|
147
src/lib/graphics/spring/spring.ts
Normal file
147
src/lib/graphics/spring/spring.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
@ -16,6 +16,8 @@ import {
|
|||||||
tok,
|
tok,
|
||||||
type Token
|
type Token
|
||||||
} from 'typescript-parsec';
|
} from 'typescript-parsec';
|
||||||
|
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
|
||||||
|
import type { IDTag } from './type';
|
||||||
|
|
||||||
|
|
||||||
interface ParserScriptItem {
|
interface ParserScriptItem {
|
||||||
@ -26,45 +28,6 @@ interface ParserScriptItem {
|
|||||||
singer?: string;
|
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({
|
function convertTimeToMs({
|
||||||
mins,
|
mins,
|
||||||
secs,
|
secs,
|
||||||
|
0
src/lib/lyrics/lrc/index.ts
Normal file
0
src/lib/lyrics/lrc/index.ts
Normal file
270
src/lib/lyrics/lrc/parser.ts
Normal file
270
src/lib/lyrics/lrc/parser.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
alt_sc,
|
||||||
|
apply,
|
||||||
|
buildLexer,
|
||||||
|
expectEOF,
|
||||||
|
fail,
|
||||||
|
kleft,
|
||||||
|
kmid,
|
||||||
|
kright,
|
||||||
|
opt_sc,
|
||||||
|
type Parser,
|
||||||
|
rep,
|
||||||
|
rep_sc,
|
||||||
|
seq,
|
||||||
|
str,
|
||||||
|
tok,
|
||||||
|
type Token
|
||||||
|
} from 'typescript-parsec';
|
||||||
|
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
|
||||||
|
import type { IDTag } from './type';
|
||||||
|
|
||||||
|
|
||||||
|
interface ParserScriptItem {
|
||||||
|
start: number;
|
||||||
|
text: string;
|
||||||
|
words?: ScriptWordsItem[];
|
||||||
|
translation?: string;
|
||||||
|
singer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTimeToMs({
|
||||||
|
mins,
|
||||||
|
secs,
|
||||||
|
decimals
|
||||||
|
}: {
|
||||||
|
mins?: number | string;
|
||||||
|
secs?: number | string;
|
||||||
|
decimals?: string;
|
||||||
|
}) {
|
||||||
|
let result = 0;
|
||||||
|
if (mins) {
|
||||||
|
result += Number(mins) * 60 * 1000;
|
||||||
|
}
|
||||||
|
if (secs) {
|
||||||
|
result += Number(secs) * 1000;
|
||||||
|
}
|
||||||
|
if (decimals) {
|
||||||
|
const denom = Math.pow(10, decimals.length);
|
||||||
|
result += Number(decimals) / (denom / 1000);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digit = Array.from({ length: 10 }, (_, i) => apply(str(i.toString()), (_) => i)).reduce(
|
||||||
|
(acc, cur) => alt_sc(cur, acc),
|
||||||
|
fail('no alternatives')
|
||||||
|
);
|
||||||
|
const numStr = apply(rep_sc(digit), (r) => r.join(''));
|
||||||
|
const num = apply(numStr, (r) => parseInt(r));
|
||||||
|
const alpha = alt_sc(
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('a'.charCodeAt(0) + i)), (_) => String.fromCharCode('a'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives')),
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('A'.charCodeAt(0) + i)), (_) => String.fromCharCode('A'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const alphaStr = apply(rep(alpha), (r) => r.join(''));
|
||||||
|
|
||||||
|
function spaces<K>(): Parser<K, Token<K>[]> {
|
||||||
|
return rep_sc(str(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unicodeStr = rep(tok('char'));
|
||||||
|
|
||||||
|
function trimmed<K, T>(p: Parser<K, Token<T>[]>): Parser<K, Token<T>[]> {
|
||||||
|
return apply(p, (r) => {
|
||||||
|
while (r.length > 0 && r[0].text.trim() === '') {
|
||||||
|
r.shift();
|
||||||
|
}
|
||||||
|
while (r.length > 0 && r[r.length - 1].text.trim() === '') {
|
||||||
|
r.pop();
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function padded<K, T>(p: Parser<K, T>): Parser<K, T> {
|
||||||
|
return kmid(spaces(), p, spaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
function anythingTyped(types: string[]) {
|
||||||
|
return types.map((t) => tok(t)).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lrcTimestamp<K, T>(delim: [Parser<K, Token<T>>, Parser<K, Token<T>>]) {
|
||||||
|
const innerTS = alt_sc(
|
||||||
|
apply(seq(num, str(':'), num, str('.'), numStr), (r) =>
|
||||||
|
convertTimeToMs({ mins: r[0], secs: r[2], decimals: r[4] })
|
||||||
|
),
|
||||||
|
apply(seq(num, str('.'), numStr), (r) => convertTimeToMs({ secs: r[0], decimals: r[2] })),
|
||||||
|
apply(seq(num, str(':'), num), (r) => convertTimeToMs({ mins: r[0], secs: r[2] })),
|
||||||
|
apply(num, (r) => convertTimeToMs({ secs: r }))
|
||||||
|
);
|
||||||
|
return kmid(delim[0], innerTS, delim[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareTS = lrcTimestamp([tok('['), tok(']')]);
|
||||||
|
const angleTS = lrcTimestamp([tok('<'), tok('>')]);
|
||||||
|
|
||||||
|
const lrcTag = apply(
|
||||||
|
seq(
|
||||||
|
tok('['),
|
||||||
|
alphaStr,
|
||||||
|
str(':'),
|
||||||
|
tokenParserToText(trimmed(rep(anythingTyped(['char', '[', ']', '<', '>'])))),
|
||||||
|
tok(']')
|
||||||
|
),
|
||||||
|
(r) => ({
|
||||||
|
[r[1]]: r[3]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function joinTokens<T>(tokens: Token<T>[]) {
|
||||||
|
return tokens.map((t) => t.text).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenParserToText<K, T>(p: Parser<K, Token<T>> | Parser<K, Token<T>[]>): Parser<K, string> {
|
||||||
|
return apply(p, (r: Token<T> | Token<T>[]) => {
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
return joinTokens(r);
|
||||||
|
}
|
||||||
|
return r.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const singerIndicator = kleft(tok('char'), str(':'));
|
||||||
|
const translateParser = kright(tok('|'), unicodeStr);
|
||||||
|
|
||||||
|
function lrcLine(
|
||||||
|
wordDiv = ' ', legacy = false
|
||||||
|
): Parser<unknown, ['script_item', ParserScriptItem] | ['lrc_tag', IDTag] | ['comment', string] | ['empty', null]> {
|
||||||
|
return alt_sc(
|
||||||
|
legacy ? apply(seq(squareTS, trimmed(rep_sc(anythingTyped(['char', '[', ']', '<', '>'])))), (r) =>
|
||||||
|
['script_item', { start: r[0], text: joinTokens(r[1]) } as ParserScriptItem] // TODO: Complete this
|
||||||
|
) : apply(
|
||||||
|
seq(
|
||||||
|
squareTS,
|
||||||
|
opt_sc(padded(singerIndicator)),
|
||||||
|
rep_sc(
|
||||||
|
seq(
|
||||||
|
opt_sc(angleTS),
|
||||||
|
trimmed(rep_sc(anythingTyped(['char', '[', ']'])))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
opt_sc(trimmed(translateParser))
|
||||||
|
), (r) => {
|
||||||
|
const start = r[0];
|
||||||
|
const singerPart = r[1];
|
||||||
|
const mainPart = r[2];
|
||||||
|
const translatePart = r[3];
|
||||||
|
|
||||||
|
const text = mainPart
|
||||||
|
.map((s) => joinTokens(s[1]))
|
||||||
|
.filter((s) => s.trim().length > 0)
|
||||||
|
.join(wordDiv);
|
||||||
|
|
||||||
|
const words = mainPart
|
||||||
|
.filter((s) => joinTokens(s[1]).trim().length > 0)
|
||||||
|
.map((s) => {
|
||||||
|
const wordBegin = s[0];
|
||||||
|
const word = s[1];
|
||||||
|
let ret: Partial<ScriptWordsItem> = { start: wordBegin };
|
||||||
|
if (word[0]) {
|
||||||
|
ret.beginIndex = word[0].pos.columnBegin - 1;
|
||||||
|
}
|
||||||
|
if (word[word.length - 1]) {
|
||||||
|
ret.endIndex = word[word.length - 1].pos.columnEnd;
|
||||||
|
}
|
||||||
|
return ret as ScriptWordsItem; // TODO: Complete this
|
||||||
|
});
|
||||||
|
|
||||||
|
const singer = singerPart?.text;
|
||||||
|
const translation = translatePart === undefined ? undefined : joinTokens(translatePart);
|
||||||
|
|
||||||
|
return ['script_item', { start, text, words, singer, translation } as ParserScriptItem];
|
||||||
|
}),
|
||||||
|
apply(lrcTag, (r) => ['lrc_tag', r as IDTag]),
|
||||||
|
apply(seq(spaces(), str('#'), unicodeStr), (cmt) => ['comment', cmt[2].join('')] as const),
|
||||||
|
apply(spaces(), (_) => ['empty', null] as const)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dumpToken<T>(t: Token<T> | undefined): string {
|
||||||
|
if (t === undefined) {
|
||||||
|
return '<EOF>';
|
||||||
|
}
|
||||||
|
return '`' + t.text + '` -> ' + dumpToken(t.next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLRC(
|
||||||
|
input: string,
|
||||||
|
{ wordDiv, strict, legacy }: { wordDiv?: string; strict?: boolean; legacy?: boolean } = {}
|
||||||
|
): ParsedLrc {
|
||||||
|
const tokenizer = buildLexer([
|
||||||
|
[true, /^\[/gu, '['],
|
||||||
|
[true, /^\]/gu, ']'],
|
||||||
|
[true, /^</gu, '<'],
|
||||||
|
[true, /^>/gu, '>'],
|
||||||
|
[true, /^\|/gu, '|'],
|
||||||
|
[true, /^./gu, 'char']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = input
|
||||||
|
.split(/\r\n|\r|\n/gu)
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => tokenizer.parse(line));
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const res = expectEOF(lrcLine(wordDiv, legacy).parse(line));
|
||||||
|
if (!res.successful) {
|
||||||
|
if (strict) {
|
||||||
|
throw new Error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.candidates[0].result;
|
||||||
|
})
|
||||||
|
.filter((r) => r !== null)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
switch (cur[0]) {
|
||||||
|
case 'lrc_tag':
|
||||||
|
Object.assign(acc, cur[1]);
|
||||||
|
return acc;
|
||||||
|
case 'script_item':
|
||||||
|
acc.scripts = acc.scripts || [];
|
||||||
|
acc.scripts.push(cur[1]);
|
||||||
|
return acc;
|
||||||
|
default:
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}, {} as ParsedLrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function lrcParser(lrc: string): LrcJsonData {
|
||||||
|
const parsedLrc = parseLRC(lrc, { wordDiv: '', strict: false });
|
||||||
|
if (parsedLrc.scripts === undefined) {
|
||||||
|
return parsedLrc as LrcJsonData;
|
||||||
|
}
|
||||||
|
let finalLrc: LrcJsonData = parsedLrc as LrcJsonData;
|
||||||
|
let lyrics: ScriptItem[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < parsedLrc.scripts.length - 1) {
|
||||||
|
let lyricLine = parsedLrc.scripts[i] as ScriptItem;
|
||||||
|
lyricLine.start/=1000;
|
||||||
|
lyricLine.end = parsedLrc.scripts[i+1].start / 1000;
|
||||||
|
if (parsedLrc.scripts[i+1].text.trim() === "") {
|
||||||
|
i+=2;
|
||||||
|
} else i++;
|
||||||
|
if (lyricLine.text.trim() !== "") {
|
||||||
|
lyrics.push(lyricLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalLrc.scripts = lyrics;
|
||||||
|
return finalLrc;
|
||||||
|
}
|
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;
|
||||||
|
}
|
169
src/lib/lyrics/ttml/parser.ts
Normal file
169
src/lib/lyrics/ttml/parser.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* 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/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LyricLine,
|
||||||
|
LyricWord,
|
||||||
|
TTMLLyric,
|
||||||
|
TTMLMetadata,
|
||||||
|
} from "./ttml-types";
|
||||||
|
|
||||||
|
const timeRegexp =
|
||||||
|
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
|
||||||
|
function parseTimespan(timeSpan: string): number {
|
||||||
|
const matches = timeRegexp.exec(timeSpan);
|
||||||
|
if (matches) {
|
||||||
|
const hour = Number(matches.groups?.hour || "0");
|
||||||
|
const min = Number(matches.groups?.min || "0");
|
||||||
|
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
||||||
|
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
||||||
|
}
|
||||||
|
throw new TypeError(`Failed to parse time stamp:${timeSpan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTTML(ttmlText: string): TTMLLyric {
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const ttmlDoc: XMLDocument = domParser.parseFromString(
|
||||||
|
ttmlText,
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mainAgentId = "v1";
|
||||||
|
|
||||||
|
const metadata: TTMLMetadata[] = [];
|
||||||
|
for (const meta of ttmlDoc.querySelectorAll("meta")) {
|
||||||
|
if (meta.tagName === "amll:meta") {
|
||||||
|
const key = meta.getAttribute("key");
|
||||||
|
if (key) {
|
||||||
|
const value = meta.getAttribute("value");
|
||||||
|
if (value) {
|
||||||
|
const existing = metadata.find((m) => m.key === key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value.push(value);
|
||||||
|
} else {
|
||||||
|
metadata.push({
|
||||||
|
key,
|
||||||
|
value: [value],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
|
||||||
|
if (agent.getAttribute("type") === "person") {
|
||||||
|
const id = agent.getAttribute("xml:id");
|
||||||
|
if (id) {
|
||||||
|
mainAgentId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyricLines: LyricLine[] = [];
|
||||||
|
|
||||||
|
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
|
||||||
|
const line: LyricLine = {
|
||||||
|
words: [],
|
||||||
|
translatedLyric: "",
|
||||||
|
romanLyric: "",
|
||||||
|
isBG,
|
||||||
|
isDuet:
|
||||||
|
!!lineEl.getAttribute("ttm:agent") &&
|
||||||
|
lineEl.getAttribute("ttm:agent") !== mainAgentId,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
};
|
||||||
|
if (isBG) line.isDuet = isDuet;
|
||||||
|
let haveBg = false;
|
||||||
|
|
||||||
|
for (const wordNode of lineEl.childNodes) {
|
||||||
|
if (wordNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
line.words?.push({
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
});
|
||||||
|
} else if (wordNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const wordEl = wordNode as Element;
|
||||||
|
const role = wordEl.getAttribute("ttm:role");
|
||||||
|
|
||||||
|
if (wordEl.nodeName === "span" && role) {
|
||||||
|
if (role === "x-bg") {
|
||||||
|
parseParseLine(wordEl, true, line.isDuet);
|
||||||
|
haveBg = true;
|
||||||
|
} else if (role === "x-translation") {
|
||||||
|
line.translatedLyric = wordEl.innerHTML;
|
||||||
|
} else if (role === "x-roman") {
|
||||||
|
line.romanLyric = wordEl.innerHTML;
|
||||||
|
}
|
||||||
|
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
||||||
|
const word: LyricWord = {
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
||||||
|
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
||||||
|
};
|
||||||
|
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
||||||
|
if (emptyBeat) {
|
||||||
|
word.emptyBeat = Number(emptyBeat);
|
||||||
|
}
|
||||||
|
line.words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.isBG) {
|
||||||
|
const firstWord = line.words?.[0];
|
||||||
|
if (firstWord?.word.startsWith("(")) {
|
||||||
|
firstWord.word = firstWord.word.substring(1);
|
||||||
|
if (firstWord.word.length === 0) {
|
||||||
|
line.words.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastWord = line.words?.[line.words.length - 1];
|
||||||
|
if (lastWord?.word.endsWith(")")) {
|
||||||
|
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
||||||
|
if (lastWord.word.length === 0) {
|
||||||
|
line.words.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = lineEl.getAttribute("begin");
|
||||||
|
const endTime = lineEl.getAttribute("end");
|
||||||
|
if (startTime && endTime) {
|
||||||
|
line.startTime = parseTimespan(startTime);
|
||||||
|
line.endTime = parseTimespan(endTime);
|
||||||
|
} else {
|
||||||
|
line.startTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
||||||
|
line.endTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haveBg) {
|
||||||
|
const bgLine = lyricLines.pop();
|
||||||
|
lyricLines.push(line);
|
||||||
|
if (bgLine) lyricLines.push(bgLine);
|
||||||
|
} else {
|
||||||
|
lyricLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
||||||
|
parseParseLine(lineEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
lyricLines: lyricLines,
|
||||||
|
};
|
||||||
|
}
|
26
src/lib/lyrics/ttml/ttml-types.ts
Normal file
26
src/lib/lyrics/ttml/ttml-types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface TTMLMetadata {
|
||||||
|
key: string;
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTMLLyric {
|
||||||
|
metadata: TTMLMetadata[];
|
||||||
|
lyricLines: LyricLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricWord {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
word: string;
|
||||||
|
emptyBeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricLine {
|
||||||
|
words: LyricWord[];
|
||||||
|
translatedLyric: string;
|
||||||
|
romanLyric: string;
|
||||||
|
isBG: boolean;
|
||||||
|
isDuet: boolean;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
254
src/lib/lyrics/ttml/writer.ts
Normal file
254
src/lib/lyrics/ttml/writer.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
||||||
|
|
||||||
|
function msToTimestamp(timeMS: number): string {
|
||||||
|
let time = timeMS;
|
||||||
|
if (!Number.isSafeInteger(time) || time < 0) {
|
||||||
|
return "00:00.000";
|
||||||
|
}
|
||||||
|
if (time === Infinity) {
|
||||||
|
return "99:99.999";
|
||||||
|
}
|
||||||
|
time = time / 1000;
|
||||||
|
const secs = time % 60;
|
||||||
|
time = (time - secs) / 60;
|
||||||
|
const mins = time % 60;
|
||||||
|
const hrs = (time - mins) / 60;
|
||||||
|
|
||||||
|
const h = hrs.toString().padStart(2, "0");
|
||||||
|
const m = mins.toString().padStart(2, "0");
|
||||||
|
const s = secs.toFixed(3).padStart(6, "0");
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
||||||
|
const params: LyricLine[][] = [];
|
||||||
|
const lyric = ttmlLyric.lyricLines;
|
||||||
|
|
||||||
|
let tmp: LyricLine[] = [];
|
||||||
|
for (const line of lyric) {
|
||||||
|
if (line.words.length === 0 && tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
tmp = [];
|
||||||
|
} else {
|
||||||
|
tmp.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document();
|
||||||
|
|
||||||
|
function createWordElement(word: LyricWord): Element {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
span.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
if (word.emptyBeat) {
|
||||||
|
span.setAttribute("amll:empty-beat", `${word.emptyBeat}`);
|
||||||
|
}
|
||||||
|
span.appendChild(doc.createTextNode(word.word));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll");
|
||||||
|
ttRoot.setAttribute(
|
||||||
|
"xmlns:itunes",
|
||||||
|
"http://music.apple.com/lyric-ttml-internal",
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.appendChild(ttRoot);
|
||||||
|
|
||||||
|
const head = doc.createElement("head");
|
||||||
|
|
||||||
|
ttRoot.appendChild(head);
|
||||||
|
|
||||||
|
const body = doc.createElement("body");
|
||||||
|
const hasOtherPerson = !!lyric.find((v) => v.isDuet);
|
||||||
|
|
||||||
|
const metadataEl = doc.createElement("metadata");
|
||||||
|
const mainPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
mainPersonAgent.setAttribute("type", "person");
|
||||||
|
mainPersonAgent.setAttribute("xml:id", "v1");
|
||||||
|
|
||||||
|
metadataEl.appendChild(mainPersonAgent);
|
||||||
|
|
||||||
|
if (hasOtherPerson) {
|
||||||
|
const otherPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
otherPersonAgent.setAttribute("type", "other");
|
||||||
|
otherPersonAgent.setAttribute("xml:id", "v2");
|
||||||
|
|
||||||
|
metadataEl.appendChild(otherPersonAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const metadata of ttmlLyric.metadata) {
|
||||||
|
for (const value of metadata.value) {
|
||||||
|
const metaEl = doc.createElement("amll:meta");
|
||||||
|
metaEl.setAttribute("key", metadata.key);
|
||||||
|
metaEl.setAttribute("value", value);
|
||||||
|
metadataEl.appendChild(metaEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head.appendChild(metadataEl);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0;
|
||||||
|
body.setAttribute("dur", msToTimestamp(guessDuration));
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const paramDiv = doc.createElement("div");
|
||||||
|
const beginTime = param[0]?.startTime ?? 0;
|
||||||
|
const endTime = param[param.length - 1]?.endTime ?? 0;
|
||||||
|
|
||||||
|
paramDiv.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
paramDiv.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
for (let lineIndex = 0; lineIndex < param.length; lineIndex++) {
|
||||||
|
const line = param[lineIndex];
|
||||||
|
const lineP = doc.createElement("p");
|
||||||
|
const beginTime = line.startTime ?? 0;
|
||||||
|
const endTime = line.endTime;
|
||||||
|
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1");
|
||||||
|
lineP.setAttribute("itunes:key", `L${++i}`);
|
||||||
|
|
||||||
|
if (line.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (const word of line.words) {
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
lineP.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(line.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(line.endTime));
|
||||||
|
} else if (line.words.length === 1) {
|
||||||
|
const word = line.words[0];
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLine = param[lineIndex + 1];
|
||||||
|
if (nextLine?.isBG) {
|
||||||
|
lineIndex++;
|
||||||
|
const bgLine = nextLine;
|
||||||
|
const bgLineSpan = doc.createElement("span");
|
||||||
|
bgLineSpan.setAttribute("ttm:role", "x-bg");
|
||||||
|
|
||||||
|
if (bgLine.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (
|
||||||
|
let wordIndex = 0;
|
||||||
|
wordIndex < bgLine.words.length;
|
||||||
|
wordIndex++
|
||||||
|
) {
|
||||||
|
const word = bgLine.words[wordIndex];
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
if (wordIndex === 0) {
|
||||||
|
span.prepend(doc.createTextNode("("));
|
||||||
|
} else if (wordIndex === bgLine.words.length - 1) {
|
||||||
|
span.appendChild(doc.createTextNode(")"));
|
||||||
|
}
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
} else if (bgLine.words.length === 1) {
|
||||||
|
const word = bgLine.words[0];
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`));
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.translatedLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.romanLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
lineP.appendChild(bgLineSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(line.translatedLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(line.romanLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
paramDiv.appendChild(lineP);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(paramDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
ttRoot.appendChild(body);
|
||||||
|
|
||||||
|
if (pretty) {
|
||||||
|
const xsltDoc = new DOMParser().parseFromString(
|
||||||
|
[
|
||||||
|
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
||||||
|
' <xsl:strip-space elements="*"/>',
|
||||||
|
' <xsl:template match="para[content-style][not(text())]">',
|
||||||
|
' <xsl:value-of select="normalize-space(.)"/>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:template match="node()|@*">',
|
||||||
|
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:output indent="yes"/>',
|
||||||
|
"</xsl:stylesheet>",
|
||||||
|
].join("\n"),
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
const xsltProcessor = new XSLTProcessor();
|
||||||
|
xsltProcessor.importStylesheet(xsltDoc);
|
||||||
|
const resultDoc = xsltProcessor.transformToDocument(doc);
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(resultDoc);
|
||||||
|
}
|
||||||
|
return new XMLSerializer().serializeToString(doc);
|
||||||
|
}
|
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;
|
||||||
|
}
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="w-full flex my-3">
|
<div class="w-full flex my-3">
|
||||||
<h2>歌词文件</h2>
|
<h2>歌词文件</h2>
|
||||||
<FileSelector accept=".lrc,.ttml" class="ml-auto top-2 relative" />
|
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileList />
|
<FileList />
|
||||||
|
@ -4,20 +4,18 @@
|
|||||||
import Background from '$lib/components/background.svelte';
|
import Background from '$lib/components/background.svelte';
|
||||||
import Cover from '$lib/components/cover.svelte';
|
import Cover from '$lib/components/cover.svelte';
|
||||||
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
||||||
import extractFileName from '$lib/utils/extractFileName';
|
import Lyrics from '$lib/components/lyrics/lyrics.svelte';
|
||||||
|
import extractFileName from '$lib/extractFileName';
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
import lrcParser from '$lib/lyrics/lrc/parser';
|
||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
import { parseTTML } from '$lib/ttml';
|
import { parseTTML, type LyricLine } from '$lib/lyrics/ttml';
|
||||||
import type { LyricLine, LyricLineMouseEvent, LyricPlayer } from '@applemusic-like-lyrics/core';
|
import NewLyrics from '$lib/components/lyrics/newLyrics.svelte';
|
||||||
import NewLyrics from '$lib/components/newLyrics.svelte';
|
|
||||||
import { LyricPlayer as CoreLyricPlayer } from '@applemusic-like-lyrics/core';
|
|
||||||
import lrcParser from '$lib/lyrics/LRCparser';
|
|
||||||
import mapLRCtoAMLL from '$lib/lyrics/LRCtoAMLL';
|
|
||||||
//import { parseLrc } from '@applemusic-like-lyrics/lyric';
|
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
let audioPlayer: HTMLAudioElement | null = null;
|
let audioPlayer: HTMLAudioElement | null = null;
|
||||||
@ -101,20 +99,24 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-lyric`, function (err, file) {
|
localforage.getItem(`${audioId}-lyric`, function (err, file) {
|
||||||
if (!file) return;
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
f.text().then((lr) => {
|
f.text().then((lr) => {
|
||||||
if (f.name.endsWith('.ttml')) {
|
if (f.name.endsWith('.ttml')) {
|
||||||
lyricLines = parseTTML(lr).lyricLines;
|
originalLyrics = parseTTML(lr);
|
||||||
hasLyrics = true;
|
for (const line of originalLyrics.scripts!) {
|
||||||
|
lyricsText.push(line.text);
|
||||||
}
|
}
|
||||||
else if (f.name.endsWith('.lrc')) {
|
|
||||||
const parsed = lrcParser(lr);
|
|
||||||
if (parsed.scripts == undefined) return;
|
|
||||||
lyricLines = lrcParser(lr).scripts!.map(mapLRCtoAMLL);
|
|
||||||
hasLyrics = true;
|
hasLyrics = true;
|
||||||
|
} else if (f.name.endsWith('.lrc')) {
|
||||||
|
originalLyrics = lrcParser(lr);
|
||||||
|
if (!originalLyrics.scripts) return;
|
||||||
|
for (const line of originalLyrics.scripts) {
|
||||||
|
lyricsText.push(line.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,9 +180,6 @@
|
|||||||
if (audioPlayer === null) return;
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
|
||||||
if (audioPlayer === null) return;
|
|
||||||
});
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
@ -217,17 +216,9 @@
|
|||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewLyrics
|
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||||
{lyricPlayer}
|
|
||||||
{lyricLines}
|
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
||||||
currentTime={Math.round(currentProgress * 1000)}
|
|
||||||
playing={!paused}
|
|
||||||
{onLyricLineClick}
|
|
||||||
alignPosition={0.3}
|
|
||||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] md:px-6 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] font-semibold mix-blend-plus-lighter"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioPlayer}
|
bind:this={audioPlayer}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { parseLRC } from '$lib/lyrics/LRCparser';
|
import { parseLRC } from '$lib/lyrics/lrc/parser';
|
||||||
|
|
||||||
describe('LRC parser test', () => {
|
describe('LRC parser test', () => {
|
||||||
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
||||||
|
Loading…
Reference in New Issue
Block a user