Compare commits

...

8 Commits
2.9.4 ... dev

Author SHA1 Message Date
b01cc47566
improve: performance by modifing template 2025-02-05 04:57:09 +08:00
f27c290f39
improve: interpolation logic 2025-02-05 03:59:53 +08:00
8fe3c73c09
improve: performance - using interpolation for spring animtion 2025-02-05 03:24:48 +08:00
7aef8e873d
improve: performance, added a fps monitor panel 2025-02-04 22:25:40 +08:00
74a21e721d
add: tokeignore 2024-12-23 02:06:55 +08:00
c1bfba8f1c
ref: delete electron package & backend
update: docker

Support for the Electron version will be delayed. We don't need it for now.
2024-12-23 02:05:49 +08:00
0d60e9a094
ref: using AquaLyrics to drive lyrics processing 2024-11-28 01:25:36 +08:00
e60baa0358
fix: overflow on mobile 2024-11-24 05:19:00 +08:00
110 changed files with 333 additions and 4028 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
.DS_Store
node_modules
/build
build
.svelte-kit
/package
.env

6
.tokeignore Normal file
View File

@ -0,0 +1,6 @@
*.toml
*.yml
*.json
*.md
*.html
*.svg

View File

@ -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"]

BIN
bun.lockb

Binary file not shown.

View File

@ -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:

View File

@ -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"
]

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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;
}

View File

@ -1,3 +0,0 @@
export interface IDTag {
[key: string]: string;
}

View File

@ -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);
};
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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();
}
})
});

View File

@ -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]

View File

@ -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]

View File

@ -1,38 +0,0 @@
# AquaVox - 洛水之音
AquaVox 是一个为中文虚拟歌手爱好者献上的产品。
> VOCALOID IS ALIVE.
这是一个 **开源、本地优先、有 B 站良好支持、界面美观优雅****音乐播放器**
## 使用
当前 AquaVox 的公开预览版位于 [aquavox.app](https://aquavox.app) 上。
[![wakatime](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55.svg)](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 并不是音乐(流媒体)平台,官方并未提供任何音源的分发和(或)售卖,也不存在其它形式的任何盈利行为。

View File

@ -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"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -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;
}

View File

@ -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 {};

View File

@ -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>

View File

@ -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}`);
});

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -1,7 +0,0 @@
interface FileItem {
name: string;
size?: number;
type: string;
lastModified?: number;
lastModifiedDate?: Date;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -1,4 +0,0 @@
export interface LyricPos {
x: number;
y: number;
}

View File

@ -1,4 +0,0 @@
import { atom } from 'jotai-svelte'
export const fileListState = atom([] as any[]);
export const finalFileListState = atom([] as any[]);

View File

@ -1,4 +0,0 @@
import { atom } from 'jotai-svelte'
export const localImportSuccess = atom([] as any[]);
export const localImportFailed = atom([] as any[]);

View File

@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
const nextUpdate = writable(-1);
export default nextUpdate;

View File

@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
const progressBarRaw = writable(0);
export default progressBarRaw;

View File

@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
const progressBarSlideValue = writable(0);
export default progressBarSlideValue;

View File

@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
const userAdjustingProgress = writable(false);
export default userAdjustingProgress;

View File

@ -1,3 +0,0 @@
export * from "./parser";
export * from "./writer";
export type * from "./ttml-types";

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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
}

View File

@ -1,4 +0,0 @@
export default function(fullname: string){
if (!fullname) return '';
return fullname.split('.').slice(0, -1).join('.')
}

View File

@ -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;
}

View File

@ -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];
}

View File

@ -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() + " ";
}
}

View File

@ -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);
})
}

View File

@ -1,5 +0,0 @@
import * as pjson from "../../../package.json";
export default function getVersion(){
return pjson.version;
}

View File

@ -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]}`
}

View File

@ -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}`;
}

View File

@ -1,8 +0,0 @@
import localforage from "localforage";
localforage.config({
driver: localforage.INDEXEDDB,
name: 'audioDB'
});
export default localforage;

View File

@ -1,3 +0,0 @@
export default function truncate(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

View File

@ -1 +0,0 @@
export const ssr = false;

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -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
>

View File

@ -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;

View File

@ -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>

View File

@ -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
>

View File

@ -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>

View File

@ -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" />

View File

@ -1 +0,0 @@
export const ssr = false;

View File

@ -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>

View File

@ -1 +0,0 @@
export const ssr = false;

View File

@ -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>

View File

@ -1 +0,0 @@
export const ssr = false;

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -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
}

View File

@ -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']
}
}
});

View File

@ -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",

View File

@ -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>

View File

@ -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
});
};

View File

@ -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."
});
}
}

View File

@ -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);
}

View File

@ -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>

View File

@ -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;

View File

@ -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>

Some files were not shown because too many files have changed in this diff Show More