Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
afbc82badb | |||
10c5ea3916 | |||
0c315972c1 | |||
d34ac176c0 | |||
![]() |
ba31bc4b98 |
23
Dockerfile
23
Dockerfile
@ -1,23 +0,0 @@
|
|||||||
# Use the official Bun image as the base image
|
|
||||||
FROM oven/bun:latest
|
|
||||||
|
|
||||||
# Set the working directory inside the container
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy the package.json and bun.lockb files to the working directory
|
|
||||||
COPY package.json bun.lockb ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Copy the rest of the application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the app
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# Expose the port the app runs on
|
|
||||||
EXPOSE 4173
|
|
||||||
|
|
||||||
# Command to run the application
|
|
||||||
CMD ["bun", "go"]
|
|
@ -1,15 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
aquavox:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "4173:4173"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
command: ["bun", "go"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
node_modules:
|
|
16
package.json
16
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "2.3.2",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@ -11,7 +11,7 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"go": "PORT=4173 bun ./build"
|
"go": "PORT=4173 node build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/svelte": "^4.0.2",
|
"@iconify/svelte": "^4.0.2",
|
||||||
@ -34,27 +34,15 @@
|
|||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
|
||||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
"@pixi/app": "^7.4.2",
|
|
||||||
"@pixi/core": "^7.4.2",
|
|
||||||
"@pixi/display": "^7.4.2",
|
|
||||||
"@pixi/filter-blur": "^7.4.2",
|
|
||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
|
||||||
"@pixi/filter-color-matrix": "^7.4.2",
|
|
||||||
"@pixi/sprite": "^7.4.2",
|
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
"bezier-easing": "^2.1.0",
|
"bezier-easing": "^2.1.0",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"jotai-svelte": "^0.0.2",
|
"jotai-svelte": "^0.0.2",
|
||||||
"jss": "^10.10.0",
|
|
||||||
"jss-preset-default": "^10.10.0",
|
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lrc-parser-ts": "^1.0.3",
|
"lrc-parser-ts": "^1.0.3",
|
||||||
"music-metadata-browser": "^2.5.10",
|
"music-metadata-browser": "^2.5.10",
|
||||||
|
@ -5,14 +5,14 @@
|
|||||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import formatDuration from '$lib/utils/formatDuration.js';
|
import formatDuration from '$lib/formatDuration';
|
||||||
import { safePath } from '$lib/server/safePath';
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
|
||||||
describe('formatDuration test', () => {
|
describe('formatDuration test', () => {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { processImage } from '$lib/graphics';
|
import { processImage } from '$lib/graphics';
|
||||||
import blobToImageData from '$lib/graphics/blob2imageData';
|
import blobToImageData from '$lib/graphics/blob2imageData';
|
||||||
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
||||||
import localforage from '$lib/utils/storage';
|
import localforage from '$lib/storage';
|
||||||
export let coverId: string;
|
export let coverId: string;
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import formatDuration from "$lib/utils/formatDuration";
|
import formatDuration from "$lib/formatDuration";
|
||||||
import { formatViews } from "$lib/utils/formatViews";
|
import { formatViews } from "$lib/formatViews";
|
||||||
|
|
||||||
export let songData: MusicMetadata;
|
export let songData: MusicMetadata;
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||||
import toHumanSize from '$lib/utils/humanSize';
|
import toHumanSize from '$lib/humanSize';
|
||||||
import formatText from '$lib/utils/formatText';
|
import formatText from '$lib/formatText';
|
||||||
import extractFileName from '$lib/utils/extractFileName';
|
import extractFileName from '$lib/extractFileName';
|
||||||
import getAudioMeta from '$lib/utils/getAudioCoverURL';
|
import getAudioMeta from '$lib/getAudioCoverURL';
|
||||||
import convertCoverData from '$lib/utils/convertCoverData';
|
import convertCoverData from '$lib/convertCoverData';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import formatDuration from '$lib/utils/formatDuration';
|
import formatDuration from '$lib/formatDuration';
|
||||||
const items = useAtom(fileListState);
|
const items = useAtom(fileListState);
|
||||||
const finalItems = useAtom(finalFileListState);
|
const finalItems = useAtom(finalFileListState);
|
||||||
let displayItems: any[] = [];
|
let displayItems: any[] = [];
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import formatDuration from '$lib/utils/formatDuration';
|
import formatDuration from '$lib/formatDuration';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||||
import truncate from '$lib/utils/truncate';
|
import truncate from '$lib/truncate';
|
||||||
|
|
||||||
export let name: string;
|
export let name: string;
|
||||||
export let singer: string = '';
|
export let singer: string = '';
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import type { Spring } from '$lib/graphics/spring/spring';
|
import type { Spring } from '$lib/graphics/spring/spring';
|
||||||
|
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const scaleCurrentLine = viewportWidth > 640 ? 1.02 : 1.045 ;
|
||||||
|
|
||||||
export let line: ScriptItem;
|
export let line: ScriptItem;
|
||||||
export let index: number;
|
export let index: number;
|
||||||
@ -17,6 +18,7 @@
|
|||||||
let time = 0;
|
let time = 0;
|
||||||
let positionX: number = 0;
|
let positionX: number = 0;
|
||||||
let positionY: number = 0;
|
let positionY: number = 0;
|
||||||
|
let scale = 1;
|
||||||
let opacity = 1;
|
let opacity = 1;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let lastPosX: number | undefined = undefined;
|
let lastPosX: number | undefined = undefined;
|
||||||
@ -74,6 +76,7 @@
|
|||||||
export const setCurrent = (isCurrent: boolean) => {
|
export const setCurrent = (isCurrent: boolean) => {
|
||||||
isCurrentLyric = isCurrent;
|
isCurrentLyric = isCurrent;
|
||||||
opacity = isCurrent ? 1 : 0.36;
|
opacity = isCurrent ? 1 : 0.36;
|
||||||
|
scale = isCurrent ? scaleCurrentLine : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setBlur = (blur: number) => {
|
export const setBlur = (blur: number) => {
|
||||||
@ -109,8 +112,8 @@
|
|||||||
lastPosY = pos.y;
|
lastPosY = pos.y;
|
||||||
positionX = pos.x;
|
positionX = pos.x;
|
||||||
positionY = pos.y;
|
positionY = pos.y;
|
||||||
springX = createSpring(pos.x, pos.x, 0.114, 0.72);
|
springX = createSpring(pos.x, pos.x, 0.126, 0.8);
|
||||||
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
|
springY = createSpring(pos.y, pos.y, 0.126, 0.8);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stop = () => {
|
export const stop = () => {
|
||||||
@ -123,36 +126,31 @@
|
|||||||
<!-- 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
|
<div
|
||||||
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: opacity, text-shadow;
|
style="transform: translate3d({positionX}px, {positionY}px, 0); scale: {scale};
|
||||||
transition-duration: 0.36s; transition-timing-function: ease-out; opacity: {opacity};
|
transition-property: scale, opacity; transition-duration: 0.5s; transition-timing-function: ease-in-out; opacity: {opacity};
|
||||||
transform-origin: center left;"
|
transform-origin: center left;"
|
||||||
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
on:touchstart={() => {
|
on:touchstart={() => {
|
||||||
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
clickMask.style.backgroundColor = "rgba(255,255,255,.3)";
|
||||||
}}
|
}}
|
||||||
on:touchend={() => {
|
on:touchend={() => {
|
||||||
clickMask.style.backgroundColor = 'transparent';
|
clickMask.style.backgroundColor = "transparent";
|
||||||
}}
|
}}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
lyricClick(index);
|
lyricClick(index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-2.75rem)] h-full
|
||||||
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] 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}>
|
||||||
-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>
|
</span>
|
||||||
{#if debugMode}
|
{#if debugMode}
|
||||||
<span class="text-lg absolute -translate-y-7">
|
<span class="text-lg absolute -translate-y-7">
|
||||||
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4`}>
|
||||||
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4
|
|
||||||
${isCurrentLyric ? 'text-glow' : ''}`}
|
|
||||||
>
|
|
||||||
{line.text}
|
{line.text}
|
||||||
</span>
|
</span>
|
||||||
{#if line.translation}
|
{#if line.translation}
|
||||||
@ -162,13 +160,3 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.text-glow {
|
|
||||||
text-shadow:
|
|
||||||
0 0 3px #ffffff2c,
|
|
||||||
0 0 6px #ffffff2c,
|
|
||||||
0 15px 30px rgba(0, 0, 0, 0.11),
|
|
||||||
0 5px 15px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -3,8 +3,10 @@
|
|||||||
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/type';
|
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[];
|
||||||
|
@ -8,9 +8,8 @@
|
|||||||
// constants
|
// constants
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
const marginY = viewportWidth > 640 ? 12 : 0 ;
|
const marginY = viewportWidth > 640 ? 36 : 0 ;
|
||||||
const blurRatio = viewportWidth > 640 ? 1 : 1.4;
|
const currentLyrictTop = viewportHeight * 0.02;
|
||||||
const currentLyrictTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
|
|
||||||
const deceleration = 0.95; // Velocity decay factor for inertia
|
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||||
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@ -57,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initLyricTopList() {
|
function initLyricTopList() {
|
||||||
let cumulativeHeight = currentLyrictTop;
|
let cumulativeHeight = 0;
|
||||||
for (let i = 0; i < lyricLines.length; i++) {
|
for (let i = 0; i < lyricLines.length; i++) {
|
||||||
const c = lyricComponents[i];
|
const c = lyricComponents[i];
|
||||||
lyricElements.push(c.getRef());
|
lyricElements.push(c.getRef());
|
||||||
@ -77,17 +76,13 @@
|
|||||||
for (let i = 0; i < lyricElements.length; i++) {
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
const currentLyricComponent = lyricComponents[i];
|
const currentLyricComponent = lyricComponents[i];
|
||||||
let delay = 0;
|
let delay = 0;
|
||||||
if (i < currentLyricIndex) {
|
if (i <= currentLyricIndex) {
|
||||||
delay = 0;
|
delay = 0;
|
||||||
}
|
} else {
|
||||||
else if (i == currentLyricIndex) {
|
delay = 0.013 + Math.min(Math.min(currentLyricDuration, 0.1), 0.075 * (i - currentLyricIndex));
|
||||||
delay = 0.042;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
delay = Math.min(Math.min(currentLyricDuration, 0.6), 0.067 * (i - currentLyricIndex+1.2));
|
|
||||||
}
|
}
|
||||||
const offset = Math.abs(i - currentLyricIndex);
|
const offset = Math.abs(i - currentLyricIndex);
|
||||||
let blurRadius = Math.min(offset * blurRatio, 16);
|
let blurRadius = Math.min(offset * 1.7, 16);
|
||||||
currentLyricComponent.setBlur(blurRadius);
|
currentLyricComponent.setBlur(blurRadius);
|
||||||
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||||
}
|
}
|
||||||
@ -115,7 +110,7 @@
|
|||||||
currentLyricComponent.setY(currentY - deltaY);
|
currentLyricComponent.setY(currentY - deltaY);
|
||||||
}
|
}
|
||||||
scrolling = true;
|
scrolling = true;
|
||||||
if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
//if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||||
scrollingTimeout = setTimeout(() => {
|
scrollingTimeout = setTimeout(() => {
|
||||||
scrolling = false;
|
scrolling = false;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@ -186,7 +181,7 @@
|
|||||||
console.log("computeLayout")
|
console.log("computeLayout")
|
||||||
computeLayout();
|
computeLayout();
|
||||||
}
|
}
|
||||||
if (Math.abs(lastProgress - progress) > 0.5) {
|
if (Math.abs(lastProgress - progress) > 0) {
|
||||||
scrolling = false;
|
scrolling = false;
|
||||||
}
|
}
|
||||||
if (lastProgress - progress > 0) {
|
if (lastProgress - progress > 0) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import * as pjson from "../../../package.json";
|
import * as pjson from "../../package.json";
|
||||||
|
|
||||||
export default function getVersion(){
|
export default function getVersion(){
|
||||||
return pjson.version;
|
return pjson.version;
|
@ -1,25 +0,0 @@
|
|||||||
import BezierEasing from 'bezier-easing';
|
|
||||||
|
|
||||||
export function smoothScrollTo(element: HTMLElement, to: number, duration: number, timingFunction: Function) {
|
|
||||||
const start = element.scrollTop;
|
|
||||||
const change = to - start;
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
function animateScroll(timestamp: number) {
|
|
||||||
const elapsedTime = timestamp - startTime;
|
|
||||||
const progress = Math.min(elapsedTime / duration, 1);
|
|
||||||
const easedProgress = timingFunction(progress, 0.38, 0, 0.24, 0.99);
|
|
||||||
element.scrollTop = start + change * easedProgress;
|
|
||||||
|
|
||||||
if (elapsedTime < duration) {
|
|
||||||
requestAnimationFrame(animateScroll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(animateScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define your custom Bézier curve function
|
|
||||||
export function customBezier(progress: number, p1x: number, p1y: number, p2x: number, p2y: number) {
|
|
||||||
return BezierEasing(p1x, p1y, p2x, p2y)(progress);
|
|
||||||
}
|
|
@ -1,270 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import type { LyricLine } from '@applemusic-like-lyrics/core';
|
|
||||||
import type { ScriptItem } from '$lib/lyrics/LRCparser';
|
|
||||||
|
|
||||||
export default function mapLRCtoAMLL(line: ScriptItem, i: number, lines: ScriptItem[]): LyricLine {
|
|
||||||
return {
|
|
||||||
words: [
|
|
||||||
{
|
|
||||||
word: line.text,
|
|
||||||
startTime: line.start * 1000,
|
|
||||||
endTime: line.end * 1000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
startTime: line.start * 1000,
|
|
||||||
endTime: line.end * 1000,
|
|
||||||
translatedLyric: line.translation ?? "",
|
|
||||||
romanLyric: '',
|
|
||||||
isBG: false,
|
|
||||||
isDuet: false
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./parser";
|
|
||||||
export * from "./writer";
|
|
||||||
export type * from "./ttml-types";
|
|
@ -1,168 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview
|
|
||||||
* 解析 TTML 歌词文档到歌词数组的解析器
|
|
||||||
* 用于解析从 Apple Music 来的歌词文件,且扩展并支持翻译和音译文本。
|
|
||||||
* @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(`时间戳字符串解析失败:${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,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview
|
|
||||||
* 用于将内部歌词数组对象导出成 TTML 格式的模块
|
|
||||||
* 但是可能会有信息会丢失
|
|
||||||
*/
|
|
||||||
|
|
||||||
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("tt");
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import extractFileName from '$lib/utils/extractFileName';
|
import extractFileName from '$lib/extractFileName';
|
||||||
import getVersion from '$lib/utils/getVersion';
|
import getVersion from '$lib/getVersion';
|
||||||
import toHumanSize from '$lib/utils/humanSize';
|
import toHumanSize from '$lib/humanSize';
|
||||||
import localforage from '$lib/utils/storage';
|
import localforage from '$lib/storage';
|
||||||
interface Song {
|
interface Song {
|
||||||
name: string;
|
name: string;
|
||||||
singer?: string;
|
singer?: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { safePath } from '$lib/server/safePath';
|
import { safePath } from '$lib/server/safePath';
|
||||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
import { getCurrentFormattedDateTime } from '$lib/songUpdateTime';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
import { getCurrentFormattedDateTime } from '$lib/songUpdateTime';
|
||||||
let templateSongData: MusicMetadata = {
|
let templateSongData: MusicMetadata = {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import FileList from '$lib/components/import/fileList.svelte';
|
import FileList from '$lib/components/import/fileList.svelte';
|
||||||
import FileSelector from '$lib/components/import/fileSelector.svelte';
|
import FileSelector from '$lib/components/import/fileSelector.svelte';
|
||||||
import localforage from '$lib/utils/storage';
|
import localforage from '$lib/storage';
|
||||||
import { fileListState } from '$lib/state/fileList.state';
|
import { fileListState } from '$lib/state/fileList.state';
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
const fileList = useAtom(fileListState);
|
const fileList = useAtom(fileListState);
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||||
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
import localforage from '$lib/utils/storage';
|
import localforage from '$lib/storage';
|
||||||
import { v1 as uuidv1 } from 'uuid';
|
import { v1 as uuidv1 } from 'uuid';
|
||||||
const fileList = useAtom(fileListState);
|
const fileList = useAtom(fileListState);
|
||||||
const finalFiles = useAtom(finalFileListState);
|
const finalFiles = useAtom(finalFileListState);
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
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 lrcParser from '$lib/lyrics/lrc/parser';
|
||||||
|
@ -2,10 +2,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
||||||
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
||||||
import wasm from 'vite-plugin-wasm';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), wasm()],
|
plugins: [sveltekit()],
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
},
|
},
|
||||||
@ -28,7 +27,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['./package.json']
|
allow: ["./package.json"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user