Compare commits

...

25 Commits
main ... 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
e573c70497
improve: mobile UX
fix: some UX bugs
2024-11-24 03:58:26 +08:00
f1ecabd523
improve: experience for mobile 2024-11-23 07:20:07 +08:00
2742ba43f2
improve: complete event-driven animation 2024-11-23 06:17:53 +08:00
a4ddd83f91
update: preparation for event-driven animation 2024-11-17 20:32:46 +08:00
93855a3f61
ref: migrating to Svelte 5 2024-11-17 19:21:26 +08:00
57de9f426f
update: use new lyric parser 2024-11-17 17:01:10 +08:00
b3ba38c91c
ref: type 2024-11-09 23:21:12 +08:00
2d10240983
update: font and style 2024-11-08 20:45:29 +08:00
630692b507
fix: when cover resolution too low, image on the UI won't fill the container
improve: adjust margin of cover and interactiveBox
2024-11-03 00:42:05 +08:00
2b6d1e7439
improve: rename unused parameter 2024-10-30 21:10:37 +08:00
935bb64958
ref: clean project, especially for tests 2024-10-30 21:09:46 +08:00
5988c8335c
improve: the effect when two lyrics overlap 2024-10-29 02:45:36 +08:00
f939472329
feature: support word-highlight lyrics 2024-10-29 01:43:23 +08:00
f50ff5c588
feature: parsing signer for TTML 2024-10-28 23:21:45 +08:00
5af6992632
fix: real progress and lyric highlighting inconsistent during interlude 2024-10-27 13:13:38 +08:00
734bce13f6
ref: put components into shared "@core" package 2024-10-27 13:09:00 +08:00
3830df1eec
ref: add monorepo for porting to electron 2024-10-27 12:56:42 +08:00
131 changed files with 1341 additions and 2795 deletions

4
.gitignore vendored
View File

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

View File

@ -2,9 +2,12 @@
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/.svelte-kit" />
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/packages/electron/.svelte-kit" />
<excludeFolder url="file://$MODULE_DIR$/packages/web/.svelte-kit" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

6
.idea/bun.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BunSettings">
<option name="bunPath" value="$USER_HOME$/.bun/bin/bun" />
</component>
</project>

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
<option name="SCRIPT_TEXT" value="bun dev" />
<configuration default="false" name="Web Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
<option name="SCRIPT_TEXT" value="bun web:dev" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />

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.

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

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,17 +1,16 @@
{
"name": "aquavox",
"version": "2.3.2",
"version": "2.9.5",
"private": false,
"module": "index.ts",
"type": "module",
"workspaces": ["packages/web", "packages/core"],
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test": "vitest",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"go": "PORT=4173 bun ./build"
"electron:dev": "bun --filter 'electron' dev",
"web:dev": "bun --filter 'web' dev",
"dev": "bun --filter '**' dev",
"web:build": "bun --filter 'web' build",
"web:deploy": "bun --filter 'web' go"
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
@ -29,38 +28,16 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^4.2.17",
"svelte": "^5.2.2",
"svelte-check": "^3.7.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-wasm": "^3.3.0",
"vitest": "^1.6.0"
},
"type": "module",
"dependencies": {
"@applemusic-like-lyrics/core": "^0.1.3",
"@applemusic-like-lyrics/lyric": "^0.2.2",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@pixi/app": "^7.4.2",
"@pixi/core": "^7.4.2",
"@pixi/display": "^7.4.2",
"@pixi/filter-blur": "^7.4.2",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
"@pixi/sprite": "^7.4.2",
"vitest": "^2.1.4",
"@types/bun": "^1.1.6",
"bezier-easing": "^2.1.0",
"jotai": "^2.8.0",
"jotai-svelte": "^0.0.2",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lrc-parser-ts": "^1.0.3",
"music-metadata-browser": "^2.5.10",
"node-cache": "^5.1.2",
"rollup-plugin-node-polyfills": "^0.2.1",
"typescript-parsec": "^0.3.4",
"uuid": "^9.0.1"
}
"concurrently": "^9.0.1",
"cross-env": "^7.0.3"
},
"trustedDependencies": [
"svelte-preprocess"
]
}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { processImage } from '$lib/graphics';
import blobToImageData from '$lib/graphics/blob2imageData';
import imageDataToBlob from '$lib/graphics/imageData2blob';
import localforage from '$lib/utils/storage';
import { processImage } from '@core/graphics';
import blobToImageData from '@core/graphics/blob2imageData';
import imageDataToBlob from '@core/graphics/imageData2blob';
import localforage from '@core/utils/storage';
export let coverId: string;
let canvas: HTMLCanvasElement;
@ -46,7 +46,6 @@
height: 100%;
object-fit: cover;
z-index: -1;
overflow: hidden;
}
canvas {
position: relative;
@ -55,6 +54,6 @@
height: 100%;
opacity: 0;
transition: .45s;
filter: brightness(0.8);
filter: saturate(1.2);
}
</style>

View File

@ -11,14 +11,17 @@
{#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]"
class="absolute shadow-md select-none z-10 object-cover rounded-lg md:rounded-2xl max-md:h-20 max-xl:h-32 max-xl:top-6
md:max-h-[calc(94vh-20rem)] max-md:w-20 xl:w-auto max-w-[90%] xl:max-w-[37vw]
md:bottom-[21rem] left-6 md:left-[calc(7vw-1rem)] lg:left-[calc(12vw-1rem)] xl:translate-x-[-50%] xl:left-[25vw]"
src={path}
width="1200"
alt="封面"
/>
{:else}
<img
class="absolute shadow-md select-none z-10 object-cover rounded-2xl max-h-[calc(94vh-18rem)] md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] md:max-w-[75%] xl:max-w-[37vw]
class="absolute shadow-md select-none z-10 object-cover rounded-2xl max-h-[calc(94vh-18rem)]
md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] md:max-w-[75%] xl:max-w-[37vw]
bottom-72 md:bottom-80 left-1/2 translate-x-[-50%]"
src={path}
alt="封面"

View File

