ref: delete electron package & backend

update: docker

Support for the Electron version will be delayed. We don't need it for now.
This commit is contained in:
alikia2x (寒寒) 2024-12-23 02:05:49 +08:00
parent 0d60e9a094
commit c1bfba8f1c
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
90 changed files with 17 additions and 3220 deletions

2
.gitignore vendored
View File

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

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

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

@ -8,7 +8,9 @@
"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",

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 '@alikia/aqualyrics';
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,10 +0,0 @@
<script>
import '../app.css';
</script>
<div
class="h-fit min-h-screen w-screen max-w-[100vw] overflow-x-hidden overflow-y-auto
bg-zinc-50 dark:bg-[#1f1f1f] text-black dark:text-white relative z-0" style="font-family: 'Barlow', sans-serif;"
>
<slot />
</div>

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

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>

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 '@core/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
>

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

View File

@ -15,6 +15,9 @@ export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
preview: {
port: 4173,
},
optimizeDeps: {
esbuildOptions: {
define: {