Compare commits
3 Commits
20a91ce2aa
...
5af6992632
Author | SHA1 | Date | |
---|---|---|---|
5af6992632 | |||
734bce13f6 | |||
3830df1eec |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,7 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
/build
|
/build
|
||||||
/.svelte-kit
|
.svelte-kit
|
||||||
/package
|
/package
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
@ -2,9 +2,12 @@
|
|||||||
<module type="WEB_MODULE" version="4">
|
<module type="WEB_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
<excludeFolder url="file://$MODULE_DIR$/.svelte-kit" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
<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>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
6
.idea/bun.xml
Normal file
6
.idea/bun.xml
Normal 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>
|
52
package.json
52
package.json
@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "2.3.2",
|
"version": "2.3.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"workspaces": ["packages/web", "packages/core", "packages/electron"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"electron:dev": "bun --filter 'electron' dev",
|
||||||
"build": "vite build",
|
"web:dev": "bun --filter 'web' dev",
|
||||||
"preview": "vite preview",
|
"dev": "bun --filter '**' dev"
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
|
||||||
"test": "vitest",
|
|
||||||
"lint": "prettier --check . && eslint .",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"go": "PORT=4173 bun ./build"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/svelte": "^4.0.2",
|
"@iconify/svelte": "^4.0.2",
|
||||||
@ -29,38 +26,15 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-svelte": "^3.2.3",
|
"prettier-plugin-svelte": "^3.2.3",
|
||||||
"svelte": "^4.2.17",
|
"svelte": "^4.2.19",
|
||||||
"svelte-check": "^3.7.1",
|
"svelte-check": "^3.7.1",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.11",
|
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^1.6.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",
|
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
"bezier-easing": "^2.1.0",
|
"concurrently": "^9.0.1",
|
||||||
"jotai": "^2.8.0",
|
"cross-env": "^7.0.3"
|
||||||
"jotai-svelte": "^0.0.2",
|
},
|
||||||
"jss": "^10.10.0",
|
"dependencies": {
|
||||||
"jss-preset-default": "^10.10.0",
|
|
||||||
"localforage": "^1.10.0",
|
|
||||||
"lrc-parser-ts": "^1.0.3",
|
|
||||||
"music-metadata-browser": "^2.5.10",
|
|
||||||
"node-cache": "^5.1.2",
|
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
|
||||||
"typescript-parsec": "^0.3.4",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { processImage } from '$lib/graphics';
|
import { processImage } from '@core/graphics';
|
||||||
import blobToImageData from '$lib/graphics/blob2imageData';
|
import blobToImageData from '@core/graphics/blob2imageData';
|
||||||
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
import imageDataToBlob from '@core/graphics/imageData2blob';
|
||||||
import localforage from '$lib/utils/storage';
|
import localforage from '@core/utils/storage';
|
||||||
export let coverId: string;
|
export let coverId: string;
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
63
packages/core/components/database/songCard.svelte
Normal file
63
packages/core/components/database/songCard.svelte
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
83
packages/core/components/import/fileList.svelte
Normal file
83
packages/core/components/import/fileList.svelte
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useAtom } from 'jotai-svelte';
|
||||||
|
import { fileListState, finalFileListState } from '@core/state/fileList.state';
|
||||||
|
import toHumanSize from '@core/utils/humanSize';
|
||||||
|
import formatText from '@core/utils/formatText';
|
||||||
|
import extractFileName from '@core/utils/extractFileName';
|
||||||
|
import getAudioMeta from '@core/utils/getAudioCoverURL';
|
||||||
|
import convertCoverData from '@core/utils/convertCoverData';
|
||||||
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
|
import formatDuration from '@core/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>
|
@ -3,7 +3,7 @@
|
|||||||
import ImportIcon from './importIcon.svelte';
|
import ImportIcon from './importIcon.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { useAtom } from 'jotai-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';
|
import AddIcon from './addIcon.svelte';
|
||||||
const fileItems = useAtom(fileListState);
|
const fileItems = useAtom(fileListState);
|
||||||
export let accept: string = ".aac, .mp3, .wav, .ogg, .flac";
|
export let accept: string = ".aac, .mp3, .wav, .ogg, .flac";
|
379
packages/core/components/interactiveBox.svelte
Normal file
379
packages/core/components/interactiveBox.svelte
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import formatDuration from '../utils/formatDuration';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import userAdjustingProgress from '../state/userAdjustingProgress';
|
||||||
|
import progressBarSlideValue from '../state/progressBarSlideValue';
|
||||||
|
import truncate from '../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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: inline-block;
|
||||||
|
height: 3.7rem;
|
||||||
|
width: 5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: 0.45s;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-img {
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-song-img {
|
||||||
|
width: auto !important;
|
||||||
|
height: 1.7rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
user-select: text;
|
||||||
|
position: absolute;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
top: 1rem;
|
||||||
|
font-family: sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info-regular {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
height: 2.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info-regular.animate {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
rgba(0, 0, 0, 1) 2rem,
|
||||||
|
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
position: relative;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 2.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
scrollbar-width: none;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name.animate {
|
||||||
|
animation: scroll 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-author {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
height: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
top: 1.8rem;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 0.4rem;
|
||||||
|
background-color: rgba(64, 64, 64, 0.5);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover {
|
||||||
|
height: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
background-color: white;
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 0.4rem;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
transition: height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .bar {
|
||||||
|
height: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-indicator {
|
||||||
|
width: fit-content;
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: inline-block;
|
||||||
|
top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-current {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-total {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.control-btn {
|
||||||
|
transition: 0.1s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import createSpring from '$lib/graphics/spring';
|
import createSpring from '@core/graphics/spring';
|
||||||
import type { ScriptItem } from '$lib/lyrics/type';
|
import type { ScriptItem } from '@core/lyrics/type';
|
||||||
import type { LyricPos } from './type';
|
import type { LyricPos } from './type';
|
||||||
import type { Spring } from '$lib/graphics/spring/spring';
|
import type { Spring } from '@core/graphics/spring/spring';
|
||||||
|
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
|
269
packages/core/components/lyrics/newLyrics.svelte
Normal file
269
packages/core/components/lyrics/newLyrics.svelte
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LrcJsonData, ScriptItem } from '@core/lyrics/type';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
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 currentLyricTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
|
||||||
|
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||||
|
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
||||||
|
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 = 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];
|
||||||
|
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) {
|
||||||
|
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 s = originalLyrics.scripts![i].start;
|
||||||
|
const t = originalLyrics.scripts![i].end;
|
||||||
|
// Explain:
|
||||||
|
// The `currentLyricIndex` is also used for locating & layout computing,
|
||||||
|
// so when the current progress is in the interlude between two lyrics,
|
||||||
|
// `currentLyricIndex` still needs to have a valid value to ensure that
|
||||||
|
// the style and scrolling position are calculated correctly.
|
||||||
|
// But in that situation, the “current lyric index” does not exist.
|
||||||
|
const isCurrent = i == currentLyricIndex && s <= progress && progress <= t;
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
currentLyricComponent.setCurrent(isCurrent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Initialize
|
||||||
|
if (localStorage.getItem('debugMode') == null) {
|
||||||
|
localStorage.setItem('debugMode', 'false');
|
||||||
|
} else {
|
||||||
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// handle KeyDown event
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||||
|
debugMode = !debugMode;
|
||||||
|
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||||
|
} else if (e.key === 't') {
|
||||||
|
showTranslation = !showTranslation;
|
||||||
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lyricClick(lyricIndex: number) {
|
||||||
|
if (player === null || originalLyrics.scripts === undefined) return;
|
||||||
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute">
|
||||||
|
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if originalLyrics && originalLyrics.scripts}
|
||||||
|
<div
|
||||||
|
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12
|
||||||
|
lg:px-[7.5rem] xl:left-[46vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar z-[1] pt-16 overflow-hidden"
|
||||||
|
bind:this={lyricsContainer}
|
||||||
|
>
|
||||||
|
{#each lyricLines as lyric, i}
|
||||||
|
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
@ -1,5 +1,5 @@
|
|||||||
import type { LyricLine } from '@applemusic-like-lyrics/core';
|
import type { LyricLine } from '@applemusic-like-lyrics/core';
|
||||||
import type { ScriptItem } from '$lib/lyrics/LRCparser';
|
import type { ScriptItem } from './LRCparser';
|
||||||
|
|
||||||
export default function mapLRCtoAMLL(line: ScriptItem, i: number, lines: ScriptItem[]): LyricLine {
|
export default function mapLRCtoAMLL(line: ScriptItem, i: number, lines: ScriptItem[]): LyricLine {
|
||||||
return {
|
return {
|
@ -1,4 +1,4 @@
|
|||||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
import type { LrcJsonData } from '../type';
|
||||||
import { parseTTML as ttmlParser } from './parser';
|
import { parseTTML as ttmlParser } from './parser';
|
||||||
import type { LyricLine } from './ttml-types';
|
import type { LyricLine } from './ttml-types';
|
||||||
export * from './writer';
|
export * from './writer';
|
47
packages/core/package.json
Normal file
47
packages/core/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"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": "^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",
|
||||||
|
"jotai": "^2.8.0",
|
||||||
|
"jotai-svelte": "^0.0.2",
|
||||||
|
"jss": "^10.10.0",
|
||||||
|
"jss-preset-default": "^10.10.0",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
|
"lrc-parser-ts": "^1.0.3",
|
||||||
|
"music-metadata-browser": "^2.5.10",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import fs from 'fs';
|
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 { getDirectoryHash } from '../dirHash';
|
||||||
import { safePath } from '../safePath';
|
import { safePath } from '../safePath';
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
interface MusicMetadata {
|
export interface MusicMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
8
packages/core/tailwind.config.js
Normal file
8
packages/core/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./**/*.{html,js,svelte,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
18
packages/core/tsconfig.json
Normal file
18
packages/core/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../tsconfig.base.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@core/*": ["./*"]
|
||||||
|
},
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
}
|
||||||
|
}
|
38
packages/electron/README.md
Normal file
38
packages/electron/README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# AquaVox - 洛水之音
|
||||||
|
|
||||||
|
AquaVox 是一个为中文虚拟歌手爱好者献上的产品。
|
||||||
|
|
||||||
|
> VOCALOID IS ALIVE.
|
||||||
|
|
||||||
|
这是一个 **开源、本地优先、有 B 站良好支持、界面美观优雅** 的 **音乐播放器**。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
当前 AquaVox 的公开预览版位于 [aquavox.app](https://aquavox.app) 上。
|
||||||
|
|
||||||
|
[](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55)
|
||||||
|
|
||||||
|
## 项目起源
|
||||||
|
|
||||||
|
**开发者寒寒的话:**
|
||||||
|
|
||||||
|
AquaVox 这个播放器项目的灵感根本上来自于 Apple Music,以其优雅流畅的界面设计而著称。
|
||||||
|
|
||||||
|
我的中 V 曲之前基本是在 B 站听的,而中文虚拟歌手社区也基本上扎根于 B 站。但在 B 站听歌也有不少劣势——
|
||||||
|
首先自然是 B 站本身并不是一个音乐软件或播放器,自然在相关的功能上并没有过多开发,“听视频”的功能也仅在移动端可用;
|
||||||
|
其次,不少人听歌还是有看歌词的需求的,而许多中 V 曲发布时附上的 PV 中的歌词,为了美观,歌词并不会以一个常规视频的字幕样式呈现,而是采用了美术字等更为艺术化的表现形式,这使得查看歌词并不方便,更不用说在“听视频”模式下 PV 根本没有播放的情况。
|
||||||
|
|
||||||
|
而后来,我选择了通过将 B 站的歌曲导入到 Apple Music 的方案,而为什么选择 AM 呢?尽管很多中 V 曲在网易云等平台上有更丰富的资源,但我依然选择了 AM。很大一个原因自然是我对 AM 播放器界面和交互设计及用户体验的喜爱,但另一方面,网易云的中 V 曲库其实也并不算全,最终还要使用导入的方法才能将自己所有喜欢的歌囊括其中,那么既然要导入,何不直接将歌曲全部导入 AM 呢?
|
||||||
|
|
||||||
|
但很快,我也发现了 AM 的一个致命问题:自行导入的歌曲没有动态歌词的功能,只能以一个静态的模式查看全部的歌词。而动态歌词的漂亮设计是我很大一部分喜欢 Apple Music 的原因,但我自己导入的歌曲却无法享受这个功能,不是很令人失望吗?
|
||||||
|
|
||||||
|
因此,最后,我还是最终决定自行开发一个播放器,加上所有我喜欢的东西——Apple Music 的页面设计和交互、从 B 站直接获取的曲库、通过网页、PWA 和 Electron 使全平台有一致的体验。
|
||||||
|
|
||||||
|
## “赠品”
|
||||||
|
|
||||||
|
**开发者寒寒的话:**
|
||||||
|
在熟虑后,我决定让 AquaVox 不仅是一个播放器。更进一步,我希望它是一个属于整个中文虚拟歌手社区的数据库。从音源、作者(P 主及 staff)、虚拟歌手、歌曲元信息、动态歌词,在整个链路上成为一个中文虚拟歌手的终极“Archive”。
|
||||||
|
|
||||||
|
因此,我们需要你的帮助。
|
||||||
|
|
||||||
|
> 声明:AquaVox 并不是音乐(流媒体)平台,官方并未提供任何音源的分发和(或)售卖,也不存在其它形式的任何盈利行为。
|
57
packages/electron/package.json
Normal file
57
packages/electron/package.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
6
packages/electron/postcss.config.js
Normal file
6
packages/electron/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
@ -26,10 +26,6 @@ h2 {
|
|||||||
0 5px 15px rgba(0, 0, 0, 0.08);
|
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-shadow-none {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
position: fixed;
|
position: fixed;
|
@ -6,6 +6,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>AquaVox</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
95
packages/electron/src/electron.js
Normal file
95
packages/electron/src/electron.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import windowStateManager from 'electron-window-state';
|
||||||
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import contextMenu from 'electron-context-menu';
|
||||||
|
import serve from 'electron-serve';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
59
packages/electron/src/lib/components/background.svelte
Normal file
59
packages/electron/src/lib/components/background.svelte
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<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>
|
26
packages/electron/src/lib/components/cover.svelte
Normal file
26
packages/electron/src/lib/components/cover.svelte
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<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}
|
17
packages/electron/src/lib/components/import/addIcon.svelte
Normal file
17
packages/electron/src/lib/components/import/addIcon.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
7
packages/electron/src/lib/components/import/fileList.d.ts
vendored
Normal file
7
packages/electron/src/lib/components/import/fileList.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
type: string;
|
||||||
|
lastModified?: number;
|
||||||
|
lastModifiedDate?: Date;
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
@ -0,0 +1,15 @@
|
|||||||
|
<div class={$$props.class}>
|
||||||
|
<svg width="1em" xmlns="http://www.w3.org/2000/svg" height="1em" fill="none" viewBox="0 0 12 17.2"
|
||||||
|
><g style="mix-blend-mode: darken; fill: rgb(0, 0, 0);"
|
||||||
|
><path
|
||||||
|
d="M12.935 6.131v7.701c0 1.532-.636 2.168-2.169 2.168H2.168C.636 16 0 15.364 0 13.832V6.131c0-1.495.636-2.168 2.168-2.168h1.869v1.121H2.168c-.785 0-1.121.337-1.121 1.047v7.701c0 .822.336 1.159 1.121 1.159h8.598c.711 0 1.047-.337 1.047-1.159V6.131c0-.71-.336-1.047-1.047-1.047H8.897V3.963l1.889-.006c1.513.006 2.149.641 2.149 2.174Z"
|
||||||
|
style="fill: rgb(0, 117, 255); fill-opacity: 1;"
|
||||||
|
class="fills"
|
||||||
|
/><path
|
||||||
|
d="M6.505 11.607c.127 0 .205-.093.336-.205l3.028-2.991a.459.459 0 0 0 .112-.299c.019-.374-.223-.561-.523-.561-.206 0-.262.075-.299.113L7.178 9.645V.673C7.178.336 6.841 0 6.505 0c-.337 0-.673.336-.673.673v8.972L3.85 7.664a.404.404 0 0 0-.299-.113c-.266 0-.542.187-.523.561.008.169.056.243.112.299l3.028 2.991c.131.112.209.205.337.205Z"
|
||||||
|
style="fill: rgb(0, 117, 255); fill-opacity: 1;"
|
||||||
|
class="fills"
|
||||||
|
/></g
|
||||||
|
></svg
|
||||||
|
>
|
||||||
|
</div>
|
@ -0,0 +1,22 @@
|
|||||||
|
<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>
|
@ -214,6 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--suppress CssUnusedSymbol, CssUnusedSymbol -->
|
||||||
<style>
|
<style>
|
||||||
.controls {
|
.controls {
|
||||||
position: absolute;
|
position: absolute;
|
174
packages/electron/src/lib/components/lyrics/lyricLine.svelte
Normal file
174
packages/electron/src/lib/components/lyrics/lyricLine.svelte
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import createSpring from '@core/graphics/spring';
|
||||||
|
import type { ScriptItem } from '@core/lyrics/type';
|
||||||
|
import type { LyricPos } from './type';
|
||||||
|
import type { Spring } from '@core/graphics/spring/spring';
|
||||||
|
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
export let line: ScriptItem;
|
||||||
|
export let index: number;
|
||||||
|
export let debugMode: Boolean;
|
||||||
|
export let lyricClick: Function;
|
||||||
|
|
||||||
|
let ref: HTMLDivElement;
|
||||||
|
let clickMask: HTMLSpanElement;
|
||||||
|
|
||||||
|
let time = 0;
|
||||||
|
let positionX: number = 0;
|
||||||
|
let positionY: number = 0;
|
||||||
|
let opacity = 1;
|
||||||
|
let stopped = false;
|
||||||
|
let lastPosX: number | undefined = undefined;
|
||||||
|
let lastPosY: number | undefined = undefined;
|
||||||
|
let lastUpdateY: number | undefined = undefined;
|
||||||
|
let lastUpdateX: number | undefined = undefined;
|
||||||
|
let springY: Spring | undefined = undefined;
|
||||||
|
let springX: Spring | undefined = undefined;
|
||||||
|
let isCurrentLyric = false;
|
||||||
|
|
||||||
|
function updateY(timestamp: number) {
|
||||||
|
if (lastUpdateY === undefined) {
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
if (springY === undefined) return;
|
||||||
|
time = (new Date().getTime() - lastUpdateY) / 1000;
|
||||||
|
springY.update(time);
|
||||||
|
positionY = springY.getCurrentPosition();
|
||||||
|
if (!springY.arrived() && !stopped) {
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
}
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateX(timestamp: number) {
|
||||||
|
if (lastUpdateX === undefined) {
|
||||||
|
lastUpdateX = timestamp;
|
||||||
|
}
|
||||||
|
if (springX === undefined) return;
|
||||||
|
time = (new Date().getTime() - lastUpdateX) / 1000;
|
||||||
|
springX.update(time);
|
||||||
|
positionX = springX.getCurrentPosition();
|
||||||
|
if (!springX.arrived()) {
|
||||||
|
requestAnimationFrame(updateX);
|
||||||
|
}
|
||||||
|
lastUpdateX = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the x position of the element, **with no animation**
|
||||||
|
* @param {number} pos - X offset, in pixels
|
||||||
|
*/
|
||||||
|
export const setX = (pos: number) => {
|
||||||
|
positionX = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the y position of the element, **with no animation**
|
||||||
|
* @param {number} pos - Y offset, in pixels
|
||||||
|
*/
|
||||||
|
export const setY = (pos: number) => {
|
||||||
|
positionY = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCurrent = (isCurrent: boolean) => {
|
||||||
|
isCurrentLyric = isCurrent;
|
||||||
|
opacity = isCurrent ? 1 : 0.36;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setBlur = (blur: number) => {
|
||||||
|
ref.style.filter = `blur(${blur}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = (pos: LyricPos, delay: number = 0) => {
|
||||||
|
if (lastPosX === undefined || lastPosY === undefined) {
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
}
|
||||||
|
springX!.setTargetPosition(pos.x, delay);
|
||||||
|
springY!.setTargetPosition(pos.y, delay);
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
lastUpdateX = new Date().getTime();
|
||||||
|
stopped = false;
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
requestAnimationFrame(updateX);
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInfo = () => {
|
||||||
|
return {
|
||||||
|
x: positionX,
|
||||||
|
y: positionY,
|
||||||
|
isCurrent: isCurrentLyric
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const init = (pos: LyricPos) => {
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
positionX = pos.x;
|
||||||
|
positionY = pos.y;
|
||||||
|
springX = createSpring(pos.x, pos.x, 0.114, 0.72);
|
||||||
|
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRef = () => ref;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: opacity, text-shadow;
|
||||||
|
transition-duration: 0.36s; transition-timing-function: ease-out; opacity: {opacity};
|
||||||
|
transform-origin: center left;"
|
||||||
|
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||||
|
bind:this={ref}
|
||||||
|
on:touchstart={() => {
|
||||||
|
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
||||||
|
}}
|
||||||
|
on:touchend={() => {
|
||||||
|
clickMask.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] h-full
|
||||||
|
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)]"
|
||||||
|
bind:this={clickMask}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute -translate-y-7">
|
||||||
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4
|
||||||
|
${isCurrentLyric ? 'text-glow' : ''}`}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</span>
|
||||||
|
{#if line.translation}
|
||||||
|
<br />
|
||||||
|
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300`}>
|
||||||
|
{line.translation}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-glow {
|
||||||
|
text-shadow:
|
||||||
|
0 0 3px #ffffff2c,
|
||||||
|
0 0 6px #ffffff2c,
|
||||||
|
0 15px 30px rgba(0, 0, 0, 0.11),
|
||||||
|
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
import type { LrcJsonData } from '@core/lyrics/type';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ScriptItem } from '$lib/lyrics/type';
|
import type { ScriptItem } from '@core/lyrics/type';
|
||||||
import LyricLine from './lyricLine.svelte';
|
import LyricLine from './lyricLine.svelte';
|
||||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
import createLyricsSearcher from '@core/lyrics/lyricSearcher';
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
const viewportHeight = document.documentElement.clientHeight;
|
4
packages/electron/src/lib/components/lyrics/type.d.ts
vendored
Normal file
4
packages/electron/src/lib/components/lyrics/type.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface LyricPos {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
4
packages/electron/src/lib/state/fileList.state.ts
Normal file
4
packages/electron/src/lib/state/fileList.state.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai-svelte'
|
||||||
|
|
||||||
|
export const fileListState = atom([] as any[]);
|
||||||
|
export const finalFileListState = atom([] as any[]);
|
@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai-svelte'
|
||||||
|
|
||||||
|
export const localImportSuccess = atom([] as any[]);
|
||||||
|
export const localImportFailed = atom([] as any[]);
|
3
packages/electron/src/lib/state/nextUpdate.ts
Normal file
3
packages/electron/src/lib/state/nextUpdate.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const nextUpdate = writable(-1);
|
||||||
|
export default nextUpdate;
|
3
packages/electron/src/lib/state/progressBarRaw.ts
Normal file
3
packages/electron/src/lib/state/progressBarRaw.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const progressBarRaw = writable(0);
|
||||||
|
export default progressBarRaw;
|
3
packages/electron/src/lib/state/progressBarSlideValue.ts
Normal file
3
packages/electron/src/lib/state/progressBarSlideValue.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const progressBarSlideValue = writable(0);
|
||||||
|
export default progressBarSlideValue;
|
3
packages/electron/src/lib/state/userAdjustingProgress.ts
Normal file
3
packages/electron/src/lib/state/userAdjustingProgress.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const userAdjustingProgress = writable(false);
|
||||||
|
export default userAdjustingProgress;
|
3
packages/electron/src/lib/ttml/index.ts
Normal file
3
packages/electron/src/lib/ttml/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./parser";
|
||||||
|
export * from "./writer";
|
||||||
|
export type * from "./ttml-types";
|
168
packages/electron/src/lib/ttml/parser.ts
Normal file
168
packages/electron/src/lib/ttml/parser.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
|
}
|
26
packages/electron/src/lib/ttml/ttml-types.ts
Normal file
26
packages/electron/src/lib/ttml/ttml-types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface TTMLMetadata {
|
||||||
|
key: string;
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTMLLyric {
|
||||||
|
metadata: TTMLMetadata[];
|
||||||
|
lyricLines: LyricLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricWord {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
word: string;
|
||||||
|
emptyBeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricLine {
|
||||||
|
words: LyricWord[];
|
||||||
|
translatedLyric: string;
|
||||||
|
romanLyric: string;
|
||||||
|
isBG: boolean;
|
||||||
|
isDuet: boolean;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
260
packages/electron/src/lib/ttml/writer.ts
Normal file
260
packages/electron/src/lib/ttml/writer.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
13
packages/electron/src/lib/utils/convertCoverData.ts
Normal file
13
packages/electron/src/lib/utils/convertCoverData.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user