Compare commits
No commits in common. "dev" and "main" have entirely different histories.
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,7 +1,7 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
|
@ -2,12 +2,9 @@
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.svelte-kit" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/electron/.svelte-kit" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/web/.svelte-kit" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="BunSettings">
|
||||
<option name="bunPath" value="$USER_HOME$/.bun/bin/bun" />
|
||||
</component>
|
||||
</project>
|
@ -1,6 +1,6 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Web Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
|
||||
<option name="SCRIPT_TEXT" value="bun web:dev" />
|
||||
<configuration default="false" name="Run Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
|
||||
<option name="SCRIPT_TEXT" value="bun dev" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
@ -1,6 +0,0 @@
|
||||
*.toml
|
||||
*.yml
|
||||
*.json
|
||||
*.md
|
||||
*.html
|
||||
*.svg
|
13
Dockerfile
13
Dockerfile
@ -4,17 +4,20 @@ FROM oven/bun:latest
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application code
|
||||
COPY . .
|
||||
# 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 web:build
|
||||
RUN bun run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 2611
|
||||
EXPOSE 4173
|
||||
|
||||
# Command to run the application
|
||||
CMD ["bun", "web:deploy"]
|
||||
CMD ["bun", "go"]
|
||||
|
@ -1,2 +0,0 @@
|
||||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
@ -4,12 +4,12 @@ services:
|
||||
aquavox:
|
||||
build: .
|
||||
ports:
|
||||
- "2611:2611"
|
||||
- "4173:4173"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- .:/app
|
||||
command: ["bun", "web:deploy"]
|
||||
command: ["bun", "go"]
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
|
57
package.json
57
package.json
@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "aquavox",
|
||||
"version": "2.9.5",
|
||||
"version": "2.3.2",
|
||||
"private": false,
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"workspaces": ["packages/web", "packages/core"],
|
||||
"scripts": {
|
||||
"electron:dev": "bun --filter 'electron' dev",
|
||||
"web:dev": "bun --filter 'web' dev",
|
||||
"dev": "bun --filter '**' dev",
|
||||
"web:build": "bun --filter 'web' build",
|
||||
"web:deploy": "bun --filter 'web' go"
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"go": "PORT=4173 bun ./build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
@ -28,16 +29,38 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"svelte": "^5.2.2",
|
||||
"svelte": "^4.2.17",
|
||||
"svelte-check": "^3.7.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^2.1.4",
|
||||
"@types/bun": "^1.1.6",
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^7.0.3"
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"svelte-preprocess"
|
||||
]
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||
"@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",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"jotai": "^2.8.0",
|
||||
"jotai-svelte": "^0.0.2",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lrc-parser-ts": "^1.0.3",
|
||||
"music-metadata-browser": "^2.5.10",
|
||||
"node-cache": "^5.1.2",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"typescript-parsec": "^0.3.4",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,135 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
// 可调整的更新频率(毫秒)
|
||||
const UPDATE_INTERVAL = 300;
|
||||
const TIME_WINDOW = 1000 * 15;
|
||||
|
||||
let frameCount = 0;
|
||||
let lastTime = 0;
|
||||
let fps = 0;
|
||||
let frameTimes: number[] = [];
|
||||
let frameTime = 0;
|
||||
let onePercentLow = 0;
|
||||
|
||||
let fpsChart: Chart;
|
||||
let frameTimeChart: Chart;
|
||||
|
||||
let lastFrameTime = 0;
|
||||
|
||||
function updateFPS() {
|
||||
const currentTime = performance.now();
|
||||
const deltaTime = currentTime - lastTime;
|
||||
|
||||
frameTime = currentTime - lastFrameTime;
|
||||
// 计算1% Low FPS
|
||||
const sortedFrameTimes = frameTimes.sort((a, b) => b - a);
|
||||
const onePercentIndex = Math.floor(sortedFrameTimes.length * 0.01);
|
||||
onePercentLow = Math.round(1000 / sortedFrameTimes[onePercentIndex]);
|
||||
if (frameTimeChart) {
|
||||
if ((frameTimeChart.data.labels![0] as number) < Date.now() - 5000) {
|
||||
frameTimeChart.data.labels!.shift();
|
||||
frameTimeChart.data.datasets[0].data.shift();
|
||||
}
|
||||
frameTimeChart.data.labels!.push(Date.now());
|
||||
frameTimeChart.data.datasets[0].data.push(frameTime);
|
||||
}
|
||||
|
||||
if (deltaTime > UPDATE_INTERVAL) {
|
||||
fps = Math.round((frameCount * 1000) / deltaTime);
|
||||
// 更新图表数据
|
||||
if (fpsChart) {
|
||||
if ((fpsChart.data.labels![0] as number) < Date.now() - TIME_WINDOW) {
|
||||
fpsChart.data.labels!.shift();
|
||||
fpsChart.data.datasets[0].data.shift();
|
||||
}
|
||||
fpsChart.data.labels!.push(Date.now());
|
||||
fpsChart.data.datasets[0].data.push(fps);
|
||||
fpsChart.update();
|
||||
}
|
||||
if (frameTimeChart) {
|
||||
frameTimeChart.update();
|
||||
}
|
||||
|
||||
frameCount = 0;
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
frameTimes.push(frameTime);
|
||||
lastFrameTime = performance.now();
|
||||
|
||||
requestAnimationFrame(updateFPS);
|
||||
}
|
||||
|
||||
const createChart = (ctx: CanvasRenderingContext2D, label: string, color: string) => {
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: label,
|
||||
borderColor: color,
|
||||
data: [],
|
||||
pointRadius: 0,
|
||||
borderWidth: label == 'FPS' ? undefined : 2,
|
||||
tension: label == 'FPS' ? 0.1 : 0,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: {
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
ticks: {
|
||||
padding: 0
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'x'
|
||||
},
|
||||
animations: {
|
||||
y: {
|
||||
duration: 0
|
||||
},
|
||||
x: {
|
||||
duration: label == 'FPS' ? undefined : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx1 = (document.getElementById('fpsChart')! as HTMLCanvasElement).getContext('2d')!;
|
||||
const ctx2 = (document.getElementById('frameTimeChart')! as HTMLCanvasElement).getContext('2d')!;
|
||||
|
||||
fpsChart = createChart(ctx1, 'FPS', '#4CAF50');
|
||||
frameTimeChart = createChart(ctx2, 'Frame Time', '#2196F3');
|
||||
updateFPS();
|
||||
});
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<span>{fps} fps</span><br />
|
||||
<span>Frame Time: {frameTime.toFixed(2)} ms</span><br />
|
||||
<span>1% Low: {onePercentLow} fps</span>
|
||||
</p>
|
||||
<span class="fixed right-2 text-white/50">fps</span>
|
||||
<canvas id="fpsChart" width="400" height="100" class="mt-2"></canvas>
|
||||
<span class="fixed right-2 text-white/50">frametime</span>
|
||||
<canvas id="frameTimeChart" width="400" height="100" class="mt-2"></canvas>
|
@ -1,3 +0,0 @@
|
||||
<div class={$$props.class}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21 14a1 1 0 0 0-1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-4a1 1 0 0 0-2 0v4a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4a1 1 0 0 0-1-1m-9.71 1.71a1 1 0 0 0 .33.21a.94.94 0 0 0 .76 0a1 1 0 0 0 .33-.21l4-4a1 1 0 0 0-1.42-1.42L13 12.59V3a1 1 0 0 0-2 0v9.59l-2.29-2.3a1 1 0 1 0-1.42 1.42Z"/></svg>
|
||||
</div>
|
@ -1,309 +0,0 @@
|
||||
<script lang="ts">
|
||||
import createSpring from '@core/graphics/spring';
|
||||
import type { LyricWord, ScriptItem } from '@alikia/aqualyrics';
|
||||
import type { LyricPos } from './type';
|
||||
import type { Spring } from '@core/graphics/spring/spring';
|
||||
import userAdjustingProgress from '@core/state/userAdjustingProgress';
|
||||
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const blurRatio = viewportWidth > 640 ? 1.2 : 1.4;
|
||||
const scrollDuration = 0.2;
|
||||
|
||||
export let line: ScriptItem;
|
||||
export let index: number;
|
||||
export let debugMode: Boolean;
|
||||
export let lyricClick: Function;
|
||||
export let progress: number;
|
||||
export let currentLyricIndex: number;
|
||||
export let scrolling: boolean;
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
let clickMask: HTMLSpanElement;
|
||||
|
||||
let time = 0;
|
||||
let positionX: number = 0;
|
||||
let positionY: number = 0;
|
||||
let blur = 0;
|
||||
let stopped = false;
|
||||
let we_are_scrolling = false;
|
||||
let scrollTarget: number | undefined = undefined;
|
||||
let scrollFrom: number | undefined = undefined;
|
||||
let scrollingStartTime: number | undefined = undefined;
|
||||
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;
|
||||
|
||||
let prevRealY: number | undefined = undefined;
|
||||
let prevRealTime: number | undefined = undefined;
|
||||
let lastRealY: number | undefined = undefined;
|
||||
let lastRealTime: number | undefined = undefined;
|
||||
const INTERPOLATED_RATE = 0; // 可调节的插值间隔(0-1)
|
||||
|
||||
function updateY(timestamp: number) {
|
||||
if (stopped) return;
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
const isRealFrame = Math.random() > INTERPOLATED_RATE;
|
||||
|
||||
if (isRealFrame) {
|
||||
// 真实物理帧处理
|
||||
if (lastUpdateY === undefined) {
|
||||
lastUpdateY = currentTime;
|
||||
}
|
||||
if (springY === undefined) return;
|
||||
|
||||
// 保存前一次的真实帧数据
|
||||
prevRealY = lastRealY;
|
||||
prevRealTime = lastRealTime;
|
||||
|
||||
// 更新物理状态
|
||||
const deltaTime = (currentTime - lastUpdateY) / 1000;
|
||||
springY.update(deltaTime);
|
||||
positionY = springY.getCurrentPosition();
|
||||
|
||||
// 记录当前真实帧数据
|
||||
lastRealY = positionY;
|
||||
lastRealTime = currentTime;
|
||||
lastUpdateY = currentTime;
|
||||
|
||||
// 继续请求动画帧
|
||||
if (!springY?.arrived() && !stopped && !we_are_scrolling) {
|
||||
requestAnimationFrame(updateY);
|
||||
}
|
||||
} else {
|
||||
// 插值帧处理
|
||||
if (lastRealY !== undefined && lastRealTime !== undefined) {
|
||||
const timeSinceLastReal = currentTime - lastRealTime;
|
||||
const deltaT = timeSinceLastReal / 1000;
|
||||
|
||||
// 计算速度(如果有前一次真实帧数据)
|
||||
let velocity = 0;
|
||||
if (prevRealY !== undefined && prevRealTime !== undefined && lastRealTime !== prevRealTime) {
|
||||
velocity = (lastRealY - prevRealY) / ((lastRealTime - prevRealTime) / 1000);
|
||||
}
|
||||
|
||||
positionY = lastRealY + velocity * deltaT;
|
||||
}
|
||||
|
||||
// 无论是否成功插值都保持动画流畅
|
||||
if (!stopped && !we_are_scrolling) {
|
||||
requestAnimationFrame(updateY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateX(timestamp: number) {
|
||||
if (stopped) return;
|
||||
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) => {
|
||||
stopped = true;
|
||||
positionX = pos;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the y position of the element, **with no animation**
|
||||
* @param {number} pos - Y offset, in pixels
|
||||
*/
|
||||
export const setY = (pos: number) => {
|
||||
stopped = true;
|
||||
positionY = pos;
|
||||
};
|
||||
|
||||
$: {
|
||||
if (ref && ref.style) {
|
||||
let blurRadius = 0;
|
||||
const offset = Math.abs(index - currentLyricIndex);
|
||||
if (progress > line.end) {
|
||||
blurRadius = Math.min(offset * blurRatio, 16);
|
||||
} else if (line.start <= progress && progress <= line.end) {
|
||||
blurRadius = 0;
|
||||
} else {
|
||||
blurRadius = Math.min(offset * blurRatio, 16);
|
||||
}
|
||||
if (scrolling) blurRadius = 0;
|
||||
if ($userAdjustingProgress) blurRadius = 0;
|
||||
blur = blurRadius;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function updateScroll(timestamp: number) {
|
||||
const elapsedTime = (new Date().getTime() - scrollingStartTime!) / 1000;
|
||||
const percentage = Math.min(elapsedTime / scrollDuration, 1);
|
||||
positionY = scrollFrom! + (scrollTarget! - scrollFrom!) * percentage;
|
||||
if (percentage < 1) {
|
||||
requestAnimationFrame(updateScroll);
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollTo = (targetY: number) => {
|
||||
scrollFrom = positionY;
|
||||
scrollTarget = targetY;
|
||||
scrollingStartTime = new Date().getTime();
|
||||
we_are_scrolling = true;
|
||||
requestAnimationFrame(updateScroll);
|
||||
springY!.setPosition(targetY);
|
||||
we_are_scrolling = false;
|
||||
};
|
||||
|
||||
export const syncSpringWithDelta = (deltaY: number) => {
|
||||
const target = positionY + deltaY;
|
||||
springY!.setPosition(target);
|
||||
};
|
||||
|
||||
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.114, 0.72);
|
||||
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
|
||||
};
|
||||
|
||||
export const stop = () => {
|
||||
stopped = true;
|
||||
};
|
||||
|
||||
export const getRef = () => ref;
|
||||
|
||||
let processedChars = line.words?.flatMap((word) => {
|
||||
const { startTime, endTime, word: text } = word;
|
||||
const wordDuration = endTime - startTime;
|
||||
return text.split('').map((chr, i) => {
|
||||
const charProgress = startTime + wordDuration * (i / text.length);
|
||||
const charDuration = wordDuration * ((i + 1) / text.length);
|
||||
const transitionDur = charDuration < 0.6 ? null : charDuration / 1.6;
|
||||
return { chr, charProgress, transitionDur };
|
||||
});
|
||||
});
|
||||
|
||||
// 新增:缓存当前行状态
|
||||
$: isActiveLine = line.start <= progress && progress <= line.end;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||
on:click={() => {
|
||||
lyricClick(index);
|
||||
}}
|
||||
on:touchend={() => {
|
||||
clickMask.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
on:touchstart={() => {
|
||||
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
||||
}}
|
||||
style="transform: translate3d({positionX}px, {positionY}px, 0);
|
||||
transform-origin: center left; font-family: LyricFont, sans-serif; filter: blur({blur}px)"
|
||||
>
|
||||
<span
|
||||
bind:this={clickMask}
|
||||
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)] z-[100]"
|
||||
>
|
||||
</span>
|
||||
{#if debugMode}
|
||||
<span class="text-white text-lg absolute -translate-y-7">
|
||||
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.words !== undefined && line.words.length > 0}
|
||||
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 `}>
|
||||
{#each processedChars as char (char.charProgress)}
|
||||
{@const isHighlighted = line.start <= progress && progress <= line.end && progress > char.charProgress}
|
||||
{@const useCustomTransition = char.transitionDur !== null && isActiveLine}
|
||||
|
||||
<span
|
||||
class="inline-block {isHighlighted ? 'opacity-100 text-glow' : 'opacity-35'}"
|
||||
style={useCustomTransition
|
||||
? `transition-duration: ${char.transitionDur}s;`
|
||||
: 'transition-duration: 200ms;'}
|
||||
>
|
||||
{char.chr}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 duration-200
|
||||
${line.start <= progress && progress <= line.end ? 'opacity-100 text-glow' : 'opacity-35'}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if line.translation}
|
||||
<br />
|
||||
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300 text-white`}>
|
||||
{line.translation}
|
||||
</span>
|
||||
{/if}
|
||||
</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);
|
||||
}
|
||||
/* 预定义过渡类 */
|
||||
.char-transition {
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.fast-transition {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.custom-transition {
|
||||
transition-duration: var(--custom-duration);
|
||||
}
|
||||
</style>
|
@ -1,314 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type LyricData, type ScriptItem } from '@alikia/aqualyrics';
|
||||
import { onMount } from 'svelte';
|
||||
import LyricLine from './lyricLine.svelte';
|
||||
import createLyricsSearcher from '@core/lyrics/lyricSearcher';
|
||||
import userAdjustingProgress from '@core/state/userAdjustingProgress';
|
||||
import DisplayFps from '../displayFPS.svelte';
|
||||
|
||||
// constants
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const marginY = viewportWidth > 640 ? 12 : 0;
|
||||
const blurRatio = viewportWidth > 640 ? 1 : 1.4;
|
||||
const currentLyricTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
|
||||
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||
const minVelocity = 0.001; // Minimum velocity to stop inertia
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Props
|
||||
let {
|
||||
originalLyrics,
|
||||
progress,
|
||||
player,
|
||||
showInteractiveBox
|
||||
}: {
|
||||
originalLyrics: LyricData;
|
||||
progress: number;
|
||||
player: HTMLAudioElement | null;
|
||||
showInteractiveBox: boolean;
|
||||
} = $props();
|
||||
|
||||
// States
|
||||
let lyricLines: ScriptItem[] = $state([]);
|
||||
let lyricsContainer: HTMLDivElement | null = $state(null);
|
||||
let debugMode = $state(false);
|
||||
let nextUpdate = $state(0);
|
||||
let lastProgress = $state(0);
|
||||
let showTranslation = $state(false);
|
||||
let scrollEventAdded = $state(false);
|
||||
let scrolling = $state(false);
|
||||
let scrollingTimeout: Timer | null = $state(null);
|
||||
let lastY: number = $state(0); // For tracking touch movements
|
||||
let lastTime: number = $state(0); // For tracking time between touch moves
|
||||
let velocityY = $state(0); // Vertical scroll velocity
|
||||
let inertiaFrame: number = $state(0); // For storing the requestAnimationFrame reference
|
||||
let inertiaFrameCount: number = $state(0);
|
||||
|
||||
// References to lyric elements
|
||||
let lyricElements: HTMLDivElement[] = $state([]);
|
||||
let lyricComponents: LyricLine[] = $state([]);
|
||||
let lyricTopList: number[] = $state([]);
|
||||
|
||||
let getLyricIndex = $derived(createLyricsSearcher(originalLyrics));
|
||||
let currentLyricIndex = $derived(getLyricIndex(progress));
|
||||
|
||||
function initLyricComponents() {
|
||||
initLyricTopList();
|
||||
for (let i = 0; i < lyricComponents.length; i++) {
|
||||
const currentLyric = lyricComponents[i];
|
||||
currentLyric.init({ x: 0, y: lyricTopList[i] });
|
||||
}
|
||||
}
|
||||
|
||||
function initLyricTopList() {
|
||||
let cumulativeHeight = currentLyricTop;
|
||||
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] - currentLyricTop;
|
||||
for (let i = 0; i < lyricElements.length; i++) {
|
||||
const currentLyricComponent = lyricComponents[i];
|
||||
const lyric = originalLyrics.scripts[i];
|
||||
const lyricBeforeProgress = lyric.end < progress;
|
||||
const lyricInProgress = lyric.start <= progress && progress <= lyric.end;
|
||||
const lyricWillHigherThanViewport = lyricTopList[i + 3] - relativeOrigin < 0;
|
||||
const lyricWillLowerThanViewport = lyricTopList[i - 3] - relativeOrigin > lyricsContainer?.getBoundingClientRect().height!;
|
||||
|
||||
let delay = 0;
|
||||
if (lyricBeforeProgress) {
|
||||
delay = 0;
|
||||
} else if (lyricInProgress) {
|
||||
delay = 0.03;
|
||||
} else {
|
||||
delay = Math.min(Math.min(currentLyricDuration, 0.6), 0.067 * (i - currentLyricIndex + 1.2));
|
||||
}
|
||||
|
||||
// if it's not in the viewport, we need to use animations
|
||||
if (lyricWillHigherThanViewport || lyricWillLowerThanViewport) {
|
||||
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||
}
|
||||
// if it's still in the viewport, we need to use spring animation
|
||||
else {
|
||||
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seekForward() {
|
||||
if (!originalLyrics.scripts) return;
|
||||
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyricTop;
|
||||
for (let i = 0; i < lyricElements.length; i++) {
|
||||
const currentLyricComponent = lyricComponents[i];
|
||||
currentLyricComponent.scrollTo(lyricTopList[i] - relativeOrigin);
|
||||
}
|
||||
lastSeekForward = new Date().getTime();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!originalLyrics || !originalLyrics.scripts) return;
|
||||
lyricLines = originalLyrics.scripts!;
|
||||
});
|
||||
let initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (lyricComponents.length > 0 && !initialized) {
|
||||
initLyricComponents();
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll(deltaY: number) {
|
||||
if (lyricComponents[0].getInfo().y > 0 && deltaY < 0) {
|
||||
deltaY = 0;
|
||||
}
|
||||
if (lyricComponents[lyricComponents.length - 1].getInfo().y < 100 && deltaY > 0) {
|
||||
deltaY = 0;
|
||||
}
|
||||
for (let i = 0; i < lyricElements.length; i++) {
|
||||
const currentLyricComponent = lyricComponents[i];
|
||||
const currentY = currentLyricComponent.getInfo().y;
|
||||
scrolling = true;
|
||||
currentLyricComponent.setY(currentY - deltaY);
|
||||
currentLyricComponent.syncSpringWithDelta(deltaY);
|
||||
}
|
||||
scrolling = true;
|
||||
if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||
scrollingTimeout = setTimeout(() => {
|
||||
scrolling = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!lyricsContainer || scrollEventAdded) return;
|
||||
// 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;
|
||||
});
|
||||
|
||||
let lastEventLyricIndex = $state(0);
|
||||
let lastEventProgress = $state(0);
|
||||
let lastSeekForward = $state(0);
|
||||
$effect(() => {
|
||||
const progressDelta = progress - lastEventProgress;
|
||||
const deltaInRange = 0 <= progressDelta && progressDelta <= 0.15;
|
||||
const deltaTooBig = progressDelta > 0.15;
|
||||
const deltaIsNegative = progressDelta < 0;
|
||||
const lyricChanged = currentLyricIndex !== lastEventLyricIndex;
|
||||
const lyricIndexDeltaTooBig = Math.abs(currentLyricIndex - lastEventLyricIndex) > 1;
|
||||
|
||||
lastEventLyricIndex = currentLyricIndex;
|
||||
lastEventProgress = progress;
|
||||
if (!lyricChanged || scrolling) return;
|
||||
if (!lyricIndexDeltaTooBig && deltaInRange) {
|
||||
console.log('Event: regular move');
|
||||
console.log(new Date().getTime(), lastSeekForward);
|
||||
computeLayout();
|
||||
} else if ($userAdjustingProgress) {
|
||||
if (deltaTooBig && lyricChanged) {
|
||||
console.log('Event: seek forward');
|
||||
seekForward();
|
||||
} else if (deltaIsNegative && lyricChanged) {
|
||||
console.log('Event: seek backward');
|
||||
seekForward();
|
||||
}
|
||||
} else {
|
||||
console.log('Event: regular move');
|
||||
computeLayout();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
userAdjustingProgress.set(false);
|
||||
player.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
{#if debugMode}
|
||||
<span
|
||||
class="text-white text-lg absolute z-50 px-2 py-0.5 m-2 rounded-3xl bg-white bg-opacity-20 backdrop-blur-lg
|
||||
right-0 font-mono"
|
||||
>
|
||||
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex},
|
||||
uap: {$userAdjustingProgress}
|
||||
</span>
|
||||
<!-- <div
|
||||
class="text-black/80 text-sm absolute z-50 px-3 py-2 m-2 rounded-lg bg-white/30 backdrop-blur-xl
|
||||
left-0 font-mono"
|
||||
>
|
||||
<DisplayFps />
|
||||
</div> -->
|
||||
{/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 duration-500
|
||||
${showInteractiveBox ? 'h-[calc(100vh-21rem)]' : 'h-[calc(100vh-7rem)]'}
|
||||
lg:px-[7.5rem] xl:left-[46vw] xl:px-[3vw] xl:h-screen font-sans
|
||||
text-left no-scrollbar z-[1] pt-16 overflow-hidden`}
|
||||
style={`mask: linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 7%, rgba(0, 0, 0, 1) 95%,
|
||||
rgba(0, 0, 0, 0) 100%);`}
|
||||
bind:this={lyricsContainer}
|
||||
>
|
||||
{#each lyricLines as lyric, i}
|
||||
<LyricLine
|
||||
line={lyric}
|
||||
index={i}
|
||||
bind:this={lyricComponents[i]}
|
||||
{debugMode}
|
||||
{lyricClick}
|
||||
{progress}
|
||||
{currentLyricIndex}
|
||||
{scrolling}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@sveltejs/adapter-auto": "^3.2.0",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.5.9",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.39.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"svelte": "^5.2.2",
|
||||
"svelte-check": "^3.7.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "5.4.6",
|
||||
"vite-plugin-wasm": "^3.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@types/bun": "^1.1.6",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"jotai": "^2.8.0",
|
||||
"jotai-svelte": "^0.0.2",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lrc-parser-ts": "^1.0.3",
|
||||
"music-metadata-browser": "^2.5.10",
|
||||
"node-cache": "^5.1.2",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import formatDuration from '@core/utils/formatDuration';
|
||||
|
||||
describe('formatDuration test', () => {
|
||||
it('converts 120 seconds to "2:00"', () => {
|
||||
expect(formatDuration(120)).toBe('2:00');
|
||||
});
|
||||
|
||||
it('converts 119.935429 seconds to "1:59"', () => {
|
||||
expect(formatDuration(119.935429)).toBe('1:59');
|
||||
});
|
||||
|
||||
it('converts 185 seconds to "3:05"', () => {
|
||||
expect(formatDuration(185)).toBe('3:05');
|
||||
});
|
||||
|
||||
it('converts 601 seconds to "10:01"', () => {
|
||||
expect(formatDuration(601)).toBe('10:01');
|
||||
});
|
||||
|
||||
it('converts 3601 seconds to "1:00:01"', () => {
|
||||
expect(formatDuration(3601)).toBe('1:00:01');
|
||||
});
|
||||
});
|
@ -1,20 +0,0 @@
|
||||
{
|
||||
"extends": ["../../tsconfig.base.json"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["./*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"types": ["bun"],
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export default function timestamp() {
|
||||
const ts = new Date().getTime();
|
||||
return ts;
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
# AquaVox - 洛水之音
|
||||
|
||||
AquaVox 是一个为中文虚拟歌手爱好者献上的产品。
|
||||
|
||||
> VOCALOID IS ALIVE.
|
||||
|
||||
这是一个 **开源、本地优先、有 B 站良好支持、界面美观优雅** 的 **音乐播放器**。
|
||||
|
||||
## 使用
|
||||
|
||||
当前 AquaVox 的公开预览版位于 [aquavox.app](https://aquavox.app) 上。
|
||||
|
||||
[](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55)
|
||||
|
||||
## 项目起源
|
||||
|
||||
**开发者寒寒的话:**
|
||||
|
||||
AquaVox 这个播放器项目的灵感根本上来自于 Apple Music,以其优雅流畅的界面设计而著称。
|
||||
|
||||
我的中 V 曲之前基本是在 B 站听的,而中文虚拟歌手社区也基本上扎根于 B 站。但在 B 站听歌也有不少劣势——
|
||||
首先自然是 B 站本身并不是一个音乐软件或播放器,自然在相关的功能上并没有过多开发,“听视频”的功能也仅在移动端可用;
|
||||
其次,不少人听歌还是有看歌词的需求的,而许多中 V 曲发布时附上的 PV 中的歌词,为了美观,歌词并不会以一个常规视频的字幕样式呈现,而是采用了美术字等更为艺术化的表现形式,这使得查看歌词并不方便,更不用说在“听视频”模式下 PV 根本没有播放的情况。
|
||||
|
||||
而后来,我选择了通过将 B 站的歌曲导入到 Apple Music 的方案,而为什么选择 AM 呢?尽管很多中 V 曲在网易云等平台上有更丰富的资源,但我依然选择了 AM。很大一个原因自然是我对 AM 播放器界面和交互设计及用户体验的喜爱,但另一方面,网易云的中 V 曲库其实也并不算全,最终还要使用导入的方法才能将自己所有喜欢的歌囊括其中,那么既然要导入,何不直接将歌曲全部导入 AM 呢?
|
||||
|
||||
但很快,我也发现了 AM 的一个致命问题:自行导入的歌曲没有动态歌词的功能,只能以一个静态的模式查看全部的歌词。而动态歌词的漂亮设计是我很大一部分喜欢 Apple Music 的原因,但我自己导入的歌曲却无法享受这个功能,不是很令人失望吗?
|
||||
|
||||
因此,最后,我还是最终决定自行开发一个播放器,加上所有我喜欢的东西——Apple Music 的页面设计和交互、从 B 站直接获取的曲库、通过网页、PWA 和 Electron 使全平台有一致的体验。
|
||||
|
||||
## “赠品”
|
||||
|
||||
**开发者寒寒的话:**
|
||||
在熟虑后,我决定让 AquaVox 不仅是一个播放器。更进一步,我希望它是一个属于整个中文虚拟歌手社区的数据库。从音源、作者(P 主及 staff)、虚拟歌手、歌曲元信息、动态歌词,在整个链路上成为一个中文虚拟歌手的终极“Archive”。
|
||||
|
||||
因此,我们需要你的帮助。
|
||||
|
||||
> 声明:AquaVox 并不是音乐(流媒体)平台,官方并未提供任何音源的分发和(或)售卖,也不存在其它形式的任何盈利行为。
|
@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"go": "PORT=2611 bun ./build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/adapter-node": "^5.2.9",
|
||||
"@sveltejs/kit": "^2.8.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@types/eslint": "^8.56.12",
|
||||
"@types/node": "^20.17.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.8",
|
||||
"svelte": "^5.2.2",
|
||||
"svelte-check": "^3.8.6",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "5.4.6",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@types/bun": "^1.1.13",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"jotai": "^2.10.2",
|
||||
"jotai-svelte": "^0.0.2",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lrc-parser-ts": "^1.0.3",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"node-cache": "^5.1.2",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.png">
|
||||
<title>AquaVox</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
@ -1,10 +0,0 @@
|
||||
<script>
|
||||
import '../../../app.css';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-fit min-h-screen w-screen max-w-[100vw] overflow-hidden
|
||||
bg-zinc-50 dark:bg-[#1f1f1f] text-black dark:text-white relative z-0" style="font-family: 'Barlow', sans-serif;"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
Binary file not shown.
Before Width: | Height: | Size: 297 KiB |
Binary file not shown.
@ -1 +0,0 @@
|
||||
<svg width="255" xmlns="http://www.w3.org/2000/svg" height="259" id="screenshot-d13e393b-6a61-8076-8005-51e8b692555d" viewBox="-0 0 255 259" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g xmlns:xlink="http://www.w3.org/1999/xlink" width="189.875" height="259.125" id="shape-d13e393b-6a61-8076-8005-51e8b692555d" data-testid="pause" style="fill: rgb(0, 0, 0);" ry="0" rx="0" version="1.1"><g id="shape-d13e393b-6a61-8076-8005-51e8b692d284" data-testid="base-background" style="display: none;"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b692d284"><rect width="255" height="259" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-d13e393b-6a61-8076-8005-51e8b6933311" data-testid="svg-g" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-d13e393b-6a61-8076-8005-51e8b6933312" data-testid="svg-rect" style="opacity: 0;"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b6933312"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="215.19166666666672" height="259"/></g></g><g id="shape-d13e393b-6a61-8076-8005-51e8b693b101" data-testid="svg-path"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b693b101"><path d="M22.667,258.875L63.467,258.875C78.625,258.875,86.133,252.253,86.133,238.885L86.133,19.990C86.133,6.247,78.625,0.000,63.467,0.000L22.667,0.000C7.508,0.000,-0.000,6.747,-0.000,19.990L-0.000,238.885C-0.000,252.253,7.508,258.875,22.667,258.875ZL22.667,258.875ZM191.533,258.875L232.333,258.875C247.492,258.875,255.000,252.253,255.000,238.885L255.000,19.990C255.000,6.247,247.492,0.000,232.333,0.000L191.533,0.000C176.375,0.000,168.867,6.747,168.867,19.990L168.867,238.885C168.867,252.253,176.375,258.875,191.533,258.875ZL191.533,258.875Z" style="fill: rgb(255, 255, 255); fill-opacity: 0.85;"/></g></g></g></g></svg>
|
Before Width: | Height: | Size: 1.9 KiB |
@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}', '../../packages/core/components/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
{
|
||||
"extends": ["./.svelte-kit/tsconfig.json", "../../tsconfig.base.json"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["../../packages/core/*"],
|
||||
"$lib/*": ["./src/lib/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun"]
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib, which is handled by https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
@ -26,15 +26,13 @@ h2 {
|
||||
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
.text-shadow-none {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'LyricFont';
|
||||
src: url('/font.otf') format('opentype');
|
||||
font-weight: 600; /* Semibold weight */
|
||||
body,
|
||||
html {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
18
src/app.html
Normal file
18
src/app.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</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>
|
@ -1,5 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safePath } from '@core/server/safePath.js';
|
||||
import formatDuration from '$lib/utils/formatDuration.js';
|
||||
import { safePath } from '$lib/server/safePath';
|
||||
|
||||
describe('formatDuration test', () => {
|
||||
it('converts 120 seconds to "2:00"', () => {
|
||||
expect(formatDuration(120)).toBe('2:00');
|
||||
});
|
||||
|
||||
it('converts 119.935429 seconds to "1:59"', () => {
|
||||
expect(formatDuration(119.935429)).toBe('1:59');
|
||||
});
|
||||
|
||||
it('converts 185 seconds to "3:05"', () => {
|
||||
expect(formatDuration(185)).toBe('3:05');
|
||||
});
|
||||
|
||||
it('converts 601 seconds to "10:01"', () => {
|
||||
expect(formatDuration(601)).toBe('10:01');
|
||||
});
|
||||
|
||||
it('converts 3601 seconds to "1:00:01"', () => {
|
||||
expect(formatDuration(3601)).toBe('1:00:01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safePath test', () => {
|
||||
const base = "data/subdir";
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { processImage } from '@core/graphics';
|
||||
import blobToImageData from '@core/graphics/blob2imageData';
|
||||
import imageDataToBlob from '@core/graphics/imageData2blob';
|
||||
import localforage from '@core/utils/storage';
|
||||
import { processImage } from '$lib/graphics';
|
||||
import blobToImageData from '$lib/graphics/blob2imageData';
|
||||
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
||||
import localforage from '$lib/utils/storage';
|
||||
export let coverId: string;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas {
|
||||
position: relative;
|
||||
@ -54,6 +55,6 @@
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: .45s;
|
||||
filter: saturate(1.2);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
</style>
|
@ -11,17 +11,14 @@
|
||||
|
||||
{#if hasLyrics}
|
||||
<img
|
||||
class="absolute shadow-md select-none z-10 object-cover rounded-lg md:rounded-2xl max-md:h-20 max-xl:h-32 max-xl:top-6
|
||||
md:max-h-[calc(94vh-20rem)] max-md:w-20 xl:w-auto max-w-[90%] xl:max-w-[37vw]
|
||||
md:bottom-[21rem] left-6 md:left-[calc(7vw-1rem)] lg:left-[calc(12vw-1rem)] xl:translate-x-[-50%] xl:left-[25vw]"
|
||||
class="absolute shadow-md select-none z-10 object-cover rounded-lg md:rounded-2xl max-md:h-20 max-xl:h-32 max-xl:top-6 md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] xl:max-w-[37vw]
|
||||
md:bottom-80 left-6 md:left-[calc(7vw-1rem)] lg:left-[calc(12vw-1rem)] xl:translate-x-[-50%] xl:left-[25vw]"
|
||||
src={path}
|
||||
width="1200"
|
||||
alt="封面"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="absolute shadow-md select-none z-10 object-cover rounded-2xl max-h-[calc(94vh-18rem)]
|
||||
md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] md:max-w-[75%] xl:max-w-[37vw]
|
||||
class="absolute shadow-md select-none z-10 object-cover rounded-2xl max-h-[calc(94vh-18rem)] md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] md:max-w-[75%] xl:max-w-[37vw]
|
||||
bottom-72 md:bottom-80 left-1/2 translate-x-[-50%]"
|
||||
src={path}
|
||||
alt="封面"
|
@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import formatDuration from "@core/utils/formatDuration";
|
||||
import { formatViews } from "@core/utils/formatViews";
|
||||
import { type MusicMetadata } from '@core/server/database/musicInfo';
|
||||
import formatDuration from "$lib/utils/formatDuration";
|
||||
import { formatViews } from "$lib/utils/formatViews";
|
||||
|
||||
export let songData: MusicMetadata;
|
||||
</script>
|
@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
import { fileListState, finalFileListState } from '@core/state/fileList.state';
|
||||
import toHumanSize from '@core/utils/humanSize';
|
||||
import formatText from '@core/utils/formatText';
|
||||
import extractFileName from '@core/utils/extractFileName';
|
||||
import getAudioMeta from '@core/utils/getAudioCoverURL';
|
||||
import convertCoverData from '@core/utils/convertCoverData';
|
||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||
import toHumanSize from '$lib/utils/humanSize';
|
||||
import formatText from '$lib/utils/formatText';
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import getAudioMeta from '$lib/utils/getAudioCoverURL';
|
||||
import convertCoverData from '$lib/utils/convertCoverData';
|
||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||
import formatDuration from '@core/utils/formatDuration';
|
||||
import formatDuration from '$lib/utils/formatDuration';
|
||||
const items = useAtom(fileListState);
|
||||
const finalItems = useAtom(finalFileListState);
|
||||
let displayItems: any[] = [];
|
@ -3,7 +3,7 @@
|
||||
import ImportIcon from './importIcon.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
import { fileListState } from '@core/state/fileList.state';
|
||||
import { fileListState } from '$lib/state/fileList.state';
|
||||
import AddIcon from './addIcon.svelte';
|
||||
const fileItems = useAtom(fileListState);
|
||||
export let accept: string = ".aac, .mp3, .wav, .ogg, .flac";
|
||||
@ -35,7 +35,7 @@
|
||||
{#if $fileItems.length > 0}
|
||||
<AddIcon class="z-[1] relative text-3xl" />
|
||||
{:else}
|
||||
<ImportIcon class="z-[1] relative text-4xl text-blue-500" />
|
||||
<ImportIcon class="z-[1] relative text-4xl" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
15
src/lib/components/import/importIcon.svelte
Normal file
15
src/lib/components/import/importIcon.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<div class={$$props.class}>
|
||||
<svg width="1em" xmlns="http://www.w3.org/2000/svg" height="1em" fill="none" viewBox="0 0 12 17.2"
|
||||
><g style="mix-blend-mode: darken; fill: rgb(0, 0, 0);"
|
||||
><path
|
||||
d="M12.935 6.131v7.701c0 1.532-.636 2.168-2.169 2.168H2.168C.636 16 0 15.364 0 13.832V6.131c0-1.495.636-2.168 2.168-2.168h1.869v1.121H2.168c-.785 0-1.121.337-1.121 1.047v7.701c0 .822.336 1.159 1.121 1.159h8.598c.711 0 1.047-.337 1.047-1.159V6.131c0-.71-.336-1.047-1.047-1.047H8.897V3.963l1.889-.006c1.513.006 2.149.641 2.149 2.174Z"
|
||||
style="fill: rgb(0, 117, 255); fill-opacity: 1;"
|
||||
class="fills"
|
||||
/><path
|
||||
d="M6.505 11.607c.127 0 .205-.093.336-.205l3.028-2.991a.459.459 0 0 0 .112-.299c.019-.374-.223-.561-.523-.561-.206 0-.262.075-.299.113L7.178 9.645V.673C7.178.336 6.841 0 6.505 0c-.337 0-.673.336-.673.673v8.972L3.85 7.664a.404.404 0 0 0-.299-.113c-.266 0-.542.187-.523.561.008.169.056.243.112.299l3.028 2.991c.131.112.209.205.337.205Z"
|
||||
style="fill: rgb(0, 117, 255); fill-opacity: 1;"
|
||||
class="fills"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
</div>
|
@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import formatDuration from '../utils/formatDuration';
|
||||
import formatDuration from '$lib/utils/formatDuration';
|
||||
import { onMount } from 'svelte';
|
||||
import userAdjustingProgress from '../state/userAdjustingProgress';
|
||||
import progressBarSlideValue from '../state/progressBarSlideValue';
|
||||
import truncate from '../utils/truncate';
|
||||
import timestamp from '@core/utils/getCurrentTimestamp';
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||
import truncate from '$lib/utils/truncate';
|
||||
|
||||
export let name: string;
|
||||
export let singer: string = '';
|
||||
@ -19,9 +18,6 @@
|
||||
|
||||
export let hasLyrics: boolean;
|
||||
|
||||
export let showInteractiveBox: boolean;
|
||||
export let showingInteractiveBoxUntil: Function;
|
||||
|
||||
let progressBar: HTMLDivElement;
|
||||
let volumeBar: HTMLDivElement;
|
||||
let showInfoTop: boolean = false;
|
||||
@ -29,12 +25,6 @@
|
||||
let songInfoTopContainer: HTMLDivElement;
|
||||
let songInfoTopContent: HTMLSpanElement;
|
||||
let userAdjustingVolume = false;
|
||||
let lastTouchClientX = 0;
|
||||
let mobileDeviceAdjustingProgress = false;
|
||||
|
||||
if (screen.width < 728) {
|
||||
showingInteractiveBoxUntil(timestamp() + 3000);
|
||||
}
|
||||
|
||||
const mql = window.matchMedia('(max-width: 1280px)');
|
||||
|
||||
@ -78,47 +68,6 @@
|
||||
$: {
|
||||
showInfoTop = mql.matches && hasLyrics;
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", (event) => {
|
||||
if ($userAdjustingProgress) {
|
||||
const x = event.clientX;
|
||||
const rec = progressBar.getBoundingClientRect();
|
||||
adjustDisplayProgress(truncate((x - rec.left) / rec.width,0,1));
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", (event) => {
|
||||
if ($userAdjustingProgress) {
|
||||
const x = event.clientX;
|
||||
const rec = progressBar.getBoundingClientRect();
|
||||
userAdjustingProgress.set(false);
|
||||
adjustProgress(truncate((x - rec.left) / rec.width,0,1));
|
||||
}
|
||||
});
|
||||
window.addEventListener("touchmove", (event) => {
|
||||
if ($userAdjustingProgress) {
|
||||
const x = event.touches[0].clientX;
|
||||
const rec = progressBar.getBoundingClientRect();
|
||||
adjustDisplayProgress(truncate((x - rec.left) / rec.width,0,1));
|
||||
lastTouchClientX = x;
|
||||
}
|
||||
});
|
||||
window.addEventListener("touchend", (event) => {
|
||||
if ($userAdjustingProgress) {
|
||||
const x = lastTouchClientX;
|
||||
const rec = progressBar.getBoundingClientRect();
|
||||
adjustProgress(truncate((x - rec.left) / rec.width,0,1));
|
||||
userAdjustingProgress.set(false);
|
||||
mobileDeviceAdjustingProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
userAdjustingProgress.subscribe(()=> {
|
||||
showingInteractiveBoxUntil(timestamp() + 5000);
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
showingInteractiveBoxUntil(timestamp() + 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showInfoTop}
|
||||
@ -129,14 +78,11 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={'absolute select-none bottom-12 h-60 w-[86vw] left-[7vw] duration-500 z-10 transition-[opacity,transform] ' +
|
||||
class={'absolute select-none bottom-2 h-60 w-[86vw] left-[7vw] z-10 ' +
|
||||
(hasLyrics
|
||||
? 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[7vw]'
|
||||
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]') + ' ' +
|
||||
(showInteractiveBox ? 'opacity-100' : 'opacity-0 translate-y-48')}
|
||||
style={`z-index: ${showInteractiveBox ? "0" : "50"}`}
|
||||
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]')}
|
||||
>
|
||||
|
||||
{#if !showInfoTop}
|
||||
<div class="song-info">
|
||||
<div class="song-info-regular {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
|
||||
@ -149,13 +95,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class={"absolute w-full h-3/4 bottom-0" + (showInteractiveBox ? '' : '-translate-y-48')}
|
||||
style={`z-index: ${showInteractiveBox ? "0" : "50"}`}
|
||||
on:click={handleClick}
|
||||
></div>
|
||||
|
||||
<div class="progress top-16">
|
||||
<div class="time-indicator text-shadow-md time-current">
|
||||
{formatDuration(progress)}
|
||||
@ -165,23 +104,31 @@
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={progress}
|
||||
bind:this={progressBar}
|
||||
class="progress-bar shadow-md {mobileDeviceAdjustingProgress && '!h-[0.7rem]'}"
|
||||
class="progress-bar shadow-md"
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:click={(e) => {
|
||||
progressBarOnClick(e);
|
||||
}}
|
||||
on:mousedown={() => {
|
||||
userAdjustingProgress.set(true);
|
||||
}}
|
||||
on:touchstart={() => {
|
||||
userAdjustingProgress.set(true);
|
||||
mobileDeviceAdjustingProgress = true;
|
||||
on:mousemove={(e) => {
|
||||
if ($userAdjustingProgress) {
|
||||
adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||
}
|
||||
}}
|
||||
on:mouseup={(e) => {
|
||||
const offsetX = e.offsetX;
|
||||
progressBarOnClick(e);
|
||||
// Q: why it needs delay?
|
||||
// A: I do not know.
|
||||
setTimeout(()=> {
|
||||
userAdjustingProgress.set(false);
|
||||
progressBarMouseUp(offsetX);
|
||||
}, 50);
|
||||
}}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="bar {mobileDeviceAdjustingProgress && '!h-[0.7rem]'}" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||
</div>
|
||||
|
||||
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
||||
@ -388,7 +335,7 @@
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar:active {
|
||||
.progress-bar:hover {
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
@ -402,7 +349,7 @@
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar:active .bar {
|
||||
.progress-bar:hover .bar {
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
@ -428,11 +375,5 @@
|
||||
.control-btn {
|
||||
transition: 0.1s
|
||||
}
|
||||
.progress-bar:hover {
|
||||
height: 0.7rem;
|
||||
}
|
||||
.progress-bar:hover .bar {
|
||||
height: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
174
src/lib/components/lyrics/lyricLine.svelte
Normal file
174
src/lib/components/lyrics/lyricLine.svelte
Normal file
@ -0,0 +1,174 @@
|
||||
<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;
|
||||
|
||||
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 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;
|
||||
};
|
||||
|
||||
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.114, 0.72);
|
||||
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
|
||||
};
|
||||
|
||||
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); transition-property: opacity, text-shadow;
|
||||
transition-duration: 0.36s; transition-timing-function: ease-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%-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}
|
||||
>
|
||||
</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
|
||||
${isCurrentLyric ? 'text-glow' : ''}`}
|
||||
>
|
||||
{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>
|
||||
|
||||
<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>
|
466
src/lib/components/lyrics/lyrics.svelte
Normal file
466
src/lib/components/lyrics/lyrics.svelte
Normal file
@ -0,0 +1,466 @@
|
||||
<script lang="ts">
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import nextUpdate from '$lib/state/nextUpdate';
|
||||
import truncate from '$lib/utils/truncate';
|
||||
|
||||
// Component input properties
|
||||
export let lyrics: string[];
|
||||
export let originalLyrics: LrcJsonData;
|
||||
export let progress: number;
|
||||
export let player: HTMLAudioElement | null;
|
||||
|
||||
// Local state and variables
|
||||
let getLyricIndex: Function;
|
||||
let debugMode = false;
|
||||
let showTranslation = false;
|
||||
if (localStorage.getItem('debugMode') == null) {
|
||||
localStorage.setItem('debugMode', 'false');
|
||||
} else {
|
||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||
}
|
||||
if (localStorage.getItem('showTranslation') == null) {
|
||||
localStorage.setItem('showTranslation', 'false');
|
||||
} else {
|
||||
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === 'true';
|
||||
}
|
||||
let currentLyricIndex = -1;
|
||||
let currentPositionIndex = -1;
|
||||
let currentAnimationIndex = -1;
|
||||
let lyricsContainer: HTMLDivElement | null;
|
||||
let localProgress = 0;
|
||||
let lastScroll = 0;
|
||||
let scrolling = false;
|
||||
let scriptScrolling = false;
|
||||
|
||||
let currentLyricTopMargin = 208;
|
||||
|
||||
// References to lyric elements
|
||||
let refs: HTMLParagraphElement[] = [];
|
||||
let _refs: any[] = [];
|
||||
$: refs = _refs.filter(Boolean);
|
||||
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||
|
||||
// handle KeyDown event
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||
debugMode = !debugMode;
|
||||
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||
} else if (e.key === 't') {
|
||||
showTranslation = !showTranslation;
|
||||
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||
setTimeout(() => {
|
||||
scrollToLyric(refs[currentPositionIndex]);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// using for debug mode
|
||||
function extractTranslateValue(s: string): string | null {
|
||||
const regex = /translateY\((-?\d*px)\)/;
|
||||
let arr = regex.exec(s);
|
||||
return arr == null ? null : arr[1];
|
||||
}
|
||||
|
||||
// Helper function to get CSS class for a lyric based on its index and progress
|
||||
function getClass(lyricIndex: number, progress: number) {
|
||||
if (!originalLyrics.scripts) return 'previous-lyric';
|
||||
if (currentLyricIndex === lyricIndex) return 'current-lyric';
|
||||
else if (progress > originalLyrics.scripts[lyricIndex].end) return 'after-lyric';
|
||||
else return 'previous-lyric';
|
||||
}
|
||||
|
||||
// Function to move the lyrics up smoothly
|
||||
async function moveToNextLine(h: number) {
|
||||
console.debug(new Date().getTime(), 'moveToNextLine', h);
|
||||
// the line that's going to process (like a pointer)
|
||||
// by default, it's "the next line" after the lift
|
||||
let processingLineIndex = currentPositionIndex + 2;
|
||||
|
||||
// modify translateY of all lines in viewport one by one to lift them up
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
const lyric = refs[i];
|
||||
lyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||
font-size 200ms ease, scale 250ms ease`;
|
||||
lyric.style.transform = `translateY(${-h}px)`;
|
||||
processingLineIndex = i;
|
||||
await sleep(75);
|
||||
const twoLinesAhead = refs[i - 2];
|
||||
if (
|
||||
lyricsContainer &&
|
||||
twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height
|
||||
)
|
||||
break;
|
||||
}
|
||||
|
||||
if (refs.length - processingLineIndex < 3) {
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
const lyric = refs[i];
|
||||
lyric.style.transition =
|
||||
'transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||
lyric.style.transform = `translateY(${-h}px)`;
|
||||
processingLineIndex = i;
|
||||
await sleep(75);
|
||||
}
|
||||
} else {
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
refs[i].style.transition =
|
||||
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||
const height = refs[i].getBoundingClientRect().height;
|
||||
refs[i].style.transform = `translateY(${-height}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
// wait until the animation end
|
||||
await sleep(650);
|
||||
|
||||
// clear the transition to let the following style changes could be done without animation
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
refs[i].style.transition =
|
||||
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||
}
|
||||
// reset the translateY, and immediately scroll down to provide visual stability
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
refs[i].style.transform = `translateY(0px)`;
|
||||
}
|
||||
scriptScrolling = true;
|
||||
if (lyricsContainer !== null) {
|
||||
lyricsContainer.scrollTop += h;
|
||||
}
|
||||
await sleep(500);
|
||||
scriptScrolling = false;
|
||||
}
|
||||
|
||||
// Scroll the lyrics container to the given lyric
|
||||
async function scrollToLyric(currentLyric: HTMLParagraphElement) {
|
||||
if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return;
|
||||
scriptScrolling = true;
|
||||
lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - currentLyricTopMargin;
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
refs[i].style.transform = 'translateY(0px)';
|
||||
}
|
||||
setTimeout(() => {
|
||||
scriptScrolling = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Handle scroll events in the lyrics container
|
||||
function scrollHandler() {
|
||||
scrolling = !scriptScrolling;
|
||||
if (scrolling && originalLyrics.scripts) {
|
||||
lastScroll = new Date().getTime();
|
||||
for (let i = 0; i < originalLyrics.scripts.length; i++) {
|
||||
if (refs[i]) {
|
||||
refs[i].style.filter = 'blur(0px)';
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (new Date().getTime() - lastScroll > 5000) {
|
||||
scrolling = false;
|
||||
}
|
||||
}, 5500);
|
||||
}
|
||||
|
||||
// Utility function to create a sleep/delay
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Scroll to corresponding lyric while adjusting progress
|
||||
$: {
|
||||
if ($userAdjustingProgress == true) {
|
||||
const currentLyric = refs[getLyricIndex(progress)];
|
||||
scrollToLyric(currentLyric);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the current lyric and apply blur effect based on the progress
|
||||
// worked in real-time.
|
||||
$: {
|
||||
(() => {
|
||||
if (!lyricsContainer || !originalLyrics.scripts) return;
|
||||
|
||||
const scripts = originalLyrics.scripts;
|
||||
currentPositionIndex = getLyricIndex(progress);
|
||||
const cl = scripts[currentPositionIndex];
|
||||
|
||||
if (cl.start <= progress && progress <= cl.end) {
|
||||
currentLyricIndex = currentPositionIndex;
|
||||
nextUpdate.set(cl.end);
|
||||
} else {
|
||||
currentLyricIndex = -1;
|
||||
nextUpdate.set(cl.start);
|
||||
}
|
||||
|
||||
const currentLyric = refs[currentPositionIndex];
|
||||
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
||||
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const offset = Math.abs(i - currentPositionIndex);
|
||||
let blurRadius = Math.min(offset * 1.25, 16);
|
||||
const rect = refs[i].getBoundingClientRect();
|
||||
if (rect.top + rect.height < 0 || rect.top > lyricsContainer.getBoundingClientRect().height) {
|
||||
blurRadius = 0;
|
||||
}
|
||||
if (refs[i]) {
|
||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
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
|
||||
// triggered by nextUpdate's update
|
||||
async function lyricsUpdate() {
|
||||
if (
|
||||
currentPositionIndex < 0 ||
|
||||
currentPositionIndex === currentAnimationIndex ||
|
||||
$userAdjustingProgress === true ||
|
||||
scrolling
|
||||
)
|
||||
return;
|
||||
|
||||
const currentLyric = refs[currentPositionIndex];
|
||||
const currentLyricRect = currentLyric.getBoundingClientRect();
|
||||
|
||||
if (originalLyrics.scripts && currentLyricRect.top < 0) return;
|
||||
|
||||
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
||||
|
||||
// prepare current line
|
||||
currentLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||
|
||||
// prepare past lines
|
||||
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
||||
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`;
|
||||
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
||||
}
|
||||
await sleep(75);
|
||||
if (currentPositionIndex + 1 < refs.length) {
|
||||
const nextLyric = refs[currentPositionIndex + 1];
|
||||
nextLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||
await moveToNextLine(offsetHeight);
|
||||
}
|
||||
currentAnimationIndex = currentPositionIndex;
|
||||
}
|
||||
|
||||
nextUpdate.subscribe(lyricsUpdate);
|
||||
|
||||
// Process while user is adjusting progress
|
||||
userAdjustingProgress.subscribe((adjusting) => {
|
||||
if (!originalLyrics) return;
|
||||
const scripts = originalLyrics.scripts;
|
||||
if (!scripts) return;
|
||||
if (adjusting) {
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
refs[i].style.filter = `blur(0px)`;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const offset = Math.abs(i - currentPositionIndex);
|
||||
const blurRadius = Math.min(offset * 1.5, 16);
|
||||
if (refs[i]) {
|
||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle progress changes at system level
|
||||
progressBarRaw.subscribe((progress: number) => {
|
||||
if ($userAdjustingProgress === false && getLyricIndex) {
|
||||
if (Math.abs(localProgress - progress) > 0.6) {
|
||||
const currentLyric = refs[getLyricIndex(progress)];
|
||||
scrollToLyric(currentLyric);
|
||||
}
|
||||
localProgress = progress;
|
||||
}
|
||||
});
|
||||
|
||||
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} />
|
||||
|
||||
<div {...$$restProps}>
|
||||
{#if debugMode && lyricsContainer}
|
||||
<div
|
||||
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
||||
px-2 rounded-xl text-white"
|
||||
>
|
||||
<p>
|
||||
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
||||
AnimationIndex:{currentAnimationIndex}
|
||||
NextUpdate: {$nextUpdate}
|
||||
Progress: {progress.toFixed(2)}
|
||||
scrollPosition: {lyricsContainer.scrollTop}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
||||
<div
|
||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
||||
bind:this={lyricsContainer}
|
||||
on:scroll={scrollHandler}
|
||||
>
|
||||
{#each lyrics as lyric, i}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={_refs[i]}
|
||||
class="relative h-fit text-shadow-lg"
|
||||
on:click={() => {
|
||||
lyricClick(i);
|
||||
}}
|
||||
>
|
||||
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
||||
<span class="previous-lyric !text-lg !absolute !-translate-y-12"
|
||||
>{i}
|
||||
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
||||
tY: {extractTranslateValue(refs[i].style.transform)}
|
||||
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}
|
||||
>
|
||||
{#if originalLyrics.scripts[i].singer}
|
||||
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
||||
{/if}
|
||||
{lyric}
|
||||
</p>
|
||||
{#if originalLyrics.scripts[i].translation && showTranslation}
|
||||
<div
|
||||
class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}
|
||||
>
|
||||
{originalLyrics.scripts[i].translation}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="relative w-full h-[50rem]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style>
|
||||
:root {
|
||||
--lyric-mobile-font-size: 2rem;
|
||||
--lyric-mobile-line-height: 2.4rem;
|
||||
--lyric-mobile-margin: 1.5rem 0;
|
||||
--lyric-mobile-font-weight: 600;
|
||||
--lyric-desktop-font-size: 3rem;
|
||||
--lyric-desktop-line-height: 4.5rem;
|
||||
--lyric-desktop-margin: 2.75rem 0;
|
||||
}
|
||||
|
||||
.lyrics {
|
||||
mask-image: linear-gradient(
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 2rem,
|
||||
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.singer {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
bottom: 50%;
|
||||
transform: translateY(calc(50%)) translateX(-3rem);
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.current-lyric {
|
||||
position: relative;
|
||||
color: white;
|
||||
font-weight: var(--lyric-mobile-font-weight);
|
||||
font-size: var(--lyric-mobile-font-size);
|
||||
line-height: var(--lyric-mobile-line-height);
|
||||
margin: var(--lyric-mobile-margin);
|
||||
scale: 1.02 1;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.previous-lyric {
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-weight: var(--lyric-mobile-font-weight);
|
||||
font-size: var(--lyric-mobile-font-size);
|
||||
line-height: var(--lyric-mobile-line-height);
|
||||
margin: var(--lyric-mobile-margin);
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.after-lyric {
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-weight: var(--lyric-mobile-font-weight);
|
||||
font-size: var(--lyric-mobile-font-size);
|
||||
line-height: var(--lyric-mobile-line-height);
|
||||
margin: var(--lyric-mobile-margin);
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.current-lyric {
|
||||
font-size: var(--lyric-desktop-font-size);
|
||||
line-height: var(--lyric-desktop-line-height);
|
||||
margin: var(--lyric-desktop-margin);
|
||||
}
|
||||
|
||||
.after-lyric {
|
||||
font-size: var(--lyric-desktop-font-size);
|
||||
line-height: var(--lyric-desktop-line-height);
|
||||
margin: var(--lyric-desktop-margin);
|
||||
}
|
||||
|
||||
.previous-lyric {
|
||||
font-size: var(--lyric-desktop-font-size);
|
||||
line-height: var(--lyric-desktop-line-height);
|
||||
margin: var(--lyric-desktop-margin);
|
||||
}
|
||||
}
|
||||
</style>
|
265
src/lib/components/lyrics/newLyrics.svelte
Normal file
265
src/lib/components/lyrics/newLyrics.svelte
Normal file
@ -0,0 +1,265 @@
|
||||
<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 ? 12 : 0 ;
|
||||
const blurRatio = viewportWidth > 640 ? 1 : 1.4;
|
||||
const currentLyrictTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
|
||||
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 = currentLyrictTop;
|
||||
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 if (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);
|
||||
let blurRadius = Math.min(offset * blurRatio, 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.5) {
|
||||
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}
|
@ -47,6 +47,7 @@ export class Spring {
|
||||
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
|
||||
);
|
270
src/lib/lyrics/LRCparser.ts
Normal file
270
src/lib/lyrics/LRCparser.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;
|
||||
}
|
20
src/lib/lyrics/LRCtoAMLL.ts
Normal file
20
src/lib/lyrics/LRCtoAMLL.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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
|
||||
};
|
||||
}
|
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;
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
import type { LyricData } from "@alikia/aqualyrics";
|
||||
import type { LrcJsonData } from "lrc-parser-ts";
|
||||
|
||||
export default function createLyricsSearcher(lrc: LyricData): (progress: number) => number {
|
||||
export default function createLyricsSearcher(lrc: LrcJsonData): (progress: number) => number {
|
||||
if (!lrc || !lrc.scripts) return () => 0;
|
||||
const startTimes: number[] = lrc.scripts.map(script => script.start);
|
||||
const endTimes: number[] = lrc.scripts.map(script => script.end);
|
||||
|
||||
return function(progress: number): number {
|
||||
// 使用二分查找定位 progress 对应的歌词索引
|
||||
let left = 0;
|
||||
let right = startTimes.length - 1;
|
||||
|
||||
@ -21,10 +22,12 @@ export default function createLyricsSearcher(lrc: LyricData): (progress: number)
|
||||
}
|
||||
}
|
||||
|
||||
// 循环结束后,检查 left 索引
|
||||
if (left < startTimes.length && startTimes[left] > progress && (left === 0 || endTimes[left - 1] <= progress)) {
|
||||
return left;
|
||||
}
|
||||
|
||||
// 如果没有找到确切的 progress,返回小于等于 progress 的最大索引
|
||||
return Math.max(0, right);
|
||||
};
|
||||
}
|
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,
|
||||
};
|
||||
}
|
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;
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import { globalMemoryStorage, songData, songNameCache } from '@core/server/cache.js';
|
||||
import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js';
|
||||
import { getDirectoryHash } from '../dirHash';
|
||||
import { safePath } from '../safePath';
|
||||
|
@ -1,4 +1,4 @@
|
||||
export interface MusicMetadata {
|
||||
interface MusicMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
26
src/lib/ttml/ttml-types.ts
Normal file
26
src/lib/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;
|
||||
}
|
@ -4,11 +4,8 @@ export default function(key: string){
|
||||
"audio/ogg": "OGG 容器",
|
||||
"audio/flac": "FLAC 无损音频",
|
||||
"audio/aac": "AAC 音频",
|
||||
"audio/wav": "WAV 音频",
|
||||
"ttml": "TTML歌词",
|
||||
"lrc": "LRC 歌词"
|
||||
}
|
||||
if (!key) return "未知格式";
|
||||
if (!(key in dict)) return key;
|
||||
else return dict[key as keyof typeof dict];
|
||||
}
|
@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import extractFileName from '@core/utils/extractFileName';
|
||||
import getVersion from '@core/utils/getVersion';
|
||||
import toHumanSize from '@core/utils/humanSize';
|
||||
import localforage from '@core/utils/storage';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import getVersion from '$lib/utils/getVersion';
|
||||
import toHumanSize from '$lib/utils/humanSize';
|
||||
import localforage from '$lib/utils/storage';
|
||||
interface Song {
|
||||
name: string;
|
||||
singer?: string;
|
||||
@ -60,22 +58,13 @@
|
||||
<div>
|
||||
<ul class="mt-4 relative w-full">
|
||||
{#each idList as id}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="!no-underline !text-black dark:!text-white" onclick={() => location.href = (`/play/${id}`)}>
|
||||
<a class="!no-underline !text-black dark:!text-white" href={`/play/${id}`}>
|
||||
<li
|
||||
class="relative my-4 p-4 duration-150 bg-zinc-200 hover:bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600 rounded-lg"
|
||||
>
|
||||
<span class="font-bold">{musicList[id].name}</span> <br />
|
||||
<div class="flex pt-0.5 items-center">
|
||||
<span class="w-[4.5rem]">{toHumanSize(musicList[id].size)}</span>
|
||||
<div class="!no-underline import inline-block cursor-default z-50 px-2 py-0.5 rounded ml-2
|
||||
hover:bg-gray-200 dark:hover:bg-zinc-500"
|
||||
onclick={(e) => {location.href = `/import/${id}/lyric`;e.stopPropagation();}}>
|
||||
导入歌词
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span>{toHumanSize(musicList[id].size)}</span> ·
|
||||
<a class="!no-underline" href={`/import/${id}/lyric`}>导入歌词</a>
|
||||
{#if musicList[id].coverUrl}
|
||||
<img
|
||||
class="h-16 w-16 object-cover absolute rounded-lg right-2 top-2"
|
||||
@ -84,7 +73,7 @@
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@ -94,7 +83,7 @@
|
||||
</p>
|
||||
<a href="/import">导入音乐</a> <br />
|
||||
<button
|
||||
onclick={() => window.confirm('确定要删除本地数据库中所有内容吗?') && clear()}
|
||||
on:click={() => window.confirm('确定要删除本地数据库中所有内容吗?') && clear()}
|
||||
class="text-white bg-red-500 px-4 py-2 mt-4 rounded-md">一键清除</button
|
||||
>
|
||||
<h2 class="mt-4"><a href="/database/">音乐数据库</a></h2>
|
||||
@ -102,10 +91,7 @@
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.import {
|
||||
@apply text-red-500 duration-150 underline;
|
||||
}
|
||||
.import:hover {
|
||||
text-shadow: 0 0 3px rgba(239, 68, 68, 0.3);
|
||||
a {
|
||||
@apply text-red-500 hover:text-red-400 duration-150 underline;
|
||||
}
|
||||
</style>
|
28
src/routes/api/database/search/+server.ts
Normal file
28
src/routes/api/database/search/+server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { songNameCache } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const keyword = url.searchParams.get('keyword');
|
||||
|
||||
await loadData();
|
||||
|
||||
if (keyword === null) {
|
||||
return error(400, {
|
||||
'message': 'Miss parameter: keyword'
|
||||
});
|
||||
}
|
||||
|
||||
const resultList: MusicMetadata[] = [];
|
||||
|
||||
for (const songName of songNameCache.keys()) {
|
||||
if (songName.toLocaleLowerCase().includes(keyword.toLocaleLowerCase())) {
|
||||
resultList.push(songNameCache.get(songName)!);
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
'result': resultList
|
||||
});
|
||||
};
|
43
src/routes/api/database/song/[id]/+server.ts
Normal file
43
src/routes/api/database/song/[id]/+server.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { safePath } from '$lib/server/safePath';
|
||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||
if (!filePath) {
|
||||
return error(404, {
|
||||
message: "No correspoding song."
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try { data = fs.readFileSync(filePath); } catch (e) {
|
||||
return error(404, {
|
||||
message: "No corresponding song."
|
||||
});
|
||||
}
|
||||
return json(JSON.parse(data.toString()));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const timeStamp = new Date().getTime();
|
||||
try {
|
||||
if (!fs.existsSync("./data/pending/")) {
|
||||
fs.mkdirSync("./data/pending", { mode: 0o755 });
|
||||
}
|
||||
const filePath = `./data/pending/${params.id}-${timeStamp}.json`;
|
||||
const data: MusicMetadata = await request.json();
|
||||
data.updateTime = getCurrentFormattedDateTime();
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), { mode: 0o644 });
|
||||
return json({
|
||||
"message": "successfully created"
|
||||
}, {
|
||||
status: 201
|
||||
});
|
||||
} catch (e) {
|
||||
return error(500, {
|
||||
message: "Internal server error."
|
||||
});
|
||||
}
|
||||
}
|
16
src/routes/api/database/songs/+server.ts
Normal file
16
src/routes/api/database/songs/+server.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { songData } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const limit = parseInt(url.searchParams.get("limit") ?? "20");
|
||||
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
||||
loadData();
|
||||
const songIDList = songData.keys().slice(offset, offset + limit);
|
||||
const songDataList = [];
|
||||
for (const songID of songIDList) {
|
||||
songDataList.push(songData.get(songID)!);
|
||||
}
|
||||
return json(songDataList);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user