Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
b01cc47566 | |||
f27c290f39 | |||
8fe3c73c09 | |||
7aef8e873d | |||
74a21e721d | |||
c1bfba8f1c | |||
0d60e9a094 | |||
e60baa0358 |
2
.gitignore
vendored
@ -1,6 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
build
|
||||
.svelte-kit
|
||||
/package
|
||||
.env
|
||||
|
6
.tokeignore
Normal file
@ -0,0 +1,6 @@
|
||||
*.toml
|
||||
*.yml
|
||||
*.json
|
||||
*.md
|
||||
*.html
|
||||
*.svg
|
13
Dockerfile
@ -4,20 +4,17 @@ FROM oven/bun:latest
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and bun.lockb files to the working directory
|
||||
COPY package.json bun.lockb ./
|
||||
# Copy the application code
|
||||
COPY . .
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN bun run build
|
||||
RUN bun run web:build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 4173
|
||||
EXPOSE 2611
|
||||
|
||||
# Command to run the application
|
||||
CMD ["bun", "go"]
|
||||
CMD ["bun", "web:deploy"]
|
@ -4,12 +4,12 @@ services:
|
||||
aquavox:
|
||||
build: .
|
||||
ports:
|
||||
- "4173:4173"
|
||||
- "2611:2611"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- .:/app
|
||||
command: ["bun", "go"]
|
||||
command: ["bun", "web:deploy"]
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
|
@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "aquavox",
|
||||
"version": "2.9.4",
|
||||
"version": "2.9.5",
|
||||
"private": false,
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"workspaces": ["packages/web", "packages/core", "packages/electron"],
|
||||
"workspaces": ["packages/web", "packages/core"],
|
||||
"scripts": {
|
||||
"electron:dev": "bun --filter 'electron' dev",
|
||||
"web:dev": "bun --filter 'web' dev",
|
||||
"dev": "bun --filter '**' dev"
|
||||
"dev": "bun --filter '**' dev",
|
||||
"web:build": "bun --filter 'web' build",
|
||||
"web:deploy": "bun --filter 'web' go"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
@ -35,7 +37,6 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"dependencies": {},
|
||||
"trustedDependencies": [
|
||||
"svelte-preprocess"
|
||||
]
|
||||
|
135
packages/core/components/displayFPS.svelte
Normal file
@ -0,0 +1,135 @@
|
||||
<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>
|
@ -151,7 +151,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class={"absolute w-full h-2/3 bottom-0" + (showInteractiveBox ? '' : '-translate-y-48')}
|
||||
<div class={"absolute w-full h-3/4 bottom-0" + (showInteractiveBox ? '' : '-translate-y-48')}
|
||||
style={`z-index: ${showInteractiveBox ? "0" : "50"}`}
|
||||
on:click={handleClick}
|
||||
></div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import createSpring from '@core/graphics/spring';
|
||||
import type { LyricWord, ScriptItem } from '@core/lyrics/type';
|
||||
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';
|
||||
@ -37,21 +37,67 @@
|
||||
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 (lastUpdateY === undefined) {
|
||||
lastUpdateY = new Date().getTime();
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (springY === undefined) return;
|
||||
time = (new Date().getTime() - lastUpdateY) / 1000;
|
||||
springY.update(time);
|
||||
positionY = springY.getCurrentPosition();
|
||||
if (!springY.arrived() && !stopped && !we_are_scrolling) {
|
||||
requestAnimationFrame(updateY);
|
||||
}
|
||||
lastUpdateY = new Date().getTime();
|
||||
}
|
||||
|
||||
function updateX(timestamp: number) {
|
||||
if (stopped) return;
|
||||
if (lastUpdateX === undefined) {
|
||||
lastUpdateX = timestamp;
|
||||
}
|
||||
@ -70,6 +116,7 @@
|
||||
* @param {number} pos - X offset, in pixels
|
||||
*/
|
||||
export const setX = (pos: number) => {
|
||||
stopped = true;
|
||||
positionX = pos;
|
||||
};
|
||||
|
||||
@ -78,6 +125,7 @@
|
||||
* @param {number} pos - Y offset, in pixels
|
||||
*/
|
||||
export const setY = (pos: number) => {
|
||||
stopped = true;
|
||||
positionY = pos;
|
||||
};
|
||||
|
||||
@ -94,7 +142,7 @@
|
||||
}
|
||||
if (scrolling) blurRadius = 0;
|
||||
if ($userAdjustingProgress) blurRadius = 0;
|
||||
blur = blurRadius
|
||||
blur = blurRadius;
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,11 +181,10 @@
|
||||
we_are_scrolling = false;
|
||||
};
|
||||
|
||||
|
||||
export const syncSpringWithDelta = (deltaY: number) => {
|
||||
const target = positionY + deltaY;
|
||||
springY!.setPosition(target);
|
||||
}
|
||||
};
|
||||
|
||||
export const getInfo = () => {
|
||||
return {
|
||||
@ -162,52 +209,19 @@
|
||||
|
||||
export const getRef = () => ref;
|
||||
|
||||
// Calculate if the current character should be highlighted based on progress
|
||||
const isCharacterHighlighted = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
||||
const charProgress = getCharacterProgress(word, charIndex);
|
||||
return line.start <= progress &&
|
||||
progress <= line.end &&
|
||||
progress > charProgress;
|
||||
};
|
||||
|
||||
// Get the progress timing for a specific character in a word
|
||||
const getCharacterProgress = (word: LyricWord, charIndex: number) => {
|
||||
const { startTime, endTime } = word;
|
||||
let processedChars = line.words?.flatMap((word) => {
|
||||
const { startTime, endTime, word: text } = word;
|
||||
const wordDuration = endTime - startTime;
|
||||
return wordDuration * (charIndex / word.word.length) + 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 };
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate the transition duration for a character
|
||||
const getTransitionDuration = (word: LyricWord, charIndex: number) => {
|
||||
const { startTime, endTime } = word;
|
||||
const wordDuration = endTime - startTime;
|
||||
const charDuration = wordDuration * ((charIndex + 1) / word.word.length);
|
||||
|
||||
// If duration is less than 0.6s, we'll use CSS class with fixed duration
|
||||
if (charDuration < 0.6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, calculate custom duration
|
||||
return charDuration / 1.6;
|
||||
};
|
||||
|
||||
// Generate the CSS classes for a character
|
||||
const getCharacterClasses = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
||||
const baseClasses = 'inline-block';
|
||||
const opacityClass = isCharacterHighlighted(line, word, charIndex, progress)
|
||||
? 'opacity-100 text-glow'
|
||||
: 'opacity-35';
|
||||
|
||||
return `${baseClasses} ${opacityClass}`.trim();
|
||||
};
|
||||
|
||||
// Generate the style string for a character
|
||||
const getCharacterStyle = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
||||
const duration = getTransitionDuration(word, charIndex);
|
||||
const progressAtCurrentLine = progress <= line.end;
|
||||
return (duration && progressAtCurrentLine) ? `transition-duration: ${duration}s;` : 'transition-duration: 200ms;';
|
||||
};
|
||||
// 新增:缓存当前行状态
|
||||
$: isActiveLine = line.start <= progress && progress <= line.end;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
@ -240,17 +254,18 @@
|
||||
{/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 line.words as word}
|
||||
{#if word.word}
|
||||
{#each word.word.split('') as chr, i}
|
||||
<span
|
||||
class={getCharacterClasses(line, word, i, progress)}
|
||||
style={getCharacterStyle(line, word, i, progress)}
|
||||
>
|
||||
{chr}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
{#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}
|
||||
@ -272,9 +287,23 @@
|
||||
|
||||
<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);
|
||||
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>
|
||||
|
@ -4,6 +4,7 @@
|
||||
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;
|
||||
@ -12,15 +13,20 @@
|
||||
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.1; // Minimum velocity to stop 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
|
||||
let {
|
||||
originalLyrics,
|
||||
progress,
|
||||
player,
|
||||
showInteractiveBox
|
||||
}: {
|
||||
originalLyrics: LyricData;
|
||||
progress: number;
|
||||
player: HTMLAudioElement | null;
|
||||
showInteractiveBox: boolean;
|
||||
} = $props();
|
||||
|
||||
// States
|
||||
@ -37,6 +43,7 @@
|
||||
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([]);
|
||||
@ -75,15 +82,28 @@
|
||||
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 (progress > lyric.end) {
|
||||
if (lyricBeforeProgress) {
|
||||
delay = 0;
|
||||
} else if (lyric.start <= progress && progress <= lyric.end) {
|
||||
delay = 0.042;
|
||||
} else if (lyricInProgress) {
|
||||
delay = 0.03;
|
||||
} else {
|
||||
delay = Math.min(Math.min(currentLyricDuration, 0.6), 0.067 * (i - currentLyricIndex + 1.2));
|
||||
}
|
||||
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,11 +130,16 @@
|
||||
});
|
||||
|
||||
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.stop();
|
||||
currentLyricComponent.setY(currentY - deltaY);
|
||||
currentLyricComponent.syncSpringWithDelta(deltaY);
|
||||
}
|
||||
@ -122,7 +147,7 @@
|
||||
if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||
scrollingTimeout = setTimeout(() => {
|
||||
scrolling = false;
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Handle the touch start event
|
||||
@ -199,21 +224,19 @@
|
||||
lastEventProgress = progress;
|
||||
if (!lyricChanged || scrolling) return;
|
||||
if (!lyricIndexDeltaTooBig && deltaInRange) {
|
||||
console.log("Event: regular move");
|
||||
console.log('Event: regular move');
|
||||
console.log(new Date().getTime(), lastSeekForward);
|
||||
computeLayout();
|
||||
}
|
||||
else if ($userAdjustingProgress) {
|
||||
} else if ($userAdjustingProgress) {
|
||||
if (deltaTooBig && lyricChanged) {
|
||||
console.log("Event: seek forward");
|
||||
console.log('Event: seek forward');
|
||||
seekForward();
|
||||
} else if (deltaIsNegative && lyricChanged) {
|
||||
console.log("Event: seek backward");
|
||||
console.log('Event: seek backward');
|
||||
seekForward();
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("Event: regular move");
|
||||
} else {
|
||||
console.log('Event: regular move');
|
||||
computeLayout();
|
||||
}
|
||||
});
|
||||
@ -252,16 +275,23 @@
|
||||
{#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">
|
||||
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)]"}
|
||||
${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%,
|
||||
@ -269,8 +299,16 @@
|
||||
bind:this={lyricsContainer}
|
||||
>
|
||||
{#each lyricLines as lyric, i}
|
||||
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} {progress}
|
||||
{currentLyricIndex} {scrolling}/>
|
||||
<LyricLine
|
||||
line={lyric}
|
||||
index={i}
|
||||
bind:this={lyricComponents[i]}
|
||||
{debugMode}
|
||||
{lyricClick}
|
||||
{progress}
|
||||
{currentLyricIndex}
|
||||
{scrolling}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -1,252 +0,0 @@
|
||||
import {
|
||||
alt_sc,
|
||||
apply,
|
||||
buildLexer,
|
||||
expectEOF,
|
||||
fail,
|
||||
kleft,
|
||||
kmid,
|
||||
kright,
|
||||
opt_sc,
|
||||
type Parser,
|
||||
rep,
|
||||
rep_sc,
|
||||
seq,
|
||||
str,
|
||||
tok,
|
||||
type Token
|
||||
} from 'typescript-parsec';
|
||||
import type { LrcJsonData, ParsedLrc, ScriptItem } from '../type';
|
||||
import type { IDTag } from './type';
|
||||
|
||||
|
||||
interface ParserScriptItem {
|
||||
start: number;
|
||||
text: string;
|
||||
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 singer = singerPart?.text;
|
||||
const translation = translatePart === undefined ? undefined : joinTokens(translatePart);
|
||||
|
||||
return ['script_item', { start, text, 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, '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;
|
||||
}
|
3
packages/core/lyrics/lrc/type.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
export interface IDTag {
|
||||
[key: string]: string;
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
import type { LrcJsonData } from "lrc-parser-ts";
|
||||
import type { LyricData } from "@alikia/aqualyrics";
|
||||
|
||||
export default function createLyricsSearcher(lrc: LrcJsonData): (progress: number) => number {
|
||||
export default function createLyricsSearcher(lrc: LyricData): (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;
|
||||
|
||||
@ -22,12 +21,10 @@ export default function createLyricsSearcher(lrc: LrcJsonData): (progress: numbe
|
||||
}
|
||||
}
|
||||
|
||||
// 循环结束后,检查 left 索引
|
||||
if (left < startTimes.length && startTimes[left] > progress && (left === 0 || endTimes[left - 1] <= progress)) {
|
||||
return left;
|
||||
}
|
||||
|
||||
// 如果没有找到确切的 progress,返回小于等于 progress 的最大索引
|
||||
return Math.max(0, right);
|
||||
};
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import type { LrcJsonData } from '../type';
|
||||
import { parseTTML as ttmlParser } from './parser';
|
||||
import type { LyricLine } from './ttml-types';
|
||||
|
||||
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[]) => {
|
||||
let words = value.words.length == 0 ? undefined : value.words;
|
||||
if (words) {
|
||||
words = words.map((word) => {
|
||||
let r = word;
|
||||
r.startTime /= 1000;
|
||||
r.endTime /= 1000;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return {
|
||||
text: value.words.map((word) => word.word).join(''),
|
||||
start: value.startTime / 1000,
|
||||
end: value.endTime / 1000,
|
||||
translation: value.translatedLyric || undefined,
|
||||
singer: value.singer || undefined,
|
||||
words: words
|
||||
};
|
||||
})
|
||||
};
|
||||
return lyrics;
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
singer: ""
|
||||
};
|
||||
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 (role === "x-singer") {
|
||||
line.singer = wordEl.innerHTML;
|
||||
}
|
||||
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
||||
const word: LyricWord = {
|
||||
word: wordNode.textContent ?? "",
|
||||
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
||||
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
||||
};
|
||||
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
||||
if (emptyBeat) {
|
||||
word.emptyBeat = Number(emptyBeat);
|
||||
}
|
||||
line.words.push(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.isBG) {
|
||||
const firstWord = line.words?.[0];
|
||||
if (firstWord?.word.startsWith("(")) {
|
||||
firstWord.word = firstWord.word.substring(1);
|
||||
if (firstWord.word.length === 0) {
|
||||
line.words.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const lastWord = line.words?.[line.words.length - 1];
|
||||
if (lastWord?.word.endsWith(")")) {
|
||||
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
||||
if (lastWord.word.length === 0) {
|
||||
line.words.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = lineEl.getAttribute("begin");
|
||||
const endTime = lineEl.getAttribute("end");
|
||||
if (startTime && endTime) {
|
||||
line.startTime = parseTimespan(startTime);
|
||||
line.endTime = parseTimespan(endTime);
|
||||
} else {
|
||||
line.startTime = line.words
|
||||
.filter((v) => v.word.trim().length > 0)
|
||||
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
||||
line.endTime = line.words
|
||||
.filter((v) => v.word.trim().length > 0)
|
||||
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
||||
}
|
||||
|
||||
if (haveBg) {
|
||||
const bgLine = lyricLines.pop();
|
||||
lyricLines.push(line);
|
||||
if (bgLine) lyricLines.push(bgLine);
|
||||
} else {
|
||||
lyricLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
||||
parseParseLine(lineEl);
|
||||
}
|
||||
|
||||
return {
|
||||
metadata,
|
||||
lyricLines: lyricLines,
|
||||
};
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
export interface TTMLMetadata {
|
||||
key: string;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface TTMLLyric {
|
||||
metadata: TTMLMetadata[];
|
||||
lyricLines: LyricLine[];
|
||||
}
|
||||
|
||||
export interface LyricWord {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
word: string;
|
||||
emptyBeat?: number;
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
words: LyricWord[];
|
||||
translatedLyric: string;
|
||||
romanLyric: string;
|
||||
isBG: boolean;
|
||||
isDuet: boolean;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
singer: string;
|
||||
}
|
33
packages/core/lyrics/type.d.ts
vendored
@ -1,33 +0,0 @@
|
||||
export interface ScriptItem{
|
||||
end: number;
|
||||
chorus?: string;
|
||||
start: number;
|
||||
text: string;
|
||||
words?: LyricWord[];
|
||||
translation?: string;
|
||||
singer?: string;
|
||||
}
|
||||
|
||||
export interface LyricWord {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
word: string;
|
||||
emptyBeat?: number;
|
||||
}
|
||||
|
||||
export interface LyricMetadata {
|
||||
ar?: string;
|
||||
ti?: string;
|
||||
al?: string;
|
||||
au?: string;
|
||||
length?: string;
|
||||
offset?: string;
|
||||
tool?: string;
|
||||
ve?: string;
|
||||
}
|
||||
|
||||
export interface LyricData extends LyricMetadata {
|
||||
scripts?: ScriptItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
@ -27,13 +27,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||
"@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",
|
||||
|
@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import { parseLRC } from '@core/lyrics/lrc/parser';
|
||||
|
||||
describe('LRC parser test', () => {
|
||||
const test01Buffer = fs.readFileSync('./packages/core/test/lyrics/resources/test-01.lrc');
|
||||
const test01Text = test01Buffer.toString('utf-8');
|
||||
const test03Buffer = fs.readFileSync('./packages/core/test/lyrics/resources/test-03.lrc');
|
||||
const test03Text = test03Buffer.toString('utf-8');
|
||||
|
||||
const lf_alternatives = ['\n', '\r\n', '\r'];
|
||||
|
||||
it('Parses test-01.lrc', () => {
|
||||
for (const lf of lf_alternatives) {
|
||||
const text = test01Text.replaceAll('\n', lf);
|
||||
|
||||
const result = parseLRC(text, { wordDiv: '', strict: true });
|
||||
|
||||
expect(result.ar).toBe("洛天依");
|
||||
expect(result.ti).toBe("中华少女·终");
|
||||
expect(result.al).toBe("中华少女");
|
||||
expect(result["tool"]).toBe("歌词滚动姬 https://lrc-maker.github.io");
|
||||
expect(result.scripts!![1].text).toBe("因果与恩怨牵杂等谁来诊断");
|
||||
expect(result.scripts!![1].start).toBe(49000 + 588);
|
||||
}
|
||||
})
|
||||
it('Parses test-03.lrc', () => {
|
||||
const result = parseLRC(test03Text, { wordDiv: ' ', strict: true });
|
||||
expect(result.scripts!![5].text).toBe("བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ།");
|
||||
expect(result.scripts!![5].translation).toBe("在舞池里舞一舞");
|
||||
expect(result.scripts!![6].text).toBe("祝祷转过千年 五色经幡飘飞");
|
||||
expect(result.scripts!![6].singer).toBe("a");
|
||||
expect(result.scripts!![11].singer).toBeUndefined();
|
||||
expect(result.scripts!![11].translation).toBe("我们在此相聚");
|
||||
});
|
||||
it('Accepts some weird but parsable LRCs', () => {
|
||||
const cases = [
|
||||
"[ti: []]",
|
||||
"[ar: [<]]",
|
||||
"[ar: <ar>]",
|
||||
"[ar: a b c]",
|
||||
"[00:00.00] <00:00.04> When the <00:00.16> the",
|
||||
"[00:00.00] [00:00.04] When [00:00.16] the",
|
||||
"[00:00.0000000] <00:00.04> When <00:00.16> the",
|
||||
"[00:00.00] <00:00.04> [When] <00:00.16> the",
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
expect(() => parseLRC(c, { strict: false })).not.toThrow();
|
||||
}
|
||||
})
|
||||
});
|
@ -1,56 +0,0 @@
|
||||
[ti: 中华少女·终]
|
||||
[ar: 洛天依]
|
||||
[al: 中华少女]
|
||||
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||
[00:46.706] 我想要仗剑天涯却陷入纷乱
|
||||
[00:49.588] 因果与恩怨牵杂等谁来诊断
|
||||
[00:52.284] 暗箭在身后是否该回身看
|
||||
[00:55.073] 人心有了鬼心房便要过鬼门关
|
||||
[00:57.875] 早已茫然染了谁的血的这抹长衫
|
||||
[01:00.702] 独木桥上独目瞧的人一夫当关
|
||||
[01:03.581] 复仇或是诅咒缠在身上的宿命
|
||||
[01:06.591] 棋子在棋盘被固定移动不停转
|
||||
[01:09.241] 仇恨与仇恨周而复始往返
|
||||
[01:12.586] 酒楼深胭脂一点分隔光暗
|
||||
[01:15.205] 数求问天涯海角血债偿还
|
||||
[01:18.015] 终是神念迷茫只做旁观
|
||||
[01:21.087] 是非恩怨三生纠葛轮转
|
||||
[01:23.709] 回望从前苦笑将杯酒斟满
|
||||
[01:26.573] 那时明月今日仍旧皎洁
|
||||
[01:29.115] 只叹换拨人看
|
||||
[01:31.024] 你可是这样的少年
|
||||
[01:33.971] 梦想着穿越回从前
|
||||
[01:36.554] 弦月下着青衫抚长剑
|
||||
[01:42.341] 风起时以血绘长卷
|
||||
[01:45.276] 三寸剑只手撼江山
|
||||
[01:47.838] 拂衣去逍遥天地间
|
||||
[01:52.991]
|
||||
[02:16.707] 黄藓绿斑苔痕将岁月扒谱
|
||||
[02:20.077] 望眼欲穿你何时寄来家书
|
||||
[02:22.788] 踱步间院落飞絮聚散化作愁字
|
||||
[02:25.601] 当泪水成河能否凫上一位游子
|
||||
[02:28.050] 当庭间嫣红轻轻闻着雨声
|
||||
[02:30.841] 电闪雷鸣院中青翠摇曳倚风
|
||||
[02:33.362] 青丝落指尖模糊记忆更为清晰
|
||||
[02:36.334] 那一朵英姿遮挡于一抹旌旗飘
|
||||
[02:39.511] 厮杀与厮杀周而复始招摇
|
||||
[02:42.576] 血渍滑落于枪尖映照宵小
|
||||
[02:45.726] 城池下红莲飞溅绽放照耀
|
||||
[02:48.509] 碧落黄泉再无叨扰
|
||||
[02:51.338] 北风呼啸三生等待轮转
|
||||
[02:53.660] 山崖古道思绪被光阴晕染
|
||||
[02:56.895] 那时明月今日仍旧皎洁
|
||||
[02:59.293] 只叹孤身人看
|
||||
[03:01.335] 你可是这样的少年
|
||||
[03:04.377] 梦想着穿越回从前
|
||||
[03:06.924] 北风里铁衣冷槊光寒
|
||||
[03:12.607] 一朝去大小三百战
|
||||
[03:15.623] 岁月欺万里定江山
|
||||
[03:18.126] 再与她同游天地间
|
||||
[03:24.356] 说书人或许会留恋
|
||||
[03:27.057] 但故事毕竟有终点
|
||||
[03:29.590] 最好的惊堂木是时间
|
||||
[03:35.157] 就让我合上这书卷
|
||||
[03:38.242] 愿那些梦中的玩伴
|
||||
[03:40.857] 梦醒后仍然是少年
|
||||
[03:46.139]
|
@ -1,77 +0,0 @@
|
||||
[ti: 雪山之眼]
|
||||
[ar: 洛天依 & 旦增益西]
|
||||
[al: 游四方]
|
||||
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||
[length: 04:17.400]
|
||||
[00:34.280] 浸透了经卷 记忆的呼喊
|
||||
[00:37.800] 雪珠滚落山巅 栽下一个春天
|
||||
[00:47.390] 松石敲响玲珑清脆的银花
|
||||
[00:51.600] 穿过玛瑙的红霞
|
||||
[00:54.430] 在她眼中结编 亘久诗篇
|
||||
[01:05.440] a: བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། | 在舞池里舞一舞
|
||||
[01:08.780] a: 祝祷转过千年 五色经幡飘飞
|
||||
[01:12.040] 奏起悠扬巴叶 任岁月拨弦
|
||||
[01:19.130] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||
[01:22.330] 宫殿 塔尖 彩绘 日月 同辉
|
||||
[01:25.810] 那层厚重壁垒化身 蝉翼一片
|
||||
[01:29.110] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། | 我们在此相聚
|
||||
[01:30.790] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[01:32.510] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[01:34.120] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[01:35.920] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[01:37.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[01:39.350] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[01:41.050] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[01:42.740] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[01:44.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[01:46.280] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[01:48.010] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[01:49.600] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[01:51.380] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[01:53.070] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[01:54.820] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[01:58.580] སྔོན་དང་པོ་གྲུབ་ཐོབ་ཐང་སྟོང་རྒྱལ་པོས་མཛད་པའི་མཛད་ཚུལ་དུ། དང་པོ་རྔོན་པའི་ས་སྦྱངས་ས་འདུལ། གཉིས་པ་རྒྱ་ལུའི་བྱིན་འབེབས། གསུམ་པ་ལྷ་མོའི་གླུ་གར་སོགས་རིན་ཆེན་གསུང་མགུར་གཞུང་བཟང་མང་པོ་འདུག་སྟེ། དེ་ཡང་མ་ཉུང་གི་ཚིག་ལ་དུམ་མཚམས་གཅིག་ཞུས་པ་བྱུང་བ་ཡིན་པ་ལགས་སོ། 如祖师唐东杰布所著,一有温巴净地,二有甲鲁祈福,三有仙女歌舞,所著繁多,在此简略献之。
|
||||
[02:24.240] 浸透了经卷 记忆的呼喊
|
||||
[02:27.450] 雪珠滚落山巅 栽下一个春天
|
||||
[02:37.090] 松石敲响玲珑清脆的银花
|
||||
[02:41.280] 穿过玛瑙的红霞
|
||||
[02:44.010] 在她眼中结编 亘久诗篇
|
||||
[02:55.250] བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། 在舞池里舞一舞
|
||||
[02:58.410] 祝祷转过千年 五色经幡飘飞
|
||||
[03:01.750] 奏起悠扬巴叶 任岁月拨弦
|
||||
[03:08.840] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||
[03:12.050] 宫殿 塔尖 彩绘 日月 同辉
|
||||
[03:15.400] 那层厚重壁垒化身 蝉翼一片
|
||||
[03:18.850] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:20.480] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:22.210] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:23.910] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:25.662] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:27.391] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:29.096] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:30.789] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:32.496] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:34.175] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:35.876] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:37.606] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:39.290] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:41.030] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:42.679] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:44.455] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:46.176] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:47.910] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:49.625] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:51.293] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:53.005] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[03:54.742] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[03:56.479] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[03:58.159] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[03:59.859] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[04:01.548] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[04:03.312] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[04:05.026] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[04:06.721] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||
[04:08.479] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||
[04:10.175] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||
[04:11.923] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||
[04:17.400]
|
@ -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,57 +0,0 @@
|
||||
{
|
||||
"name": "electron",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=dev bun run dev:all",
|
||||
"dev:all": "concurrently -n=svelte,electron -c='#ff3e00',blue \"bun run dev:svelte\" \"bun run dev:electron\"",
|
||||
"dev:svelte": "vite dev",
|
||||
"dev:electron": "electron src/electron.js",
|
||||
"build": "cross-env NODE_ENV=production bun run build:svelte && bun run build:electron",
|
||||
"build:svelte": "vite build",
|
||||
"build:electron": "electron-builder -mwl --config build.config.json"
|
||||
},
|
||||
"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": "4.2.19",
|
||||
"svelte-check": "^3.7.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "5.4.6",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||
"@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",
|
||||
"electron": "^33.0.2",
|
||||
"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,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
h1 {
|
||||
@apply text-4xl font-bold leading-[4rem];
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl font-medium leading-[3rem];
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.text-shadow-md {
|
||||
text-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.12),
|
||||
0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.text-shadow-lg {
|
||||
text-shadow:
|
||||
0 15px 30px rgba(0, 0, 0, 0.11),
|
||||
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
13
packages/electron/src/app.d.ts
vendored
@ -1,13 +0,0 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
@ -1,19 +0,0 @@
|
||||
<!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" />
|
||||
<title>AquaVox</title>
|
||||
%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,94 +0,0 @@
|
||||
import windowStateManager from 'electron-window-state';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import contextMenu from 'electron-context-menu';
|
||||
import serve from 'electron-serve';
|
||||
|
||||
try {
|
||||
require('electron-reloader')(module);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const serveURL = serve({ directory: '.' });
|
||||
const port = process.env.PORT || 5173;
|
||||
const dev = !app.isPackaged;
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
let windowState = windowStateManager({
|
||||
defaultWidth: 800,
|
||||
defaultHeight: 600,
|
||||
});
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
backgroundColor: 'whitesmoke',
|
||||
titleBarStyle: 'hidden',
|
||||
autoHideMenuBar: true,
|
||||
minHeight: 450,
|
||||
minWidth: 500,
|
||||
webPreferences: {
|
||||
enableRemoteModule: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: true,
|
||||
spellcheck: false,
|
||||
devTools: dev,
|
||||
|
||||
},
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
});
|
||||
|
||||
windowState.manage(mainWindow);
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
mainWindow.on('close', () => {
|
||||
windowState.saveState(mainWindow);
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
contextMenu({
|
||||
showLookUpSelection: true,
|
||||
showSearchWithGoogle: true,
|
||||
showCopyImage: true,
|
||||
});
|
||||
|
||||
function loadVite(port) {
|
||||
mainWindow.loadURL(`http://localhost:${port}`).catch((e) => {
|
||||
console.log('Error loading URL, retrying', e);
|
||||
setTimeout(() => {
|
||||
loadVite(port);
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
mainWindow = createWindow();
|
||||
mainWindow.once('close', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
if (dev) loadVite(port);
|
||||
else serveURL(mainWindow);
|
||||
}
|
||||
|
||||
app.once('ready', createMainWindow);
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
createMainWindow();
|
||||
}
|
||||
});
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on('to-main', (event, count) => {
|
||||
return mainWindow.webContents.send('from-main', `next count is ${count + 1}`);
|
||||
});
|
@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { processImage } from '@core/graphics';
|
||||
import blobToImageData from '@core/graphics/blob2imageData';
|
||||
import imageDataToBlob from '@core/graphics/imageData2blob';
|
||||
import localforage from '$lib/utils/storage';
|
||||
export let coverId: string;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
localforage.getItem(`${coverId}-cover-cache`, function (_err, file) {
|
||||
if (file) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
blobToImageData(file as Blob).then((imageData) => {
|
||||
canvas.height = imageData.height;
|
||||
canvas.width = imageData.width;
|
||||
ctx?.putImageData(imageData, 0, 0);
|
||||
canvas.style.opacity = '1';
|
||||
});
|
||||
} else {
|
||||
localforage.getItem(`${coverId}-cover`, function (_err, file) {
|
||||
if (file) {
|
||||
const path = URL.createObjectURL(file as File);
|
||||
processImage(16, 4, 96, path, canvas, (resultImageData: ImageData) => {
|
||||
localforage.setItem(
|
||||
`${coverId}-cover-cache`,
|
||||
imageDataToBlob(resultImageData)
|
||||
);
|
||||
canvas.style.opacity = '1';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
}
|
||||
canvas {
|
||||
position: relative;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: .45s;
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
</style>
|
@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
export let coverPath: Writable<string>;
|
||||
export let hasLyrics: boolean;
|
||||
let path: string = '';
|
||||
|
||||
coverPath.subscribe((p) => {
|
||||
if (p) path = p;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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)] 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}
|
||||
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]
|
||||
bottom-72 md:bottom-80 left-1/2 translate-x-[-50%]"
|
||||
src={path}
|
||||
alt="封面"
|
||||
/>
|
||||
{/if}
|
@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import formatDuration from "$lib/utils/formatDuration";
|
||||
import { formatViews } from "$lib/utils/formatViews";
|
||||
|
||||
export let songData: MusicMetadata;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="relative w-56 h-56 bg-zinc-300 dark:bg-zinc-600 rounded-lg overflow-hidden
|
||||
shadow-lg cursor-pointer justify-self-center"
|
||||
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full duration-100
|
||||
z-10 opacity-0 hover:opacity-100 bg-[rgba(0,0,0,0.15)]"
|
||||
>
|
||||
<a href={songData.url} class="absolute z-10 h-full w-full">
|
||||
|
||||
</a>
|
||||
<a
|
||||
class="brightness-125 absolute top-2 right-2 w-8 h-8 rounded-full
|
||||
bg-[rgba(49,49,49,0.7)] backdrop-blur-lg z-30 hover:bg-red-500"
|
||||
href={`/database/edit/${songData.id}`}
|
||||
>
|
||||
<img class="relative w-4 h-4 top-2 left-2 scale-90" src="/edit.svg" alt="编辑" />
|
||||
</a>
|
||||
</div>
|
||||
<img src={songData.coverURL[0]} class="w-56 h-56" alt="" />
|
||||
<div
|
||||
class="absolute bottom-0 w-full h-28 backdrop-blur-xl"
|
||||
style="mask-image: linear-gradient(to top, black 50%, transparent);"
|
||||
>
|
||||
<div class="absolute bottom-0 w-full h-16 pl-2">
|
||||
<span
|
||||
class="font-semibold text-2xl text-white"
|
||||
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);">{songData.name}</span
|
||||
>
|
||||
<br />
|
||||
<span
|
||||
class="relative inline-block whitespace-nowrap text-white w-28
|
||||
overflow-hidden text-ellipsis"
|
||||
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);"
|
||||
>
|
||||
{songData.producer}
|
||||
</span>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 text-right text-white"
|
||||
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);"
|
||||
>
|
||||
{#if songData.duration}
|
||||
<span>{formatDuration(songData.duration)}</span>
|
||||
{/if}
|
||||
<br />
|
||||
{#if songData.views}
|
||||
<span>{formatViews(songData.views)}播放</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,17 +0,0 @@
|
||||
<div class={$$props.class}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<g style="fill: rgb(0, 0, 0);"
|
||||
><path
|
||||
d="M0 7.818c0 .394.342.72.745.72h6.514v6.363a.742.742 0 0 0 1.482 0V8.538h6.514c.403 0 .745-.326.745-.72a.743.743 0 0 0-.745-.728H8.741V.728a.742.742 0 0 0-1.482 0V7.09H.745A.743.743 0 0 0 0 7.818Z"
|
||||
style="fill: rgb(0, 117, 255);"
|
||||
class="fills"
|
||||
/></g
|
||||
>
|
||||
</svg>
|
||||
</div>
|
@ -1,7 +0,0 @@
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size?: number;
|
||||
type: string;
|
||||
lastModified?: number;
|
||||
lastModifiedDate?: Date;
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
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 '$lib/utils/formatDuration';
|
||||
const items = useAtom(fileListState);
|
||||
const finalItems = useAtom(finalFileListState);
|
||||
let displayItems: any[] = [];
|
||||
|
||||
$: {
|
||||
const length = $items.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
if ($items[i].type.indexOf('audio') === -1) {
|
||||
finalItems.update((prev) => {
|
||||
return [...prev, $items[i]];
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if ($items[i].pic || $items[i].pic === 'N/A') continue;
|
||||
getAudioMeta($items[i], (metadata: IAudioMetadata) => {
|
||||
let cover: string | null = null;
|
||||
let duration: number | null = null;
|
||||
if (metadata.common.picture) cover = convertCoverData(metadata.common.picture[0]);
|
||||
if (metadata.format.duration) duration = metadata.format.duration;
|
||||
finalItems.update((prev) => {
|
||||
if (cover) {
|
||||
let currentItem = [];
|
||||
currentItem = $items[i];
|
||||
currentItem.pic = cover;
|
||||
currentItem.duration = duration;
|
||||
return [...prev, currentItem];
|
||||
} else {
|
||||
let currentItem = [];
|
||||
currentItem = $items[i];
|
||||
currentItem.pic = 'N/A';
|
||||
currentItem.duration = duration;
|
||||
return [...prev, currentItem];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
// remove duplicated
|
||||
displayItems = $finalItems.filter((item, index) => {
|
||||
return $finalItems.indexOf(item) === index;
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul
|
||||
class="mt-4 relative w-full min-h-48 max-h-[27rem] overflow-y-auto bg-zinc-200 dark:bg-zinc-800 rounded"
|
||||
>
|
||||
{#each displayItems as item}
|
||||
<li class="relative m-4 p-4 bg-zinc-300 dark:bg-zinc-600 rounded-lg">
|
||||
<span>{extractFileName(item.name)}</span> <br />
|
||||
<span>{toHumanSize(item.size)}</span>
|
||||
{#if item.type}
|
||||
· <span>{formatText(item.type)}</span>
|
||||
{:else if item.name.split('.').length > 1}
|
||||
· <span>{formatText(item.name.split('.')[item.name.split('.').length - 1])}</span>
|
||||
{:else}
|
||||
· <span>未知格式</span>
|
||||
{/if}
|
||||
{#if item.duration}
|
||||
· <span>{formatDuration(item.duration)}</span>
|
||||
{/if}
|
||||
{#if item.pic !== undefined && item.pic !== 'N/A'}
|
||||
<img
|
||||
class="h-16 w-16 object-cover absolute rounded-lg right-2 top-2"
|
||||
src={item.pic}
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let audioFiles: HTMLInputElement;
|
||||
import ImportIcon from './importIcon.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
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";
|
||||
|
||||
onMount(() => {
|
||||
audioFiles.addEventListener('change', function (e: any) {
|
||||
if (audioFiles.files) {
|
||||
fileItems.update((prev) => {
|
||||
if (audioFiles.files) {
|
||||
return [...prev, ...Array.from(audioFiles.files)];
|
||||
} else {
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<input style="display: none;" type="file" bind:this={audioFiles} multiple accept={accept} />
|
||||
<div class={$$props.class}>
|
||||
<button
|
||||
on:click={() => {
|
||||
audioFiles.click();
|
||||
}}
|
||||
>
|
||||
{#if $fileItems.length > 0}
|
||||
<AddIcon class="z-[1] relative text-3xl" />
|
||||
{:else}
|
||||
<ImportIcon class="z-[1] relative text-4xl" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
@ -1,15 +0,0 @@
|
||||
<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,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let icon: string;
|
||||
export let details: string;
|
||||
export let dest: string;
|
||||
import Icon from '@iconify/svelte';
|
||||
</script>
|
||||
|
||||
<a href={dest}>
|
||||
<div
|
||||
class="cursor-pointer flex relative min-h-20 h-fit p-4 w-full my-4 lg:m-4 border-2 border-zinc-400 dark:border-neutral-700 rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col justify-center text-4xl">
|
||||
<Icon {icon} />
|
||||
</div>
|
||||
|
||||
<div class="ml-4 flex flex-col justify-center">
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
<p>{details}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
@ -1,380 +0,0 @@
|
||||
<script lang="ts">
|
||||
import formatDuration from '$lib/utils/formatDuration';
|
||||
import { onMount } from 'svelte';
|
||||
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 = '';
|
||||
export let duration: number = 0;
|
||||
export let progress: number = 0;
|
||||
export let paused: boolean;
|
||||
export let volume: number = 1;
|
||||
export let clickPlay: Function;
|
||||
export let adjustProgress: Function;
|
||||
export let adjustDisplayProgress: Function;
|
||||
export let adjustVolume: Function;
|
||||
|
||||
export let hasLyrics: boolean;
|
||||
|
||||
let progressBar: HTMLDivElement;
|
||||
let volumeBar: HTMLDivElement;
|
||||
let showInfoTop: boolean = false;
|
||||
let isInfoTopOverflowing = false;
|
||||
let songInfoTopContainer: HTMLDivElement;
|
||||
let songInfoTopContent: HTMLSpanElement;
|
||||
let userAdjustingVolume = false;
|
||||
|
||||
const mql = window.matchMedia('(max-width: 1280px)');
|
||||
|
||||
function volumeBarOnChange(e: MouseEvent) {
|
||||
const value = e.offsetX / volumeBar.getBoundingClientRect().width;
|
||||
adjustVolume(value);
|
||||
localStorage.setItem('volume', value.toString());
|
||||
}
|
||||
|
||||
function volumeBarChangeTouch(e: TouchEvent) {
|
||||
const value = truncate(
|
||||
e.touches[0].clientX - volumeBar.getBoundingClientRect().x,
|
||||
0,
|
||||
volumeBar.getBoundingClientRect().width
|
||||
) / volumeBar.getBoundingClientRect().width;
|
||||
adjustVolume(value);
|
||||
localStorage.setItem('volume', value.toString());
|
||||
}
|
||||
|
||||
function progressBarOnClick(e: MouseEvent) {
|
||||
adjustProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||
progressBarSlideValue.set((e.offsetX / progressBar.getBoundingClientRect().width) * duration);
|
||||
}
|
||||
|
||||
function progressBarMouseUp(offsetX: number) {
|
||||
adjustDisplayProgress(offsetX / progressBar.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mql.addEventListener('change', (e) => {
|
||||
showInfoTop = e.matches && hasLyrics;
|
||||
});
|
||||
});
|
||||
|
||||
$: {
|
||||
if (songInfoTopContainer && songInfoTopContent) {
|
||||
isInfoTopOverflowing = songInfoTopContent.offsetWidth > songInfoTopContainer.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
showInfoTop = mql.matches && hasLyrics;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showInfoTop}
|
||||
<div class="absolute top-6 md:top-12 left-28 md:left-48 lg:left-64 flex-col">
|
||||
<span class="song-name text-shadow">{name}</span><br />
|
||||
<span class="song-author">{singer}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
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]')}
|
||||
>
|
||||
{#if !showInfoTop}
|
||||
<div class="song-info">
|
||||
<div class="song-info-regular {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
|
||||
<span
|
||||
class="song-name text-shadow {isInfoTopOverflowing ? 'animate' : ''}"
|
||||
bind:this={songInfoTopContent}>{name}</span
|
||||
>
|
||||
</div>
|
||||
<span class="song-author text-shadow-lg">{singer}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="progress top-16">
|
||||
<div class="time-indicator text-shadow-md time-current">
|
||||
{formatDuration(progress)}
|
||||
</div>
|
||||
<div
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={progress}
|
||||
bind:this={progressBar}
|
||||
class="progress-bar shadow-md"
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:mousedown={() => {
|
||||
userAdjustingProgress.set(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" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||
</div>
|
||||
|
||||
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
||||
</div>
|
||||
<div class="controls top-32 flex h-16 overflow-hidden items-center justify-center">
|
||||
<button class="control-btn previous" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||
<img alt="上一曲" class="control-img switch-song-img" src="/previous.svg" />
|
||||
</button>
|
||||
<button
|
||||
class="control-btn play-btn"
|
||||
on:click={(e) => clickPlay()}
|
||||
on:focus={null}
|
||||
on:mouseleave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
}}
|
||||
on:mouseover={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
|
||||
}}
|
||||
on:touchend={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
e.currentTarget.style.scale = '1';
|
||||
clickPlay();
|
||||
}}
|
||||
on:touchstart={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
|
||||
e.currentTarget.style.scale = '0.8';
|
||||
}}
|
||||
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
||||
>
|
||||
<img alt={paused ? '播放' : '暂停'} class="control-img" src={paused ? '/play.svg' : '/pause.svg'} />
|
||||
</button>
|
||||
<button class="control-btn next" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||
<img alt="下一曲" class="control-img switch-song-img" src="/next.svg" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative top-52 h-6 flex">
|
||||
<img alt="最小音量" class="scale-75" src="/volumeDown.svg" />
|
||||
<div
|
||||
aria-valuemax="1"
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={volume}
|
||||
bind:this={volumeBar}
|
||||
class="progress-bar shadow-md !top-1/2 !translate-y-[-50%]"
|
||||
on:click={(e) => volumeBarOnChange(e)}
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:mousedown={() => {
|
||||
userAdjustingVolume = true;
|
||||
}}
|
||||
on:mousemove={(e) => {
|
||||
if (userAdjustingVolume) {
|
||||
volumeBarOnChange(e);
|
||||
}
|
||||
}}
|
||||
on:mouseup={() => {
|
||||
userAdjustingVolume = false;
|
||||
}}
|
||||
on:touchend={(e) => {
|
||||
e.preventDefault();
|
||||
userAdjustingVolume = false;
|
||||
}}
|
||||
on:touchmove={(e) => {
|
||||
e.preventDefault();
|
||||
userAdjustingVolume = true;
|
||||
if (userAdjustingVolume) {
|
||||
volumeBarChangeTouch(e);
|
||||
}
|
||||
}}
|
||||
on:touchstart={(e) => {
|
||||
if (e.cancelable) {
|
||||
e.preventDefault();
|
||||
}
|
||||
userAdjustingVolume = true;
|
||||
}}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="bar" style={`width: ${volume * 100}%;`}></div>
|
||||
</div>
|
||||
<img alt="最大音量" class="scale-75" src="/volumeUp.svg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--suppress CssUnusedSymbol, CssUnusedSymbol -->
|
||||
<style>
|
||||
.controls {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-block;
|
||||
height: 3.7rem;
|
||||
width: 5rem;
|
||||
cursor: pointer;
|
||||
margin: 0 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: 0.45s;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.control-img {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.switch-song-img {
|
||||
width: auto !important;
|
||||
height: 1.7rem !important;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
user-select: text;
|
||||
position: absolute;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
top: 1rem;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.song-info-regular {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 2.375rem;
|
||||
}
|
||||
|
||||
.song-info-regular.animate {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
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%
|
||||
);
|
||||
}
|
||||
|
||||
.song-name {
|
||||
position: relative;
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.5rem;
|
||||
overflow-y: auto;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
scrollbar-width: none;
|
||||
height: 2.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.song-name.animate {
|
||||
animation: scroll 10s linear infinite;
|
||||
}
|
||||
|
||||
.song-name::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.song-author {
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
top: 1.8rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0.4rem;
|
||||
background-color: rgba(64, 64, 64, 0.5);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar:hover {
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 0.4rem;
|
||||
display: inline-block;
|
||||
border-radius: 1rem;
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar:hover .bar {
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
.time-indicator {
|
||||
width: fit-content;
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
line-height: 1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: inline-block;
|
||||
top: 0.2rem;
|
||||
}
|
||||
|
||||
.time-current {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.time-total {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.control-btn {
|
||||
transition: 0.1s
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,174 +0,0 @@
|
||||
<script lang="ts">
|
||||
import createSpring from '@core/graphics/spring';
|
||||
import type { ScriptItem } from '@core/lyrics/type';
|
||||
import type { LyricPos } from './type';
|
||||
import type { Spring } from '@core/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>
|
@ -1,265 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { LrcJsonData } from '@core/lyrics/type';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ScriptItem } from '@core/lyrics/type';
|
||||
import LyricLine from './lyricLine.svelte';
|
||||
import createLyricsSearcher from '@core/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}
|
@ -1,4 +0,0 @@
|
||||
export interface LyricPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
import { atom } from 'jotai-svelte'
|
||||
|
||||
export const fileListState = atom([] as any[]);
|
||||
export const finalFileListState = atom([] as any[]);
|
@ -1,4 +0,0 @@
|
||||
import { atom } from 'jotai-svelte'
|
||||
|
||||
export const localImportSuccess = atom([] as any[]);
|
||||
export const localImportFailed = atom([] as any[]);
|
@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const nextUpdate = writable(-1);
|
||||
export default nextUpdate;
|
@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const progressBarRaw = writable(0);
|
||||
export default progressBarRaw;
|
@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const progressBarSlideValue = writable(0);
|
||||
export default progressBarSlideValue;
|
@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const userAdjustingProgress = writable(false);
|
||||
export default userAdjustingProgress;
|
@ -1,3 +0,0 @@
|
||||
export * from "./parser";
|
||||
export * from "./writer";
|
||||
export type * from "./ttml-types";
|
@ -1,168 +0,0 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 解析 TTML 歌词文档到歌词数组的解析器
|
||||
* 用于解析从 Apple Music 来的歌词文件,且扩展并支持翻译和音译文本。
|
||||
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
|
||||
*/
|
||||
|
||||
import type {
|
||||
LyricLine,
|
||||
LyricWord,
|
||||
TTMLLyric,
|
||||
TTMLMetadata,
|
||||
} from "./ttml-types";
|
||||
|
||||
const timeRegexp =
|
||||
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
|
||||
function parseTimespan(timeSpan: string): number {
|
||||
const matches = timeRegexp.exec(timeSpan);
|
||||
if (matches) {
|
||||
const hour = Number(matches.groups?.hour || "0");
|
||||
const min = Number(matches.groups?.min || "0");
|
||||
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
||||
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
||||
}
|
||||
throw new TypeError(`时间戳字符串解析失败:${timeSpan}`);
|
||||
}
|
||||
|
||||
export function parseTTML(ttmlText: string): TTMLLyric {
|
||||
const domParser = new DOMParser();
|
||||
const ttmlDoc: XMLDocument = domParser.parseFromString(
|
||||
ttmlText,
|
||||
"application/xml",
|
||||
);
|
||||
|
||||
let mainAgentId = "v1";
|
||||
|
||||
const metadata: TTMLMetadata[] = [];
|
||||
for (const meta of ttmlDoc.querySelectorAll("meta")) {
|
||||
if (meta.tagName === "amll:meta") {
|
||||
const key = meta.getAttribute("key");
|
||||
if (key) {
|
||||
const value = meta.getAttribute("value");
|
||||
if (value) {
|
||||
const existing = metadata.find((m) => m.key === key);
|
||||
if (existing) {
|
||||
existing.value.push(value);
|
||||
} else {
|
||||
metadata.push({
|
||||
key,
|
||||
value: [value],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
|
||||
if (agent.getAttribute("type") === "person") {
|
||||
const id = agent.getAttribute("xml:id");
|
||||
if (id) {
|
||||
mainAgentId = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lyricLines: LyricLine[] = [];
|
||||
|
||||
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
|
||||
const line: LyricLine = {
|
||||
words: [],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG,
|
||||
isDuet:
|
||||
!!lineEl.getAttribute("ttm:agent") &&
|
||||
lineEl.getAttribute("ttm:agent") !== mainAgentId,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
};
|
||||
if (isBG) line.isDuet = isDuet;
|
||||
let haveBg = false;
|
||||
|
||||
for (const wordNode of lineEl.childNodes) {
|
||||
if (wordNode.nodeType === Node.TEXT_NODE) {
|
||||
line.words?.push({
|
||||
word: wordNode.textContent ?? "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
});
|
||||
} else if (wordNode.nodeType === Node.ELEMENT_NODE) {
|
||||
const wordEl = wordNode as Element;
|
||||
const role = wordEl.getAttribute("ttm:role");
|
||||
|
||||
if (wordEl.nodeName === "span" && role) {
|
||||
if (role === "x-bg") {
|
||||
parseParseLine(wordEl, true, line.isDuet);
|
||||
haveBg = true;
|
||||
} else if (role === "x-translation") {
|
||||
line.translatedLyric = wordEl.innerHTML;
|
||||
} else if (role === "x-roman") {
|
||||
line.romanLyric = wordEl.innerHTML;
|
||||
}
|
||||
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
||||
const word: LyricWord = {
|
||||
word: wordNode.textContent ?? "",
|
||||
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
||||
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
||||
};
|
||||
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
||||
if (emptyBeat) {
|
||||
word.emptyBeat = Number(emptyBeat);
|
||||
}
|
||||
line.words.push(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.isBG) {
|
||||
const firstWord = line.words?.[0];
|
||||
if (firstWord?.word.startsWith("(")) {
|
||||
firstWord.word = firstWord.word.substring(1);
|
||||
if (firstWord.word.length === 0) {
|
||||
line.words.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const lastWord = line.words?.[line.words.length - 1];
|
||||
if (lastWord?.word.endsWith(")")) {
|
||||
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
||||
if (lastWord.word.length === 0) {
|
||||
line.words.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = lineEl.getAttribute("begin");
|
||||
const endTime = lineEl.getAttribute("end");
|
||||
if (startTime && endTime) {
|
||||
line.startTime = parseTimespan(startTime);
|
||||
line.endTime = parseTimespan(endTime);
|
||||
} else {
|
||||
line.startTime = line.words
|
||||
.filter((v) => v.word.trim().length > 0)
|
||||
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
||||
line.endTime = line.words
|
||||
.filter((v) => v.word.trim().length > 0)
|
||||
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
||||
}
|
||||
|
||||
if (haveBg) {
|
||||
const bgLine = lyricLines.pop();
|
||||
lyricLines.push(line);
|
||||
if (bgLine) lyricLines.push(bgLine);
|
||||
} else {
|
||||
lyricLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
||||
parseParseLine(lineEl);
|
||||
}
|
||||
|
||||
return {
|
||||
metadata,
|
||||
lyricLines: lyricLines,
|
||||
};
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
export interface TTMLMetadata {
|
||||
key: string;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface TTMLLyric {
|
||||
metadata: TTMLMetadata[];
|
||||
lyricLines: LyricLine[];
|
||||
}
|
||||
|
||||
export interface LyricWord {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
word: string;
|
||||
emptyBeat?: number;
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
words: LyricWord[];
|
||||
translatedLyric: string;
|
||||
romanLyric: string;
|
||||
isBG: boolean;
|
||||
isDuet: boolean;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
@ -1,260 +0,0 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 用于将内部歌词数组对象导出成 TTML 格式的模块
|
||||
* 但是可能会有信息会丢失
|
||||
*/
|
||||
|
||||
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
||||
|
||||
function msToTimestamp(timeMS: number): string {
|
||||
let time = timeMS;
|
||||
if (!Number.isSafeInteger(time) || time < 0) {
|
||||
return "00:00.000";
|
||||
}
|
||||
if (time === Infinity) {
|
||||
return "99:99.999";
|
||||
}
|
||||
time = time / 1000;
|
||||
const secs = time % 60;
|
||||
time = (time - secs) / 60;
|
||||
const mins = time % 60;
|
||||
const hrs = (time - mins) / 60;
|
||||
|
||||
const h = hrs.toString().padStart(2, "0");
|
||||
const m = mins.toString().padStart(2, "0");
|
||||
const s = secs.toFixed(3).padStart(6, "0");
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
||||
const params: LyricLine[][] = [];
|
||||
const lyric = ttmlLyric.lyricLines;
|
||||
|
||||
let tmp: LyricLine[] = [];
|
||||
for (const line of lyric) {
|
||||
if (line.words.length === 0 && tmp.length > 0) {
|
||||
params.push(tmp);
|
||||
tmp = [];
|
||||
} else {
|
||||
tmp.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (tmp.length > 0) {
|
||||
params.push(tmp);
|
||||
}
|
||||
|
||||
const doc = new Document();
|
||||
|
||||
function createWordElement(word: LyricWord): Element {
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("begin", msToTimestamp(word.startTime));
|
||||
span.setAttribute("end", msToTimestamp(word.endTime));
|
||||
if (word.emptyBeat) {
|
||||
span.setAttribute("amll:empty-beat", `${word.emptyBeat}`);
|
||||
}
|
||||
span.appendChild(doc.createTextNode(word.word));
|
||||
return span;
|
||||
}
|
||||
|
||||
const ttRoot = doc.createElement("tt");
|
||||
|
||||
ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml");
|
||||
ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata");
|
||||
ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll");
|
||||
ttRoot.setAttribute(
|
||||
"xmlns:itunes",
|
||||
"http://music.apple.com/lyric-ttml-internal",
|
||||
);
|
||||
|
||||
doc.appendChild(ttRoot);
|
||||
|
||||
const head = doc.createElement("head");
|
||||
|
||||
ttRoot.appendChild(head);
|
||||
|
||||
const body = doc.createElement("body");
|
||||
const hasOtherPerson = !!lyric.find((v) => v.isDuet);
|
||||
|
||||
const metadataEl = doc.createElement("metadata");
|
||||
const mainPersonAgent = doc.createElement("ttm:agent");
|
||||
mainPersonAgent.setAttribute("type", "person");
|
||||
mainPersonAgent.setAttribute("xml:id", "v1");
|
||||
|
||||
metadataEl.appendChild(mainPersonAgent);
|
||||
|
||||
if (hasOtherPerson) {
|
||||
const otherPersonAgent = doc.createElement("ttm:agent");
|
||||
otherPersonAgent.setAttribute("type", "other");
|
||||
otherPersonAgent.setAttribute("xml:id", "v2");
|
||||
|
||||
metadataEl.appendChild(otherPersonAgent);
|
||||
}
|
||||
|
||||
for (const metadata of ttmlLyric.metadata) {
|
||||
for (const value of metadata.value) {
|
||||
const metaEl = doc.createElement("amll:meta");
|
||||
metaEl.setAttribute("key", metadata.key);
|
||||
metaEl.setAttribute("value", value);
|
||||
metadataEl.appendChild(metaEl);
|
||||
}
|
||||
}
|
||||
|
||||
head.appendChild(metadataEl);
|
||||
|
||||
let i = 0;
|
||||
|
||||
const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0;
|
||||
body.setAttribute("dur", msToTimestamp(guessDuration));
|
||||
|
||||
for (const param of params) {
|
||||
const paramDiv = doc.createElement("div");
|
||||
const beginTime = param[0]?.startTime ?? 0;
|
||||
const endTime = param[param.length - 1]?.endTime ?? 0;
|
||||
|
||||
paramDiv.setAttribute("begin", msToTimestamp(beginTime));
|
||||
paramDiv.setAttribute("end", msToTimestamp(endTime));
|
||||
|
||||
for (let lineIndex = 0; lineIndex < param.length; lineIndex++) {
|
||||
const line = param[lineIndex];
|
||||
const lineP = doc.createElement("p");
|
||||
const beginTime = line.startTime ?? 0;
|
||||
const endTime = line.endTime;
|
||||
|
||||
lineP.setAttribute("begin", msToTimestamp(beginTime));
|
||||
lineP.setAttribute("end", msToTimestamp(endTime));
|
||||
|
||||
lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1");
|
||||
lineP.setAttribute("itunes:key", `L${++i}`);
|
||||
|
||||
if (line.words.length > 1) {
|
||||
let beginTime = Infinity;
|
||||
let endTime = 0;
|
||||
for (const word of line.words) {
|
||||
if (word.word.trim().length === 0) {
|
||||
lineP.appendChild(doc.createTextNode(word.word));
|
||||
} else {
|
||||
const span = createWordElement(word);
|
||||
lineP.appendChild(span);
|
||||
beginTime = Math.min(beginTime, word.startTime);
|
||||
endTime = Math.max(endTime, word.endTime);
|
||||
}
|
||||
}
|
||||
lineP.setAttribute("begin", msToTimestamp(line.startTime));
|
||||
lineP.setAttribute("end", msToTimestamp(line.endTime));
|
||||
} else if (line.words.length === 1) {
|
||||
const word = line.words[0];
|
||||
lineP.appendChild(doc.createTextNode(word.word));
|
||||
lineP.setAttribute("begin", msToTimestamp(word.startTime));
|
||||
lineP.setAttribute("end", msToTimestamp(word.endTime));
|
||||
}
|
||||
|
||||
const nextLine = param[lineIndex + 1];
|
||||
if (nextLine?.isBG) {
|
||||
lineIndex++;
|
||||
const bgLine = nextLine;
|
||||
const bgLineSpan = doc.createElement("span");
|
||||
bgLineSpan.setAttribute("ttm:role", "x-bg");
|
||||
|
||||
if (bgLine.words.length > 1) {
|
||||
let beginTime = Infinity;
|
||||
let endTime = 0;
|
||||
for (
|
||||
let wordIndex = 0;
|
||||
wordIndex < bgLine.words.length;
|
||||
wordIndex++
|
||||
) {
|
||||
const word = bgLine.words[wordIndex];
|
||||
if (word.word.trim().length === 0) {
|
||||
bgLineSpan.appendChild(doc.createTextNode(word.word));
|
||||
} else {
|
||||
const span = createWordElement(word);
|
||||
if (wordIndex === 0) {
|
||||
span.prepend(doc.createTextNode("("));
|
||||
} else if (wordIndex === bgLine.words.length - 1) {
|
||||
span.appendChild(doc.createTextNode(")"));
|
||||
}
|
||||
bgLineSpan.appendChild(span);
|
||||
beginTime = Math.min(beginTime, word.startTime);
|
||||
endTime = Math.max(endTime, word.endTime);
|
||||
}
|
||||
}
|
||||
bgLineSpan.setAttribute("begin", msToTimestamp(beginTime));
|
||||
bgLineSpan.setAttribute("end", msToTimestamp(endTime));
|
||||
} else if (bgLine.words.length === 1) {
|
||||
const word = bgLine.words[0];
|
||||
bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`));
|
||||
bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime));
|
||||
bgLineSpan.setAttribute("end", msToTimestamp(word.endTime));
|
||||
}
|
||||
|
||||
if (bgLine.translatedLyric) {
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("ttm:role", "x-translation");
|
||||
span.setAttribute("xml:lang", "zh-CN");
|
||||
span.appendChild(doc.createTextNode(bgLine.translatedLyric));
|
||||
bgLineSpan.appendChild(span);
|
||||
}
|
||||
|
||||
if (bgLine.romanLyric) {
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("ttm:role", "x-roman");
|
||||
span.appendChild(doc.createTextNode(bgLine.romanLyric));
|
||||
bgLineSpan.appendChild(span);
|
||||
}
|
||||
|
||||
lineP.appendChild(bgLineSpan);
|
||||
}
|
||||
|
||||
if (line.translatedLyric) {
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("ttm:role", "x-translation");
|
||||
span.setAttribute("xml:lang", "zh-CN");
|
||||
span.appendChild(doc.createTextNode(line.translatedLyric));
|
||||
lineP.appendChild(span);
|
||||
}
|
||||
|
||||
if (line.romanLyric) {
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("ttm:role", "x-roman");
|
||||
span.appendChild(doc.createTextNode(line.romanLyric));
|
||||
lineP.appendChild(span);
|
||||
}
|
||||
|
||||
paramDiv.appendChild(lineP);
|
||||
}
|
||||
|
||||
body.appendChild(paramDiv);
|
||||
}
|
||||
|
||||
ttRoot.appendChild(body);
|
||||
|
||||
if (pretty) {
|
||||
const xsltDoc = new DOMParser().parseFromString(
|
||||
[
|
||||
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
||||
' <xsl:strip-space elements="*"/>',
|
||||
' <xsl:template match="para[content-style][not(text())]">',
|
||||
' <xsl:value-of select="normalize-space(.)"/>',
|
||||
" </xsl:template>",
|
||||
' <xsl:template match="node()|@*">',
|
||||
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||
" </xsl:template>",
|
||||
' <xsl:output indent="yes"/>',
|
||||
"</xsl:stylesheet>",
|
||||
].join("\n"),
|
||||
"application/xml",
|
||||
);
|
||||
|
||||
const xsltProcessor = new XSLTProcessor();
|
||||
xsltProcessor.importStylesheet(xsltDoc);
|
||||
const resultDoc = xsltProcessor.transformToDocument(doc);
|
||||
|
||||
return new XMLSerializer().serializeToString(resultDoc);
|
||||
}
|
||||
return new XMLSerializer().serializeToString(doc);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
export default function(dataObject: any) {
|
||||
// Create a blob from the UInt8Array data
|
||||
const blob = new Blob([dataObject.data], { type: dataObject.format });
|
||||
|
||||
// Create a URL for the blob
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Create an Image object
|
||||
const image = new Image();
|
||||
image.src = imageUrl;
|
||||
|
||||
return imageUrl; // return the URL of the image
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export default function(fullname: string){
|
||||
if (!fullname) return '';
|
||||
return fullname.split('.').slice(0, -1).join('.')
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
export default function(durationInSeconds: number): string {
|
||||
// Calculate hours, minutes, and seconds
|
||||
const hours = Math.floor(durationInSeconds / 3600);
|
||||
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(durationInSeconds) % 60;
|
||||
|
||||
// Format hours, minutes, and seconds into string
|
||||
let formattedTime = '';
|
||||
if (hours > 0) {
|
||||
formattedTime += hours + ':';
|
||||
}
|
||||
if (minutes < 10 && hours > 0) {
|
||||
formattedTime += '0';
|
||||
}
|
||||
formattedTime += minutes + ':';
|
||||
formattedTime += (seconds < 10 ? '0' : '') + seconds;
|
||||
|
||||
return formattedTime;
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
export default function(key: string){
|
||||
const dict = {
|
||||
"audio/mpeg": "MP3 音频",
|
||||
"audio/ogg": "OGG 容器",
|
||||
"audio/flac": "FLAC 无损音频",
|
||||
"audio/aac": "AAC 音频",
|
||||
"lrc": "LRC 歌词"
|
||||
}
|
||||
if (!key) return "未知格式";
|
||||
else return dict[key as keyof typeof dict];
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
export function formatViews(num: number): string {
|
||||
if (num >= 10000) {
|
||||
const formattedNum = Math.floor(num / 1000) / 10; // 向下保留1位小数
|
||||
return `${formattedNum} 万`;
|
||||
} else {
|
||||
return num.toString() + " ";
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import * as musicMetadata from 'music-metadata-browser';
|
||||
|
||||
export default function getAudioMeta(audio: File, callback: Function) {
|
||||
musicMetadata.parseBlob(audio).then((metadata) => {
|
||||
if (metadata)
|
||||
callback(metadata);
|
||||
else
|
||||
callback(null);
|
||||
})
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import * as pjson from "../../../package.json";
|
||||
|
||||
export default function getVersion(){
|
||||
return pjson.version;
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export default function toHumanSize(size: number | undefined){
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let unitIndex = 0;
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
export function getCurrentFormattedDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0'); // getMonth() is zero-based
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
localforage.config({
|
||||
driver: localforage.INDEXEDDB,
|
||||
name: 'audioDB'
|
||||
});
|
||||
|
||||
export default localforage;
|
@ -1,3 +0,0 @@
|
||||
export default function truncate(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export const ssr = false;
|
@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
coverUrl?: string;
|
||||
size?: number;
|
||||
}
|
||||
interface SongList {
|
||||
[key: string]: Song;
|
||||
}
|
||||
let musicList: SongList = {};
|
||||
let idList: string[] = [];
|
||||
|
||||
function extractId(key: string) {
|
||||
const addons = ['-cover-cache', '-file', '-lyric', '-metadata', '-cover'];
|
||||
let r = key;
|
||||
for (const addon of addons) {
|
||||
if (r.endsWith(addon)) {
|
||||
return [r.substring(0, r.length - addon.length), addon.replace(/-/g, ' ').trim()];
|
||||
}
|
||||
}
|
||||
return [r, ''];
|
||||
}
|
||||
localforage.iterate(function (value: File | Blob | any, key, iterationNumber) {
|
||||
const [id, type] = extractId(key);
|
||||
if (!type) return;
|
||||
if (!musicList[id]) musicList[id] = { name: '' };
|
||||
if (type === 'file') {
|
||||
const v = value as File;
|
||||
musicList[id].name = extractFileName(v.name);
|
||||
musicList[id].size = v.size;
|
||||
} else if (type === 'cover') {
|
||||
const v = value as Blob;
|
||||
musicList[id].coverUrl = URL.createObjectURL(v);
|
||||
}
|
||||
idList = Object.keys(musicList);
|
||||
});
|
||||
|
||||
function clear() {
|
||||
localforage.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Aquavox - 音乐库</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="absolute w-screen md:w-2/3 lg:w-1/2 left-0 md:left-[16.6667%] lg:left-1/4 px-[3%] md:px-0 top-16"
|
||||
>
|
||||
<h1>AquaVox</h1>
|
||||
<h2>音乐库</h2>
|
||||
<div>
|
||||
<ul class="mt-4 relative w-full">
|
||||
{#each idList as 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 />
|
||||
<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"
|
||||
src={musicList[id].coverUrl}
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
AquaVox {getVersion()} · 早期公开预览 · 源代码参见
|
||||
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
||||
</p>
|
||||
<a href="/import">导入音乐</a> <br />
|
||||
<button
|
||||
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>
|
||||
<p>你可以在这里探索,提交和分享好听的歌曲。</p>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
a {
|
||||
@apply text-red-500 hover:text-red-400 duration-150 underline;
|
||||
}
|
||||
</style>
|
@ -1,3 +0,0 @@
|
||||
<div class="absolute w-screen md:w-2/3 lg:w-1/2 left-0 md:left-[16.6667%] lg:left-1/4 px-[3%] md:px-0 top-16">
|
||||
<slot />
|
||||
</div>
|
@ -1,17 +0,0 @@
|
||||
import { songData } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
loadData();
|
||||
const songIDList = songData.keys().slice(0, 20);
|
||||
const songDataList = [];
|
||||
for (const songID of songIDList) {
|
||||
songDataList.push(songData.get(songID)!);
|
||||
}
|
||||
return {
|
||||
songDataList: songDataList
|
||||
};
|
||||
};
|
||||
|
||||
export const ssr = true;
|
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SongCard from '@core/components/database/songCard.svelte';
|
||||
import { type MusicMetadata } from '@core/server/database/musicInfo';
|
||||
import type { PageServerData } from './$types';
|
||||
export let data: PageServerData;
|
||||
let songList: MusicMetadata[] = data.songDataList;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AquaVox 音乐数据库</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center h-20 mb-8">
|
||||
<h1>AquaVox 音乐数据库</h1>
|
||||
<a
|
||||
href="/database/submit"
|
||||
class="h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
>提交新曲</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative grid mb-32"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
justify-content: space-between;
|
||||
gap: 2rem 1rem;"
|
||||
>
|
||||
{#each songList as song}
|
||||
<SongCard songData={song}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
@ -1,23 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { safePath } from '$lib/server/safePath';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||
if (!filePath) {
|
||||
return {
|
||||
songData: null
|
||||
};
|
||||
}
|
||||
try {
|
||||
const dataBuffer = fs.readFileSync(filePath);
|
||||
const data = JSON.parse(dataBuffer.toString());
|
||||
return {
|
||||
songData: data
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
songData: null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
import { page } from '$app/stores';
|
||||
const songID = $page.params.id;
|
||||
let editingData: string = JSON.stringify(data.songData, null, 8);
|
||||
|
||||
async function submit() {
|
||||
fetch(`/api/database/song/${songID}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: editingData
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>建议编辑: {data.songData.name} ({songID})</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/database/">AquaVox 音乐数据库</a></h1>
|
||||
|
||||
<h1>建议编辑: {data.songData.name} ({songID})</h1>
|
||||
|
||||
<textarea bind:value={editingData} class="dark:bg-zinc-600 w-full min-h-[30rem] mt-6" />
|
||||
|
||||
<button
|
||||
class="mt-4 mb-32 h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
on:click={() => {
|
||||
submit();
|
||||
}}>提交</button
|
||||
>
|
@ -1,18 +0,0 @@
|
||||
import { songData } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const offset = (parseInt(params.id) - 1) * 20;
|
||||
loadData();
|
||||
const songIDList = songData.keys().slice(offset, offset + 20);
|
||||
const songDataList = [];
|
||||
for (const songID of songIDList) {
|
||||
songDataList.push(songData.get(songID)!);
|
||||
}
|
||||
return {
|
||||
songDataList: songDataList
|
||||
};
|
||||
};
|
||||
|
||||
export const ssr = true;
|
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SongCard from '@core/components/database/songCard.svelte';
|
||||
import { type MusicMetadata } from '@core/server/database/musicInfo';
|
||||
import type { PageServerData } from './$types';
|
||||
export let data: PageServerData;
|
||||
let songList: MusicMetadata[] = data.songDataList;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AquaVox 音乐数据库</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center h-20 mb-8">
|
||||
<h1>AquaVox 音乐数据库</h1>
|
||||
<a
|
||||
href="/database/submit"
|
||||
class="h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
>提交新曲</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative grid mb-32"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
justify-content: space-between;
|
||||
gap: 2rem 1rem;"
|
||||
>
|
||||
{#each songList as song}
|
||||
<SongCard songData={song}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||
import {type MusicMetadata } from "@core/server/database/musicInfo"
|
||||
let templateSongData: MusicMetadata = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
singer: [],
|
||||
producer: '',
|
||||
tuning: [],
|
||||
lyricist: [],
|
||||
composer: [],
|
||||
arranger: [],
|
||||
mixing: [],
|
||||
pv: [],
|
||||
illustrator: [],
|
||||
harmony: [],
|
||||
instruments: [],
|
||||
songURL: [],
|
||||
coverURL: [],
|
||||
duration: null,
|
||||
views: null,
|
||||
publishTime: null,
|
||||
updateTime: getCurrentFormattedDateTime(),
|
||||
netEaseID: null,
|
||||
lyric: null
|
||||
};
|
||||
let editingData: string = JSON.stringify(templateSongData, null, 8);
|
||||
|
||||
async function submit() {
|
||||
const dataToSubmit: MusicMetadata = JSON.parse(editingData);
|
||||
fetch(`/api/database/song/${dataToSubmit.id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: editingData
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>提交新曲</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/database/">AquaVox 音乐数据库</a></h1>
|
||||
|
||||
<h1>提交新曲</h1>
|
||||
|
||||
<textarea bind:value={editingData} class="dark:bg-zinc-600 w-full min-h-[36rem] mt-6" />
|
||||
|
||||
<button
|
||||
class="mt-4 mb-32 h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
on:click={() => {
|
||||
submit();
|
||||
}}>提交</button
|
||||
>
|
@ -1,3 +0,0 @@
|
||||
<div class="absolute w-screen md:w-2/3 lg:w-1/2 left-0 md:left-[16.6667%] lg:left-1/4 px-[3%] md:px-0 top-16">
|
||||
<slot />
|
||||
</div>
|
@ -1,12 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SourceCard from "@core/components/import/sourceCard.svelte";
|
||||
|
||||
</script>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
<h1>导入</h1>
|
||||
<p class="leading-10">希望从哪里导入你的歌曲?</p>
|
||||
<SourceCard title="本地" dest="/import/local" details="从本地导入歌曲添加到 AquaVox 的本地音乐库。
|
||||
音乐文件将保存在浏览器中,因此无法跨浏览器聆听。若希望能在多个设备或浏览器上聆听,请考虑跨设备同步功能。" icon="uil:import" />
|
||||
<SourceCard title="哔哩哔哩" dest="/import/bilibili" details="通过导入哔哩哔哩的公开收藏夹或若干给定BV号的视频,你可以将自己在哔哩哔哩中喜爱的歌曲导入到 AquaVox。
|
||||
但需要注意,此选项的可用性无法保证。" icon="ri:bilibili-fill" />
|
@ -1 +0,0 @@
|
||||
export const ssr = false;
|
@ -1,42 +0,0 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import FileList from '@core/components/import/fileList.svelte';
|
||||
import FileSelector from '@core/components/import/fileSelector.svelte';
|
||||
import localforage from '$lib/utils/storage';
|
||||
import { fileListState } from '$lib/state/fileList.state';
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
const fileList = useAtom(fileListState);
|
||||
const audioId = $page.params.id;
|
||||
let status = "";
|
||||
</script>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
<h1>歌词导入</h1>
|
||||
<p>当前为 <span class="text-zinc-700 dark:text-zinc-400">{audioId}</span> 导入歌词</p>
|
||||
|
||||
<div class="w-full flex my-3">
|
||||
<h2>歌词文件</h2>
|
||||
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
|
||||
</div>
|
||||
|
||||
<FileList />
|
||||
|
||||
<p class="mt-4">
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
class="mt-1 bg-blue-500 hover:bg-blue-600 duration-200 text-white font-bold py-2 px-5 rounded"
|
||||
on:click={() => {
|
||||
for (let file of $fileList) {
|
||||
localforage.setItem(audioId + '-lyric', file, function (err) {
|
||||
if (err) {
|
||||
status = "歌词导入失败";
|
||||
} else {
|
||||
status = "已经导入。";
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
导入
|
||||
</button>
|
@ -1 +0,0 @@
|
||||
export const ssr = false;
|
@ -1,78 +0,0 @@
|
||||
<script>
|
||||
import FileList from '@core/components/import/fileList.svelte';
|
||||
import FileSelector from '@core/components/import/fileSelector.svelte';
|
||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
import localforage from '$lib/utils/storage';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
const fileList = useAtom(fileListState);
|
||||
const finalFiles = useAtom(finalFileListState);
|
||||
const failed = useAtom(localImportFailed);
|
||||
const success = useAtom(localImportSuccess);
|
||||
</script>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
<h1>本地导入向导</h1>
|
||||
<p>欢迎使用本地导入向导!</p>
|
||||
<p>
|
||||
你可以选择从本地导入你喜欢的音乐文件,并同时将封面、歌词、歌手与制作者等其他信息一并囊括其中。
|
||||
</p>
|
||||
|
||||
<div class="w-full flex my-3">
|
||||
<h2>音频</h2>
|
||||
<FileSelector class="ml-auto top-2 relative" />
|
||||
</div>
|
||||
|
||||
<FileList />
|
||||
|
||||
<p class="mt-4">
|
||||
<span>待处理 {$fileList.length} 个文件</span>
|
||||
{#if $success.length > 0}
|
||||
<span class="mt-4">{$success.length} 个文件导入成功</span>
|
||||
{/if}
|
||||
{#if $failed.length > 0}
|
||||
<span class="mt-4">{$failed.length} 个文件导入失败</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
class="mt-1 bg-blue-500 hover:bg-blue-600 duration-200 text-white font-bold py-2 px-5 rounded"
|
||||
on:click={() => {
|
||||
for (let file of $fileList) {
|
||||
let audioId = uuidv1();
|
||||
localforage.setItem(audioId + '-file', file, function (err) {
|
||||
if (err) {
|
||||
failed.update((prev) => [...prev, file]);
|
||||
} else {
|
||||
if (file.cover === 'N/A') {
|
||||
success.update((prev) => [...prev, file]);
|
||||
finalFiles.update((prev) => {
|
||||
return prev.filter((item) => item !== file);
|
||||
});
|
||||
fileList.update((prev) => {
|
||||
return prev.filter((item) => item !== file);
|
||||
});
|
||||
return;
|
||||
}
|
||||
let blob = fetch(file.pic).then((r) => {
|
||||
localforage.setItem(audioId + '-cover', r.blob(), function (err) {
|
||||
if (err) {
|
||||
failed.update((prev) => [...prev, file]);
|
||||
} else {
|
||||
success.update((prev) => [...prev, file]);
|
||||
finalFiles.update((prev) => {
|
||||
return prev.filter((item) => item !== file);
|
||||
});
|
||||
fileList.update((prev) => {
|
||||
return prev.filter((item) => item !== file);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
导入
|
||||
</button>
|
@ -1 +0,0 @@
|
||||
export const ssr = false;
|
@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import getAudioIDMetadata from '@core/audio/getAudioIDMetadata';
|
||||
import Background from '@core/components/background.svelte';
|
||||
import Cover from '@core/components/cover.svelte';
|
||||
import InteractiveBox from '@core/components/interactiveBox.svelte';
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import localforage from 'localforage';
|
||||
import { writable } from 'svelte/store';
|
||||
import lrcParser from '@core/lyrics/lrc/parser';
|
||||
import type { LrcJsonData } from '@core/lyrics/type';
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||
import { onMount } from 'svelte';
|
||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||
import { parseTTML } from '@core/lyrics/ttml';
|
||||
import NewLyrics from '@core/components/lyrics/newLyrics.svelte';
|
||||
|
||||
const audioId = $page.params.id;
|
||||
let audioPlayer: HTMLAudioElement | null = null;
|
||||
let volume = 1;
|
||||
let name = '';
|
||||
let singer = '';
|
||||
let duration = 0;
|
||||
let currentProgress = 0;
|
||||
let audioFile: File;
|
||||
let paused: boolean = true;
|
||||
let launched = false;
|
||||
let prepared: string[] = [];
|
||||
let originalLyrics: LrcJsonData;
|
||||
let lyricsText: string[] = [];
|
||||
let hasLyrics: boolean;
|
||||
const coverPath = writable('');
|
||||
let mainInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
function setMediaSession() {
|
||||
if ('mediaSession' in navigator === false) return;
|
||||
const ms = navigator.mediaSession;
|
||||
ms.metadata = new MediaMetadata({
|
||||
title: name,
|
||||
artist: singer,
|
||||
artwork: [
|
||||
{
|
||||
src: $coverPath
|
||||
}
|
||||
]
|
||||
});
|
||||
ms.setActionHandler('play', function () {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.play();
|
||||
paused = false;
|
||||
});
|
||||
|
||||
ms.setActionHandler('pause', function () {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.pause();
|
||||
paused = true;
|
||||
});
|
||||
|
||||
ms.setActionHandler('seekbackward', function () {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
ms.setActionHandler('previoustrack', function () {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readDB() {
|
||||
getAudioIDMetadata(audioId, (metadata: IAudioMetadata | null) => {
|
||||
if (!metadata) return;
|
||||
duration = metadata.format.duration ? metadata.format.duration : 0;
|
||||
singer = metadata.common.artist ? metadata.common.artist : '未知歌手';
|
||||
prepared.push('duration');
|
||||
});
|
||||
localforage.getItem(`${audioId}-cover`, function (err, file) {
|
||||
if (file) {
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file as File);
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 计算新的宽度和高度,确保宽度至少为1200px
|
||||
let newWidth = img.width;
|
||||
let newHeight = img.height;
|
||||
|
||||
console.log(newWidth)
|
||||
|
||||
if (newWidth < 1200) {
|
||||
newWidth = 1200;
|
||||
newHeight = (img.height * 1200) / img.width;
|
||||
}
|
||||
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// 绘制放大后的图片到canvas
|
||||
ctx!.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// 将canvas内容转换为Blob
|
||||
canvas.toBlob(function (blob) {
|
||||
const path = URL.createObjectURL(blob!);
|
||||
coverPath.set(path);
|
||||
}, 'image/jpeg'); // 你可以根据需要更改图片格式
|
||||
|
||||
prepared.push('cover');
|
||||
};
|
||||
|
||||
img.onerror = function () {
|
||||
console.error('Failed to load image');
|
||||
prepared.push('cover');
|
||||
};
|
||||
} else {
|
||||
prepared.push('cover');
|
||||
}
|
||||
});
|
||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||
if (audioPlayer === null) return;
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
audioFile = f;
|
||||
audioPlayer.src = URL.createObjectURL(audioFile);
|
||||
name = extractFileName(f.name);
|
||||
prepared.push('name');
|
||||
prepared.push('file');
|
||||
}
|
||||
});
|
||||
localforage.getItem(`${audioId}-lyric`, function (err, file) {
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
f.text().then((lr) => {
|
||||
if (f.name.endsWith('.ttml')) {
|
||||
originalLyrics = parseTTML(lr);
|
||||
for (const line of originalLyrics.scripts!) {
|
||||
lyricsText.push(line.text);
|
||||
}
|
||||
hasLyrics = true;
|
||||
} else if (f.name.endsWith('.lrc')) {
|
||||
originalLyrics = lrcParser(lr);
|
||||
if (!originalLyrics.scripts) return;
|
||||
for (const line of originalLyrics.scripts) {
|
||||
lyricsText.push(line.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.duration) {
|
||||
duration = audioPlayer.duration;
|
||||
}
|
||||
audioPlayer.paused ? audioPlayer.play() : audioPlayer.pause();
|
||||
paused = audioPlayer.paused;
|
||||
setMediaSession();
|
||||
}
|
||||
|
||||
$: {
|
||||
if (!launched && audioPlayer) {
|
||||
const requirements = ['name', 'file', 'cover'];
|
||||
let flag = true;
|
||||
for (const r of requirements) {
|
||||
if (!prepared.includes(r)) {
|
||||
flag = false;
|
||||
}
|
||||
}
|
||||
if (flag) {
|
||||
launched = true;
|
||||
setMediaSession();
|
||||
audioPlayer.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function adjustProgress(progress: number) {
|
||||
if (audioPlayer) {
|
||||
audioPlayer.currentTime = duration * progress;
|
||||
currentProgress = duration * progress;
|
||||
}
|
||||
}
|
||||
|
||||
function adjustDisplayProgress(progress: number) {
|
||||
if (audioPlayer) {
|
||||
currentProgress = duration * progress;
|
||||
}
|
||||
}
|
||||
|
||||
function adjustVolume(targetVolume: number) {
|
||||
if (audioPlayer) {
|
||||
audioPlayer.volume = targetVolume;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
clearInterval(mainInterval);
|
||||
mainInterval = setInterval(() => {
|
||||
if (audioPlayer === null) return;
|
||||
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
|
||||
progressBarRaw.set(audioPlayer.currentTime);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||
});
|
||||
|
||||
$: {
|
||||
if (audioPlayer) {
|
||||
paused = audioPlayer.paused;
|
||||
volume = audioPlayer.volume;
|
||||
}
|
||||
}
|
||||
|
||||
$: hasLyrics = !!originalLyrics;
|
||||
|
||||
readDB();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{name} - AquaVox</title>
|
||||
</svelte:head>
|
||||
|
||||
<Background coverId={audioId} />
|
||||
<Cover {coverPath} {hasLyrics} />
|
||||
<InteractiveBox
|
||||
{name}
|
||||
{singer}
|
||||
{duration}
|
||||
{volume}
|
||||
progress={currentProgress}
|
||||
clickPlay={playAudio}
|
||||
{paused}
|
||||
{adjustProgress}
|
||||
{adjustVolume}
|
||||
{adjustDisplayProgress}
|
||||
{hasLyrics}
|
||||
/>
|
||||
|
||||
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer} />
|
||||
|
||||
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
||||
|
||||
<audio
|
||||
bind:this={audioPlayer}
|
||||
controls
|
||||
style="display: none"
|
||||
on:play={() => {
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:pause={() => {
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:ended={() => {
|
||||
paused = true;
|
||||
if (audioPlayer == null) return;
|
||||
audioPlayer.pause();
|
||||
}}
|
||||
></audio>
|
@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 232.5-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20.5" height="17.0234">
|
||||
<g>
|
||||
<rect height="17.0234" opacity="0" width="20.5" x="0" y="0"/>
|
||||
<path d="M13.7656 17.0234C15.0469 17.0234 16.0938 15.9766 16.0938 14.6953C16.0938 13.4219 15.0469 12.375 13.7656 12.375C12.4922 12.375 11.4453 13.4219 11.4453 14.6953C11.4453 15.9766 12.4922 17.0234 13.7656 17.0234ZM13.7656 15.9141C13.0859 15.9141 12.5547 15.375 12.5547 14.6953C12.5547 14.0078 13.0859 13.4766 13.7656 13.4766C14.4531 13.4766 14.9844 14.0078 14.9844 14.6953C14.9844 15.375 14.4531 15.9141 13.7656 15.9141ZM12.1562 14L0.695312 14C0.304688 14 0 14.3047 0 14.6953C0 15.0781 0.304688 15.3828 0.695312 15.3828L12.1562 15.3828ZM19.4297 14L15.4922 14L15.4922 15.3828L19.4297 15.3828C19.7891 15.3828 20.0938 15.0781 20.0938 14.6953C20.0938 14.3047 19.7891 14 19.4297 14ZM6.52344 10.8594C7.80469 10.8594 8.85156 9.82031 8.85156 8.53906C8.85156 7.25781 7.80469 6.21094 6.52344 6.21094C5.25 6.21094 4.20312 7.25781 4.20312 8.53906C4.20312 9.82031 5.25 10.8594 6.52344 10.8594ZM6.52344 9.75781C5.84375 9.75781 5.3125 9.21094 5.3125 8.53125C5.3125 7.84375 5.84375 7.3125 6.52344 7.3125C7.21094 7.3125 7.74219 7.84375 7.74219 8.53125C7.74219 9.21094 7.21094 9.75781 6.52344 9.75781ZM0.664062 7.84375C0.304688 7.84375 0 8.14844 0 8.53125C0 8.92188 0.304688 9.22656 0.664062 9.22656L4.82031 9.22656L4.82031 7.84375ZM19.3984 7.84375L8.14062 7.84375L8.14062 9.22656L19.3984 9.22656C19.7891 9.22656 20.0938 8.92188 20.0938 8.53125C20.0938 8.14844 19.7891 7.84375 19.3984 7.84375ZM13.7656 4.6875C15.0469 4.6875 16.0938 3.64062 16.0938 2.35938C16.0938 1.08594 15.0469 0.0390625 13.7656 0.0390625C12.4922 0.0390625 11.4453 1.08594 11.4453 2.35938C11.4453 3.64062 12.4922 4.6875 13.7656 4.6875ZM13.7656 3.57812C13.0859 3.57812 12.5547 3.03906 12.5547 2.35938C12.5547 1.67188 13.0859 1.14062 13.7656 1.14062C14.4531 1.14062 14.9844 1.67188 14.9844 2.35938C14.9844 3.03906 14.4531 3.57812 13.7656 3.57812ZM12.1797 1.67969L0.695312 1.67969C0.304688 1.67969 0 1.98438 0 2.375C0 2.75781 0.304688 3.0625 0.695312 3.0625L12.1797 3.0625ZM19.4297 1.67969L15.4062 1.67969L15.4062 3.0625L19.4297 3.0625C19.7891 3.0625 20.0938 2.75781 20.0938 2.375C20.0938 1.98438 19.7891 1.67969 19.4297 1.67969Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.4 KiB |
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="473.625" height="249.625">
|
||||
<g>
|
||||
<rect height="249.625" opacity="0" width="473.625" x="0" y="0"/>
|
||||
<path d="M58.625 249.625C64.75 249.625 70 247.375 76 244L241.25 146.75C251.25 140.75 255.75 133.625 255.75 125C255.75 116.375 251.25 109.375 241.25 103.375L76 6.125C70 2.625 64.75 0.5 58.625 0.5C47 0.5 37 9.25 37 26.25L37 223.75C37 240.75 47 249.625 58.625 249.625ZM276.5 249.625C282.625 249.625 288 247.375 293.875 244L459.125 146.75C469.125 140.75 473.625 133.625 473.625 125C473.625 116.375 469.125 109.375 459.125 103.375L293.875 6.125C287.875 2.625 282.625 0.5 276.5 0.5C264.875 0.5 254.875 9.25 254.875 26.25L254.875 223.75C254.875 240.75 264.875 249.625 276.5 249.625Z" fill="#ffffff" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 977 B |
@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="189.875" height="259.125">
|
||||
<g>
|
||||
<rect height="259.125" opacity="0" width="189.875" x="0" y="0"/>
|
||||
<path d="M20 259L56 259C69.375 259 76 252.375 76 239L76 20C76 6.25 69.375 0 56 0L20 0C6.625 0 0 6.75 0 20L0 239C0 252.375 6.625 259 20 259ZM133.875 259L169.875 259C183.25 259 189.875 252.375 189.875 239L189.875 20C189.875 6.25 183.25 0 169.875 0L133.875 0C120.5 0 113.875 6.75 113.875 20L113.875 239C113.875 252.375 120.5 259 133.875 259Z" fill="#ffffff" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 741 B |
@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="278.25" height="271.875">
|
||||
<g>
|
||||
<rect height="271.875" opacity="0" width="278.25" x="0" y="0"/>
|
||||
<path d="M54.5 271.875C60.375 271.875 65.125 269.875 71.125 266.375L262 155.375C273.875 148.625 278.25 143.875 278.25 136C278.25 128.25 273.875 123.5 262 116.625L71.125 5.75C65.125 2.25 60.375 0.125 54.5 0.125C43.75 0.125 37 8.25 37 21.125L37 251C37 263.75 43.75 271.875 54.5 271.875Z" fill="#ffffff" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 685 B |
@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="473.625" height="249.625">
|
||||
<g>
|
||||
<rect height="249.625" opacity="0" width="473.625" x="0" y="0"/>
|
||||
<path d="M197.125 249.625C208.75 249.625 218.75 240.75 218.75 223.75L218.75 26.25C218.75 9.25 208.75 0.5 197.125 0.5C191 0.5 185.75 2.625 179.75 6.125L14.5 103.375C4.5 109.375 0 116.375 0 125C0 133.625 4.5 140.75 14.5 146.75L179.75 244C185.75 247.375 191 249.625 197.125 249.625ZM415 249.625C426.625 249.625 436.625 240.75 436.625 223.75L436.625 26.25C436.625 9.25 426.625 0.5 415 0.5C408.875 0.5 403.625 2.625 397.625 6.125L232.5 103.375C222.375 109.375 217.875 116.375 217.875 125C217.875 133.625 222.5 140.75 232.5 146.75L397.625 244C403.625 247.375 408.875 249.625 415 249.625Z" fill="#ffffff" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 984 B |
@ -1 +0,0 @@
|
||||
<svg width="13.137" xmlns="http://www.w3.org/2000/svg" height="16" fill="none" viewBox="0 0 13.137 16"><g style="fill: rgb(0, 0, 0);"><path d="M9.819 16c.556 0 .933-.406.933-.947V.99c0-.541-.377-.983-.947-.983-.392 0-.662.171-1.068.556L4.756 4.336a.344.344 0 0 1-.256.093H1.801C.662 4.429 0 5.091 0 6.302v3.446c0 1.203.662 1.866 1.801 1.866H4.5c.107 0 .192.035.256.099l3.981 3.767c.37.349.691.52 1.082.52Z" style="fill: rgb(255, 255, 255); fill-opacity: 0.85;" class="fills"/></g></svg>
|
Before Width: | Height: | Size: 486 B |
@ -1 +0,0 @@
|
||||
<svg width="21.937" xmlns="http://www.w3.org/2000/svg" height="16" fill="none" viewBox="0 0 21.937 16"><g style="fill: rgb(0, 0, 0);"><path d="M18.804 15.901c.252.18.593.11.787-.174a13.678 13.678 0 0 0 2.346-7.723c0-3.017-.941-5.602-2.346-7.729-.194-.29-.535-.355-.787-.174-.264.18-.303.522-.103.812 1.283 1.947 2.147 4.306 2.147 7.091 0 2.778-.864 5.144-2.147 7.085-.2.29-.161.631.103.812ZM15.787 13.69c.265.18.593.122.78-.148 1.07-1.444 1.709-3.475 1.709-5.538 0-2.069-.632-4.113-1.709-5.537-.187-.271-.515-.336-.78-.155-.258.18-.303.509-.096.806.947 1.315 1.482 3.062 1.482 4.886 0 1.824-.548 3.565-1.482 4.886-.2.291-.162.626.096.8ZM12.803 11.517c.245.168.58.11.767-.161.638-.838 1.038-2.056 1.038-3.352 0-1.302-.4-2.514-1.038-3.359a.545.545 0 0 0-.767-.154c-.284.2-.329.548-.11.844.529.722.812 1.644.812 2.669 0 1.019-.29 1.934-.812 2.669-.213.296-.174.638.11.844ZM8.89 15.25c.502 0 .844-.368.844-.858V1.661c0-.49-.342-.89-.857-.89-.355 0-.6.155-.967.503L4.306 4.691a.31.31 0 0 1-.232.083H1.631C.6 4.774 0 5.374 0 6.47v3.12c0 1.089.6 1.689 1.631 1.689h2.443c.097 0 .174.032.232.09l3.604 3.41c.335.316.625.471.98.471Z" style="fill: rgb(255, 255, 255); fill-opacity: 0.85;" class="fills"/></g></svg>
|
Before Width: | Height: | Size: 1.2 KiB |
@ -1,15 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
},
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
|
||||
export default config;
|
@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{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
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
||||
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': path.resolve(__dirname, '../../packages/core'),
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
global: 'globalThis'
|
||||
},
|
||||
plugins: [
|
||||
NodeGlobalsPolyfillPlugin({
|
||||
buffer: true
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
plugins: [rollupNodePolyFill()]
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['./package.json']
|
||||
}
|
||||
}
|
||||
});
|
@ -8,7 +8,8 @@
|
||||
"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"
|
||||
"test": "vitest",
|
||||
"go": "PORT=2611 bun ./build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
@ -36,8 +37,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.4",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
|
@ -5,6 +5,7 @@
|
||||
<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>
|
||||
|
@ -1,28 +0,0 @@
|
||||
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
|
||||
});
|
||||
};
|
@ -1,44 +0,0 @@
|
||||
import { safePath } from '@core/server/safePath';
|
||||
import { getCurrentFormattedDateTime } from '@core/utils/songUpdateTime';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
import type { MusicMetadata } from '@core/server/database/musicInfo';
|
||||
|
||||
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."
|
||||
});
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
<div class="absolute w-screen md:w-2/3 lg:w-1/2 left-0 md:left-[16.6667%] lg:left-1/4 px-[3%] md:px-0 top-16">
|
||||
<slot />
|
||||
</div>
|
@ -1,17 +0,0 @@
|
||||
import { songData } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
loadData();
|
||||
const songIDList = songData.keys().slice(0, 20);
|
||||
const songDataList = [];
|
||||
for (const songID of songIDList) {
|
||||
songDataList.push(songData.get(songID)!);
|
||||
}
|
||||
return {
|
||||
songDataList: songDataList
|
||||
};
|
||||
};
|
||||
|
||||
export const ssr = true;
|
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SongCard from '@core/components/database/songCard.svelte';
|
||||
import { type MusicMetadata } from '@core/server/database/musicInfo';
|
||||
import type { PageServerData } from './$types';
|
||||
export let data: PageServerData;
|
||||
let songList: MusicMetadata[] = data.songDataList;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AquaVox 音乐数据库</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center h-20 mb-8">
|
||||
<h1>AquaVox 音乐数据库</h1>
|
||||
<a
|
||||
href="/database/submit"
|
||||
class="h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
>提交新曲</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative grid mb-32"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
justify-content: space-between;
|
||||
gap: 2rem 1rem;"
|
||||
>
|
||||
{#each songList as song}
|
||||
<SongCard songData={song}/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|