@ -1,6 +1,7 @@
<script lang="ts">
import formatDuration from "$lib/utils/formatDuration";
import { formatViews } from "$lib/utils/formatViews";
import formatDuration from "@core/utils/formatDuration";
import { formatViews } from "@core/utils/formatViews";
import { type MusicMetadata } from '@core/server/database/musicInfo';
export let songData: MusicMetadata;
</script>

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

@ -1,13 +1,13 @@
<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 { fileListState, finalFileListState } from '@core/state/fileList.state';
import toHumanSize from '@core/utils/humanSize';
import formatText from '@core/utils/formatText';
import extractFileName from '@core/utils/extractFileName';
import getAudioMeta from '@core/utils/getAudioCoverURL';
import convertCoverData from '@core/utils/convertCoverData';
import type { IAudioMetadata } from 'music-metadata-browser';
import formatDuration from '$lib/utils/formatDuration';
import formatDuration from '@core/utils/formatDuration';
const items = useAtom(fileListState);
const finalItems = useAtom(finalFileListState);
let displayItems: any[] = [];

View File

@ -3,7 +3,7 @@
import ImportIcon from './importIcon.svelte';
import { onMount } from 'svelte';
import { useAtom } from 'jotai-svelte';
import { fileListState } from '$lib/state/fileList.state';
import { fileListState } from '@core/state/fileList.state';
import AddIcon from './addIcon.svelte';
const fileItems = useAtom(fileListState);
export let accept: string = ".aac, .mp3, .wav, .ogg, .flac";
@ -35,7 +35,7 @@
{#if $fileItems.length > 0}
<AddIcon class="z-[1] relative text-3xl" />
{:else}
<ImportIcon class="z-[1] relative text-4xl" />
<ImportIcon class="z-[1] relative text-4xl text-blue-500" />
{/if}
</div>
</button>

View File

@ -0,0 +1,3 @@
<div class={$$props.class}>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21 14a1 1 0 0 0-1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-4a1 1 0 0 0-2 0v4a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4a1 1 0 0 0-1-1m-9.71 1.71a1 1 0 0 0 .33.21a.94.94 0 0 0 .76 0a1 1 0 0 0 .33-.21l4-4a1 1 0 0 0-1.42-1.42L13 12.59V3a1 1 0 0 0-2 0v9.59l-2.29-2.3a1 1 0 1 0-1.42 1.42Z"/></svg>
</div>

View File

@ -1,9 +1,10 @@
<script lang="ts">
import formatDuration from '$lib/utils/formatDuration';
import formatDuration from '../utils/formatDuration';
import { onMount } from 'svelte';
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
import truncate from '$lib/utils/truncate';
import userAdjustingProgress from '../state/userAdjustingProgress';
import progressBarSlideValue from '../state/progressBarSlideValue';
import truncate from '../utils/truncate';
import timestamp from '@core/utils/getCurrentTimestamp';
export let name: string;
export let singer: string = '';
@ -18,6 +19,9 @@
export let hasLyrics: boolean;
export let showInteractiveBox: boolean;
export let showingInteractiveBoxUntil: Function;
let progressBar: HTMLDivElement;
let volumeBar: HTMLDivElement;
let showInfoTop: boolean = false;
@ -25,6 +29,12 @@
let songInfoTopContainer: HTMLDivElement;
let songInfoTopContent: HTMLSpanElement;
let userAdjustingVolume = false;
let lastTouchClientX = 0;
let mobileDeviceAdjustingProgress = false;
if (screen.width < 728) {
showingInteractiveBoxUntil(timestamp() + 3000);
}
const mql = window.matchMedia('(max-width: 1280px)');
@ -68,6 +78,47 @@
$: {
showInfoTop = mql.matches && hasLyrics;
}
window.addEventListener("mousemove", (event) => {
if ($userAdjustingProgress) {
const x = event.clientX;
const rec = progressBar.getBoundingClientRect();
adjustDisplayProgress(truncate((x - rec.left) / rec.width,0,1));
}
});
window.addEventListener("mouseup", (event) => {
if ($userAdjustingProgress) {
const x = event.clientX;
const rec = progressBar.getBoundingClientRect();
userAdjustingProgress.set(false);
adjustProgress(truncate((x - rec.left) / rec.width,0,1));
}
});
window.addEventListener("touchmove", (event) => {
if ($userAdjustingProgress) {
const x = event.touches[0].clientX;
const rec = progressBar.getBoundingClientRect();
adjustDisplayProgress(truncate((x - rec.left) / rec.width,0,1));
lastTouchClientX = x;
}
});
window.addEventListener("touchend", (event) => {
if ($userAdjustingProgress) {
const x = lastTouchClientX;
const rec = progressBar.getBoundingClientRect();
adjustProgress(truncate((x - rec.left) / rec.width,0,1));
userAdjustingProgress.set(false);
mobileDeviceAdjustingProgress = false;
}
});
userAdjustingProgress.subscribe(()=> {
showingInteractiveBoxUntil(timestamp() + 5000);
});
function handleClick() {
showingInteractiveBoxUntil(timestamp() + 5000);
}
</script>
{#if showInfoTop}
@ -78,11 +129,14 @@
{/if}
<div
class={'absolute select-none bottom-2 h-60 w-[86vw] left-[7vw] z-10 ' +
class={'absolute select-none bottom-12 h-60 w-[86vw] left-[7vw] duration-500 z-10 transition-[opacity,transform] ' +
(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]')}
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]') + ' ' +
(showInteractiveBox ? 'opacity-100' : 'opacity-0 translate-y-48')}
style={`z-index: ${showInteractiveBox ? "0" : "50"}`}
>
{#if !showInfoTop}
<div class="song-info">
<div class="song-info-regular {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
@ -95,6 +149,13 @@
</div>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class={"absolute w-full h-3/4 bottom-0" + (showInteractiveBox ? '' : '-translate-y-48')}
style={`z-index: ${showInteractiveBox ? "0" : "50"}`}
on:click={handleClick}
></div>
<div class="progress top-16">
<div class="time-indicator text-shadow-md time-current">
{formatDuration(progress)}
@ -104,31 +165,23 @@
aria-valuemin="0"
aria-valuenow={progress}
bind:this={progressBar}
class="progress-bar shadow-md"
class="progress-bar shadow-md {mobileDeviceAdjustingProgress && '!h-[0.7rem]'}"
on:keydown
on:keyup
on:click={(e) => {
progressBarOnClick(e);
}}
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);
on:touchstart={() => {
userAdjustingProgress.set(true);
mobileDeviceAdjustingProgress = true;
}}
role="slider"
tabindex="0"
>
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
<div class="bar {mobileDeviceAdjustingProgress && '!h-[0.7rem]'}" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
</div>
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
@ -335,7 +388,7 @@
transition: 0.3s;
}
.progress-bar:hover {
.progress-bar:active {
height: 0.7rem;
}
@ -349,7 +402,7 @@
transition: height 0.3s;
}
.progress-bar:hover .bar {
.progress-bar:active .bar {
height: 0.7rem;
}
@ -375,5 +428,11 @@
.control-btn {
transition: 0.1s
}
.progress-bar:hover {
height: 0.7rem;
}
.progress-bar:hover .bar {
height: 0.7rem;
}
}
</style>

View File

@ -0,0 +1,309 @@
<script lang="ts">
import createSpring from '@core/graphics/spring';
import type { LyricWord, ScriptItem } from '@alikia/aqualyrics';
import type { LyricPos } from './type';
import type { Spring } from '@core/graphics/spring/spring';
import userAdjustingProgress from '@core/state/userAdjustingProgress';
const viewportWidth = document.documentElement.clientWidth;
const blurRatio = viewportWidth > 640 ? 1.2 : 1.4;
const scrollDuration = 0.2;
export let line: ScriptItem;
export let index: number;
export let debugMode: Boolean;
export let lyricClick: Function;
export let progress: number;
export let currentLyricIndex: number;
export let scrolling: boolean;
let ref: HTMLDivElement;
let clickMask: HTMLSpanElement;
let time = 0;
let positionX: number = 0;
let positionY: number = 0;
let blur = 0;
let stopped = false;
let we_are_scrolling = false;
let scrollTarget: number | undefined = undefined;
let scrollFrom: number | undefined = undefined;
let scrollingStartTime: number | undefined = undefined;
let lastPosX: number | undefined = undefined;
let lastPosY: number | undefined = undefined;
let lastUpdateY: number | undefined = undefined;
let lastUpdateX: number | undefined = undefined;
let springY: Spring | undefined = undefined;
let springX: Spring | undefined = undefined;
let isCurrentLyric = false;
let prevRealY: number | undefined = undefined;
let prevRealTime: number | undefined = undefined;
let lastRealY: number | undefined = undefined;
let lastRealTime: number | undefined = undefined;
const INTERPOLATED_RATE = 0; // 可调节的插值间隔0-1
function updateY(timestamp: number) {
if (stopped) return;
const currentTime = new Date().getTime();
const isRealFrame = Math.random() > INTERPOLATED_RATE;
if (isRealFrame) {
// 真实物理帧处理
if (lastUpdateY === undefined) {
lastUpdateY = currentTime;
}
if (springY === undefined) return;
// 保存前一次的真实帧数据
prevRealY = lastRealY;
prevRealTime = lastRealTime;
// 更新物理状态
const deltaTime = (currentTime - lastUpdateY) / 1000;
springY.update(deltaTime);
positionY = springY.getCurrentPosition();
// 记录当前真实帧数据
lastRealY = positionY;
lastRealTime = currentTime;
lastUpdateY = currentTime;
// 继续请求动画帧
if (!springY?.arrived() && !stopped && !we_are_scrolling) {
requestAnimationFrame(updateY);
}
} else {
// 插值帧处理
if (lastRealY !== undefined && lastRealTime !== undefined) {
const timeSinceLastReal = currentTime - lastRealTime;
const deltaT = timeSinceLastReal / 1000;
// 计算速度(如果有前一次真实帧数据)
let velocity = 0;
if (prevRealY !== undefined && prevRealTime !== undefined && lastRealTime !== prevRealTime) {
velocity = (lastRealY - prevRealY) / ((lastRealTime - prevRealTime) / 1000);
}
positionY = lastRealY + velocity * deltaT;
}
// 无论是否成功插值都保持动画流畅
if (!stopped && !we_are_scrolling) {
requestAnimationFrame(updateY);
}
}
}
function updateX(timestamp: number) {
if (stopped) return;
if (lastUpdateX === undefined) {
lastUpdateX = timestamp;
}
if (springX === undefined) return;
time = (new Date().getTime() - lastUpdateX) / 1000;
springX.update(time);
positionX = springX.getCurrentPosition();
if (!springX.arrived()) {
requestAnimationFrame(updateX);
}
lastUpdateX = timestamp;
}
/**
* Set the x position of the element, **with no animation**
* @param {number} pos - X offset, in pixels
*/
export const setX = (pos: number) => {
stopped = true;
positionX = pos;
};
/**
* Set the y position of the element, **with no animation**
* @param {number} pos - Y offset, in pixels
*/
export const setY = (pos: number) => {
stopped = true;
positionY = pos;
};
$: {
if (ref && ref.style) {
let blurRadius = 0;
const offset = Math.abs(index - currentLyricIndex);
if (progress > line.end) {
blurRadius = Math.min(offset * blurRatio, 16);
} else if (line.start <= progress && progress <= line.end) {
blurRadius = 0;
} else {
blurRadius = Math.min(offset * blurRatio, 16);
}
if (scrolling) blurRadius = 0;
if ($userAdjustingProgress) blurRadius = 0;
blur = blurRadius;
}
}
export const update = (pos: LyricPos, delay: number = 0) => {
if (lastPosX === undefined || lastPosY === undefined) {
lastPosX = pos.x;
lastPosY = pos.y;
}
springX!.setTargetPosition(pos.x, delay);
springY!.setTargetPosition(pos.y, delay);
lastUpdateY = new Date().getTime();
lastUpdateX = new Date().getTime();
stopped = false;
requestAnimationFrame(updateY);
requestAnimationFrame(updateX);
lastPosX = pos.x;
lastPosY = pos.y;
};
function updateScroll(timestamp: number) {
const elapsedTime = (new Date().getTime() - scrollingStartTime!) / 1000;
const percentage = Math.min(elapsedTime / scrollDuration, 1);
positionY = scrollFrom! + (scrollTarget! - scrollFrom!) * percentage;
if (percentage < 1) {
requestAnimationFrame(updateScroll);
}
}
export const scrollTo = (targetY: number) => {
scrollFrom = positionY;
scrollTarget = targetY;
scrollingStartTime = new Date().getTime();
we_are_scrolling = true;
requestAnimationFrame(updateScroll);
springY!.setPosition(targetY);
we_are_scrolling = false;
};
export const syncSpringWithDelta = (deltaY: number) => {
const target = positionY + deltaY;
springY!.setPosition(target);
};
export const getInfo = () => {
return {
x: positionX,
y: positionY,
isCurrent: isCurrentLyric
};
};
export const init = (pos: LyricPos) => {
lastPosX = pos.x;
lastPosY = pos.y;
positionX = pos.x;
positionY = pos.y;
springX = createSpring(pos.x, pos.x, 0.114, 0.72);
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
};
export const stop = () => {
stopped = true;
};
export const getRef = () => ref;
let processedChars = line.words?.flatMap((word) => {
const { startTime, endTime, word: text } = word;
const wordDuration = endTime - startTime;
return text.split('').map((chr, i) => {
const charProgress = startTime + wordDuration * (i / text.length);
const charDuration = wordDuration * ((i + 1) / text.length);
const transitionDur = charDuration < 0.6 ? null : charDuration / 1.6;
return { chr, charProgress, transitionDur };
});
});
// 新增:缓存当前行状态
$: isActiveLine = line.start <= progress && progress <= line.end;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={ref}
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
on:click={() => {
lyricClick(index);
}}
on:touchend={() => {
clickMask.style.backgroundColor = 'transparent';
}}
on:touchstart={() => {
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
}}
style="transform: translate3d({positionX}px, {positionY}px, 0);
transform-origin: center left; font-family: LyricFont, sans-serif; filter: blur({blur}px)"
>
<span
bind:this={clickMask}
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] h-full
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)] z-[100]"
>
</span>
{#if debugMode}
<span class="text-white text-lg absolute -translate-y-7">
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
</span>
{/if}
{#if line.words !== undefined && line.words.length > 0}
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 `}>
{#each processedChars as char (char.charProgress)}
{@const isHighlighted = line.start <= progress && progress <= line.end && progress > char.charProgress}
{@const useCustomTransition = char.transitionDur !== null && isActiveLine}
<span
class="inline-block {isHighlighted ? 'opacity-100 text-glow' : 'opacity-35'}"
style={useCustomTransition
? `transition-duration: ${char.transitionDur}s;`
: 'transition-duration: 200ms;'}
>
{char.chr}
</span>
{/each}
</span>
{:else}
<span
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 duration-200
${line.start <= progress && progress <= line.end ? 'opacity-100 text-glow' : 'opacity-35'}`}
>
{line.text}
</span>
{/if}
{#if line.translation}
<br />
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300 text-white`}>
{line.translation}
</span>
{/if}
</div>
<style>
.text-glow {
text-shadow:
0 0 3px #ffffff2c,
0 0 6px #ffffff2c,
0 15px 30px rgba(0, 0, 0, 0.11),
0 5px 15px rgba(0, 0, 0, 0.08);
}
/* 预定义过渡类 */
.char-transition {
transition-property: opacity;
transition-timing-function: ease-out;
}
.fast-transition {
transition-duration: 200ms;
}
.custom-transition {
transition-duration: var(--custom-duration);
}
</style>

View File

@ -0,0 +1,314 @@
<script lang="ts">
import { type LyricData, type ScriptItem } from '@alikia/aqualyrics';
import { onMount } from 'svelte';
import LyricLine from './lyricLine.svelte';
import createLyricsSearcher from '@core/lyrics/lyricSearcher';
import userAdjustingProgress from '@core/state/userAdjustingProgress';
import DisplayFps from '../displayFPS.svelte';
// constants
const viewportHeight = document.documentElement.clientHeight;
const viewportWidth = document.documentElement.clientWidth;
const marginY = viewportWidth > 640 ? 12 : 0;
const blurRatio = viewportWidth > 640 ? 1 : 1.4;
const currentLyricTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
const deceleration = 0.95; // Velocity decay factor for inertia
const minVelocity = 0.001; // Minimum velocity to stop inertia
document.body.style.overflow = 'hidden';
// Props
let {
originalLyrics,
progress,
player,
showInteractiveBox
}: {
originalLyrics: LyricData;
progress: number;
player: HTMLAudioElement | null;
showInteractiveBox: boolean;
} = $props();
// States
let lyricLines: ScriptItem[] = $state([]);
let lyricsContainer: HTMLDivElement | null = $state(null);
let debugMode = $state(false);
let nextUpdate = $state(0);
let lastProgress = $state(0);
let showTranslation = $state(false);
let scrollEventAdded = $state(false);
let scrolling = $state(false);
let scrollingTimeout: Timer | null = $state(null);
let lastY: number = $state(0); // For tracking touch movements
let lastTime: number = $state(0); // For tracking time between touch moves
let velocityY = $state(0); // Vertical scroll velocity
let inertiaFrame: number = $state(0); // For storing the requestAnimationFrame reference
let inertiaFrameCount: number = $state(0);
// References to lyric elements
let lyricElements: HTMLDivElement[] = $state([]);
let lyricComponents: LyricLine[] = $state([]);
let lyricTopList: number[] = $state([]);
let getLyricIndex = $derived(createLyricsSearcher(originalLyrics));
let currentLyricIndex = $derived(getLyricIndex(progress));
function initLyricComponents() {
initLyricTopList();
for (let i = 0; i < lyricComponents.length; i++) {
const currentLyric = lyricComponents[i];
currentLyric.init({ x: 0, y: lyricTopList[i] });
}
}
function initLyricTopList() {
let cumulativeHeight = currentLyricTop;
for (let i = 0; i < lyricLines.length; i++) {
const c = lyricComponents[i];
lyricElements.push(c.getRef());
const e = lyricElements[i];
const elementHeight = e.getBoundingClientRect().height;
const elementTargetTop = cumulativeHeight;
cumulativeHeight += elementHeight + marginY;
lyricTopList.push(elementTargetTop);
}
}
function computeLayout() {
if (!originalLyrics.scripts) return;
const currentLyricDuration =
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyricTop;
for (let i = 0; i < lyricElements.length; i++) {
const currentLyricComponent = lyricComponents[i];
const lyric = originalLyrics.scripts[i];
const lyricBeforeProgress = lyric.end < progress;
const lyricInProgress = lyric.start <= progress && progress <= lyric.end;
const lyricWillHigherThanViewport = lyricTopList[i + 3] - relativeOrigin < 0;
const lyricWillLowerThanViewport = lyricTopList[i - 3] - relativeOrigin > lyricsContainer?.getBoundingClientRect().height!;
let delay = 0;
if (lyricBeforeProgress) {
delay = 0;
} else if (lyricInProgress) {
delay = 0.03;
} else {
delay = Math.min(Math.min(currentLyricDuration, 0.6), 0.067 * (i - currentLyricIndex + 1.2));
}
// if it's not in the viewport, we need to use animations
if (lyricWillHigherThanViewport || lyricWillLowerThanViewport) {
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
}
// if it's still in the viewport, we need to use spring animation
else {
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
}
}
}
function seekForward() {
if (!originalLyrics.scripts) return;
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyricTop;
for (let i = 0; i < lyricElements.length; i++) {
const currentLyricComponent = lyricComponents[i];
currentLyricComponent.scrollTo(lyricTopList[i] - relativeOrigin);
}
lastSeekForward = new Date().getTime();
}
$effect(() => {
if (!originalLyrics || !originalLyrics.scripts) return;
lyricLines = originalLyrics.scripts!;
});
let initialized = $state(false);
$effect(() => {
if (lyricComponents.length > 0 && !initialized) {
initLyricComponents();
initialized = true;
}
});
function handleScroll(deltaY: number) {
if (lyricComponents[0].getInfo().y > 0 && deltaY < 0) {
deltaY = 0;
}
if (lyricComponents[lyricComponents.length - 1].getInfo().y < 100 && deltaY > 0) {
deltaY = 0;
}
for (let i = 0; i < lyricElements.length; i++) {
const currentLyricComponent = lyricComponents[i];
const currentY = currentLyricComponent.getInfo().y;
scrolling = true;
currentLyricComponent.setY(currentY - deltaY);
currentLyricComponent.syncSpringWithDelta(deltaY);
}
scrolling = true;
if (scrollingTimeout) clearTimeout(scrollingTimeout);
scrollingTimeout = setTimeout(() => {
scrolling = false;
}, 2000);
}
// Handle the touch start event
function handleTouchStart(event: TouchEvent) {
lastY = event.touches[0].clientY;
}
// Handle the touch move event
function handleTouchMove(event: TouchEvent) {
const currentY = event.touches[0].clientY;
const currentTime = Date.now();
const deltaY = lastY - currentY; // Calculate vertical swipe distance
const deltaTime = currentTime - lastTime;
// Calculate the scroll velocity (change in Y over time)
if (deltaTime > 0) {
velocityY = deltaY / deltaTime;
}
handleScroll(deltaY); // Simulate the scroll event
lastY = currentY; // Update lastY for the next move event
lastTime = currentTime; // Update the lastTime for the next move event
}
// Handle the touch end event
function handleTouchEnd() {
// Start inertia scrolling based on the velocity
function inertiaScroll() {
if (Math.abs(velocityY) < minVelocity) {
cancelAnimationFrame(inertiaFrame);
return;
}
handleScroll(velocityY * 16); // Multiply by frame time (16ms) to get smooth scroll
velocityY *= deceleration; // Apply deceleration to velocity
inertiaFrame = requestAnimationFrame(inertiaScroll); // Continue scrolling in next frame
}
inertiaScroll();
}
$effect(() => {
if (!lyricsContainer || scrollEventAdded) return;
// Wheel event for desktop
lyricsContainer.addEventListener(
'wheel',
(e) => {
e.preventDefault();
const deltaY = e.deltaY;
handleScroll(deltaY);
},
{ passive: false }
);
// Touch events for mobile
lyricsContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
lyricsContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
lyricsContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollEventAdded = true;
});
let lastEventLyricIndex = $state(0);
let lastEventProgress = $state(0);
let lastSeekForward = $state(0);
$effect(() => {
const progressDelta = progress - lastEventProgress;
const deltaInRange = 0 <= progressDelta && progressDelta <= 0.15;
const deltaTooBig = progressDelta > 0.15;
const deltaIsNegative = progressDelta < 0;
const lyricChanged = currentLyricIndex !== lastEventLyricIndex;
const lyricIndexDeltaTooBig = Math.abs(currentLyricIndex - lastEventLyricIndex) > 1;
lastEventLyricIndex = currentLyricIndex;
lastEventProgress = progress;
if (!lyricChanged || scrolling) return;
if (!lyricIndexDeltaTooBig && deltaInRange) {
console.log('Event: regular move');
console.log(new Date().getTime(), lastSeekForward);
computeLayout();
} else if ($userAdjustingProgress) {
if (deltaTooBig && lyricChanged) {
console.log('Event: seek forward');
seekForward();
} else if (deltaIsNegative && lyricChanged) {
console.log('Event: seek backward');
seekForward();
}
} else {
console.log('Event: regular move');
computeLayout();
}
});
onMount(() => {
// Initialize
if (localStorage.getItem('debugMode') == null) {
localStorage.setItem('debugMode', 'false');
} else {
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
}
});
// handle KeyDown event
function onKeyDown(e: KeyboardEvent) {
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
debugMode = !debugMode;
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
} else if (e.key === 't') {
showTranslation = !showTranslation;
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
computeLayout();
}
}
function lyricClick(lyricIndex: number) {
if (player === null || originalLyrics.scripts === undefined) return;
player.currentTime = originalLyrics.scripts[lyricIndex].start;
userAdjustingProgress.set(false);
player.play();
}
</script>
<svelte:window on:keydown={onKeyDown} />
{#if debugMode}
<span
class="text-white text-lg absolute z-50 px-2 py-0.5 m-2 rounded-3xl bg-white bg-opacity-20 backdrop-blur-lg
right-0 font-mono"
>
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex},
uap: {$userAdjustingProgress}
</span>
<!-- <div
class="text-black/80 text-sm absolute z-50 px-3 py-2 m-2 rounded-lg bg-white/30 backdrop-blur-xl
left-0 font-mono"
>
<DisplayFps />
</div> -->
{/if}
{#if originalLyrics && originalLyrics.scripts}
<div
class={`absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 duration-500
${showInteractiveBox ? 'h-[calc(100vh-21rem)]' : 'h-[calc(100vh-7rem)]'}
lg:px-[7.5rem] xl:left-[46vw] xl:px-[3vw] xl:h-screen font-sans
text-left no-scrollbar z-[1] pt-16 overflow-hidden`}
style={`mask: linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 7%, rgba(0, 0, 0, 1) 95%,
rgba(0, 0, 0, 0) 100%);`}
bind:this={lyricsContainer}
>
{#each lyricLines as lyric, i}
<LyricLine
line={lyric}
index={i}
bind:this={lyricComponents[i]}
{debugMode}
{lyricClick}
{progress}
{currentLyricIndex}
{scrolling}
/>
{/each}
</div>
{/if}

View File

@ -47,7 +47,6 @@ export class Spring {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.getV(this.currentTime) < 0.01 &&
this.getV2(this.currentTime) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);

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

@ -0,0 +1,46 @@
{
"name": "core",
"private": true,
"type": "module",
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.9",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@types/eslint": "^8.56.10",
"@types/node": "^20.14.10",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.39.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^5.2.2",
"svelte-check": "^3.7.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "5.4.6",
"vite-plugin-wasm": "^3.3.0"
},
"dependencies": {
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@types/bun": "^1.1.6",
"bezier-easing": "^2.1.0",
"chart.js": "^4.4.7",
"jotai": "^2.8.0",
"jotai-svelte": "^0.0.2",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lrc-parser-ts": "^1.0.3",
"music-metadata-browser": "^2.5.10",
"node-cache": "^5.1.2",
"uuid": "^9.0.1"
}
}

View File

@ -1,5 +1,5 @@
import fs from 'fs';
import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js';
import { globalMemoryStorage, songData, songNameCache } from '@core/server/cache.js';
import { getDirectoryHash } from '../dirHash';
import { safePath } from '../safePath';

View File

@ -1,4 +1,4 @@
interface MusicMetadata {
export interface MusicMetadata {
id: string;
name: string;
url: string;

View File

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

View File

@ -1,28 +1,5 @@
import { describe, it, expect } from 'vitest';
import formatDuration from '$lib/utils/formatDuration.js';
import { safePath } from '$lib/server/safePath';
describe('formatDuration test', () => {
it('converts 120 seconds to "2:00"', () => {
expect(formatDuration(120)).toBe('2:00');
});
it('converts 119.935429 seconds to "1:59"', () => {
expect(formatDuration(119.935429)).toBe('1:59');
});
it('converts 185 seconds to "3:05"', () => {
expect(formatDuration(185)).toBe('3:05');
});
it('converts 601 seconds to "10:01"', () => {
expect(formatDuration(601)).toBe('10:01');
});
it('converts 3601 seconds to "1:00:01"', () => {
expect(formatDuration(3601)).toBe('1:00:01');
});
});
import { safePath } from '@core/server/safePath.js';
describe('safePath test', () => {
const base = "data/subdir";

View File

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import formatDuration from '@core/utils/formatDuration';
describe('formatDuration test', () => {
it('converts 120 seconds to "2:00"', () => {
expect(formatDuration(120)).toBe('2:00');
});
it('converts 119.935429 seconds to "1:59"', () => {
expect(formatDuration(119.935429)).toBe('1:59');
});
it('converts 185 seconds to "3:05"', () => {
expect(formatDuration(185)).toBe('3:05');
});
it('converts 601 seconds to "10:01"', () => {
expect(formatDuration(601)).toBe('10:01');
});
it('converts 3601 seconds to "1:00:01"', () => {
expect(formatDuration(3601)).toBe('1:00:01');
});
});

View File

@ -0,0 +1,20 @@
{
"extends": ["../../tsconfig.base.json"],
"compilerOptions": {
"target": "ES2019",
"baseUrl": ".",
"paths": {
"@core/*": ["./*"]
},
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"types": ["bun"],
"moduleResolution": "bundler"
}
}

View File

@ -4,8 +4,11 @@ export default function(key: string){
"audio/ogg": "OGG 容器",
"audio/flac": "FLAC 无损音频",
"audio/aac": "AAC 音频",
"audio/wav": "WAV 音频",
"ttml": "TTML歌词",
"lrc": "LRC 歌词"
}
if (!key) return "未知格式";
if (!(key in dict)) return key;
else return dict[key as keyof typeof dict];
}

View File

@ -0,0 +1,4 @@
export default function timestamp() {
const ts = new Date().getTime();
return ts;
}

38
packages/web/README.md Normal file
View File

@ -0,0 +1,38 @@
# 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 并不是音乐(流媒体)平台,官方并未提供任何音源的分发和(或)售卖,也不存在其它形式的任何盈利行为。

56
packages/web/package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test": "vitest",
"go": "PORT=2611 bun ./build"
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.8.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^8.56.12",
"@types/node": "^20.17.6",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.8",
"svelte": "^5.2.2",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "5.4.6",
"vite-plugin-wasm": "^3.3.0",
"vitest": "^1.6.0"
},
"dependencies": {
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@types/bun": "^1.1.13",
"bezier-easing": "^2.1.0",
"jotai": "^2.10.2",
"jotai-svelte": "^0.0.2",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lrc-parser-ts": "^1.0.3",
"music-metadata-browser": "^2.5.11",
"node-cache": "^5.1.2",
"rollup-plugin-node-polyfills": "^0.2.1",
"uuid": "^9.0.1"
}
}

View File

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

View File

@ -26,13 +26,15 @@ h2 {
0 5px 15px rgba(0, 0, 0, 0.08);
}
.text-shadow-none {
text-shadow: none;
}
body,
html {
position: fixed;
overflow: hidden;
overscroll-behavior: none;
position: fixed;
overflow: hidden;
overscroll-behavior: none;
}
@font-face {
font-family: 'LyricFont';
src: url('/font.otf') format('opentype');
font-weight: 600; /* Semibold weight */
}

15
packages/web/src/app.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/x-icon" href="/favicon.png">
<title>AquaVox</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,8 +1,10 @@
<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';
import extractFileName from '@core/utils/extractFileName';
import getVersion from '@core/utils/getVersion';
import toHumanSize from '@core/utils/humanSize';
import localforage from '@core/utils/storage';
import { goto } from '$app/navigation';
interface Song {
name: string;
singer?: string;
@ -58,13 +60,22 @@
<div>
<ul class="mt-4 relative w-full">
{#each idList as id}
<a class="!no-underline !text-black dark:!text-white" href={`/play/${id}`}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="!no-underline !text-black dark:!text-white" onclick={() => location.href = (`/play/${id}`)}>
<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>
<div class="flex pt-0.5 items-center">
<span class="w-[4.5rem]">{toHumanSize(musicList[id].size)}</span>
<div class="!no-underline import inline-block cursor-default z-50 px-2 py-0.5 rounded ml-2
hover:bg-gray-200 dark:hover:bg-zinc-500"
onclick={(e) => {location.href = `/import/${id}/lyric`;e.stopPropagation();}}>
导入歌词
</div>
</div>
{#if musicList[id].coverUrl}
<img
class="h-16 w-16 object-cover absolute rounded-lg right-2 top-2"
@ -73,7 +84,7 @@
/>
{/if}
</li>
</a>
</div>
{/each}
</ul>
</div>
@ -83,7 +94,7 @@
</p>
<a href="/import">导入音乐</a> <br />
<button
on:click={() => window.confirm('确定要删除本地数据库中所有内容吗?') && clear()}
onclick={() => 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>
@ -91,7 +102,10 @@
</div>
<style lang="postcss">
a {
@apply text-red-500 hover:text-red-400 duration-150 underline;
.import {
@apply text-red-500 duration-150 underline;
}
.import:hover {
text-shadow: 0 0 3px rgba(239, 68, 68, 0.3);
}
</style>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import SourceCard from "$lib/components/import/sourceCard.svelte";
import SourceCard from "@core/components/import/sourceCard.svelte";
</script>

View File

@ -1,9 +1,9 @@
<script>
import { page } from '$app/stores';
import FileList from '$lib/components/import/fileList.svelte';
import FileSelector from '$lib/components/import/fileSelector.svelte';
import localforage from '$lib/utils/storage';
import { fileListState } from '$lib/state/fileList.state';
import FileList from '@core/components/import/fileList.svelte';
import FileSelector from '@core/components/import/fileSelector.svelte';
import localforage from '@core/utils/storage';
import { fileListState } from '@core/state/fileList.state';
import { useAtom } from 'jotai-svelte';
const fileList = useAtom(fileListState);
const audioId = $page.params.id;

View File

@ -1,10 +1,10 @@
<script>
import FileList from '$lib/components/import/fileList.svelte';
import FileSelector from '$lib/components/import/fileSelector.svelte';
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
import FileList from '@core/components/import/fileList.svelte';
import FileSelector from '@core/components/import/fileSelector.svelte';
import { fileListState, finalFileListState } from '@core/state/fileList.state';
import { localImportFailed, localImportSuccess } from '@core/state/localImportStatus.state';
import { useAtom } from 'jotai-svelte';
import localforage from '$lib/utils/storage';
import localforage from '@core/utils/storage';
import { v1 as uuidv1 } from 'uuid';
const fileList = useAtom(fileListState);
const finalFiles = useAtom(finalFileListState);

View File

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

View File

@ -1,20 +1,20 @@
<script lang="ts">
import { page } from '$app/stores';
import getAudioIDMetadata from '$lib/audio/getAudioIDMetadata';
import Background from '$lib/components/background.svelte';
import Cover from '$lib/components/cover.svelte';
import InteractiveBox from '$lib/components/interactiveBox.svelte';
import extractFileName from '$lib/utils/extractFileName';
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 '@core/utils/extractFileName';
import localforage from 'localforage';
import { writable } from 'svelte/store';
import lrcParser from '$lib/lyrics/lrc/parser';
import type { LrcJsonData } from '$lib/lyrics/type';
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
import { type LyricData } from "@alikia/aqualyrics";
import userAdjustingProgress from '@core/state/userAdjustingProgress';
import type { IAudioMetadata } from 'music-metadata-browser';
import { onMount } from 'svelte';
import progressBarRaw from '$lib/state/progressBarRaw';
import { parseTTML, type LyricLine } from '$lib/lyrics/ttml';
import NewLyrics from '$lib/components/lyrics/newLyrics.svelte';
import progressBarRaw from '@core/state/progressBarRaw';
import { parseTTML, parseLRC } from '@alikia/aqualyrics';
import NewLyrics from '@core/components/lyrics/newLyrics.svelte';
import timestamp from '@core/utils/getCurrentTimestamp';
const audioId = $page.params.id;
let audioPlayer: HTMLAudioElement | null = null;
@ -27,11 +27,13 @@
let paused: boolean = true;
let launched = false;
let prepared: string[] = [];
let originalLyrics: LrcJsonData;
let originalLyrics: LyricData;
let lyricsText: string[] = [];
let hasLyrics: boolean;
const coverPath = writable('');
let mainInterval: ReturnType<typeof setInterval>;
let showInteractiveBoxUntil = Infinity;
let showInteractiveBox = true;
function setMediaSession() {
if ('mediaSession' in navigator === false) return;
@ -81,10 +83,41 @@
});
localforage.getItem(`${audioId}-cover`, function (err, file) {
if (file) {
const path = URL.createObjectURL(file as File);
coverPath.set(path);
const img = new Image();
img.src = URL.createObjectURL(file as File);
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let newWidth = img.width;
let newHeight = img.height;
if (newWidth < 1200) {
newWidth = 1200;
newHeight = (img.height * 1200) / img.width;
}
canvas.width = newWidth;
canvas.height = newHeight;
ctx!.drawImage(img, 0, 0, newWidth, newHeight);
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');
}
prepared.push('cover');
});
localforage.getItem(`${audioId}-file`, function (err, file) {
if (audioPlayer === null) return;
@ -103,12 +136,13 @@
f.text().then((lr) => {
if (f.name.endsWith('.ttml')) {
originalLyrics = parseTTML(lr);
console.log(originalLyrics);
for (const line of originalLyrics.scripts!) {
lyricsText.push(line.text);
}
hasLyrics = true;
} else if (f.name.endsWith('.lrc')) {
originalLyrics = lrcParser(lr);
originalLyrics = parseLRC(lr);
if (!originalLyrics.scripts) return;
for (const line of originalLyrics.scripts) {
lyricsText.push(line.text);
@ -165,13 +199,17 @@
}
}
function showingInteractiveBoxUntil(until: number) {
showInteractiveBoxUntil = until;
}
$: {
clearInterval(mainInterval);
mainInterval = setInterval(() => {
if (audioPlayer === null) return;
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
progressBarRaw.set(audioPlayer.currentTime);
}, 50);
}, 70);
}
onMount(() => {
@ -188,6 +226,10 @@
$: hasLyrics = !!originalLyrics;
setInterval(() => {
showInteractiveBox = timestamp() < showInteractiveBoxUntil || screen.width > 728;
}, 500);
readDB();
</script>
@ -195,41 +237,45 @@
<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}
/>
<div class="select-none">
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
<Background coverId={audioId} />
<Cover {coverPath} {hasLyrics} />
<InteractiveBox
{name}
{singer}
{duration}
{volume}
progress={currentProgress}
clickPlay={playAudio}
{paused}
{adjustProgress}
{adjustVolume}
{adjustDisplayProgress}
{hasLyrics}
{showInteractiveBox}
{showingInteractiveBoxUntil}
/>
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer} {showInteractiveBox}/>
<audio
bind:this={audioPlayer}
controls
style="display: none"
on:play={() => {
<audio
bind:this={audioPlayer}
controls
style="display: none"
on:play={() => {
if (audioPlayer === null) return;
paused = audioPlayer.paused;
}}
on:pause={() => {
on:pause={() => {
if (audioPlayer === null) return;
paused = audioPlayer.paused;
}}
on:ended={() => {
on:ended={() => {
paused = true;
if (audioPlayer == null) return;
audioPlayer.pause();
}}
></audio>
></audio>
</div>

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 977 B

View File

@ -0,0 +1 @@
<svg width="255" xmlns="http://www.w3.org/2000/svg" height="259" id="screenshot-d13e393b-6a61-8076-8005-51e8b692555d" viewBox="-0 0 255 259" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g xmlns:xlink="http://www.w3.org/1999/xlink" width="189.875" height="259.125" id="shape-d13e393b-6a61-8076-8005-51e8b692555d" data-testid="pause" style="fill: rgb(0, 0, 0);" ry="0" rx="0" version="1.1"><g id="shape-d13e393b-6a61-8076-8005-51e8b692d284" data-testid="base-background" style="display: none;"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b692d284"><rect width="255" height="259" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: none;" ry="0" fill="none" rx="0" y="0"/></g></g><g id="shape-d13e393b-6a61-8076-8005-51e8b6933311" data-testid="svg-g" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-d13e393b-6a61-8076-8005-51e8b6933312" data-testid="svg-rect" style="opacity: 0;"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b6933312"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="215.19166666666672" height="259"/></g></g><g id="shape-d13e393b-6a61-8076-8005-51e8b693b101" data-testid="svg-path"><g class="fills" id="fills-d13e393b-6a61-8076-8005-51e8b693b101"><path d="M22.667,258.875L63.467,258.875C78.625,258.875,86.133,252.253,86.133,238.885L86.133,19.990C86.133,6.247,78.625,0.000,63.467,0.000L22.667,0.000C7.508,0.000,-0.000,6.747,-0.000,19.990L-0.000,238.885C-0.000,252.253,7.508,258.875,22.667,258.875ZL22.667,258.875ZM191.533,258.875L232.333,258.875C247.492,258.875,255.000,252.253,255.000,238.885L255.000,19.990C255.000,6.247,247.492,0.000,232.333,0.000L191.533,0.000C176.375,0.000,168.867,6.747,168.867,19.990L168.867,238.885C168.867,252.253,176.375,258.875,191.533,258.875ZL191.533,258.875Z" style="fill: rgb(255, 255, 255); fill-opacity: 0.85;"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 685 B

After

Width:  |  Height:  |  Size: 685 B

View File

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 984 B

View File

Before

Width:  |  Height:  |  Size: 486 B

After

Width:  |  Height:  |  Size: 486 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

@ -0,0 +1,25 @@
{
"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

@ -3,12 +3,21 @@ 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({
resolve: {
alias: {
'@core': path.resolve(__dirname, '../../packages/core'),
}
},
plugins: [sveltekit(), wasm()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
preview: {
port: 4173,
},
optimizeDeps: {
esbuildOptions: {
define: {

View File

@ -1,18 +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" />
%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,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>

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