ref: add new AMLL dynamic lyric
This commit is contained in:
parent
e8b7a6e3d2
commit
d19b230809
14
package.json
14
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "1.15.0",
|
"version": "2.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@ -34,15 +34,27 @@
|
|||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||||
|
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@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",
|
"bezier-easing": "^2.1.0",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"jotai-svelte": "^0.0.2",
|
"jotai-svelte": "^0.0.2",
|
||||||
|
"jss": "^10.10.0",
|
||||||
|
"jss-preset-default": "^10.10.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lrc-parser-ts": "^1.0.3",
|
"lrc-parser-ts": "^1.0.3",
|
||||||
"music-metadata-browser": "^2.5.10",
|
"music-metadata-browser": "^2.5.10",
|
||||||
|
132
src/lib/components/newLyrics.svelte
Normal file
132
src/lib/components/newLyrics.svelte
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import {
|
||||||
|
LyricPlayer as CoreLyricPlayer,
|
||||||
|
type LyricLineMouseEvent,
|
||||||
|
type LyricLine,
|
||||||
|
type spring
|
||||||
|
} from '@applemusic-like-lyrics/core';
|
||||||
|
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
export let playing: boolean = true;
|
||||||
|
export let alignAnchor: 'top' | 'bottom' | 'center' = 'center';
|
||||||
|
export let alignPosition: number = 0.5;
|
||||||
|
export let enableSpring: boolean = true;
|
||||||
|
export let enableBlur: boolean = true;
|
||||||
|
export let enableScale: boolean = true;
|
||||||
|
export let hidePassedLines: boolean = false;
|
||||||
|
export let lyricLines: LyricLine[] = [];
|
||||||
|
export let currentTime: number = 0;
|
||||||
|
export let linePosXSpringParams: Partial<spring.SpringParams> = {};
|
||||||
|
export let linePosYSpringParams: Partial<spring.SpringParams> = {};
|
||||||
|
export let lineScaleSpringParams: Partial<spring.SpringParams> = {};
|
||||||
|
export let bottomLine: any = null;
|
||||||
|
export let onLyricLineClick: (line: LyricLineMouseEvent) => void = () => {};
|
||||||
|
export let onLyricLineContextMenu: (line: LyricLineMouseEvent) => void = () => {};
|
||||||
|
|
||||||
|
export let lyricPlayer: CoreLyricPlayer;
|
||||||
|
let wrapperEl: HTMLDivElement | null;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (playing) {
|
||||||
|
lyricPlayer.resume();
|
||||||
|
} else {
|
||||||
|
lyricPlayer.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationCanceled = false;
|
||||||
|
let animationLastTime = -1;
|
||||||
|
|
||||||
|
const onFrame = (time: number) => {
|
||||||
|
if (animationCanceled) return;
|
||||||
|
if (animationLastTime === -1) {
|
||||||
|
animationLastTime = time;
|
||||||
|
}
|
||||||
|
lyricPlayer.update(time - animationLastTime);
|
||||||
|
animationLastTime = time;
|
||||||
|
requestAnimationFrame(onFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAnimation = () => {
|
||||||
|
animationCanceled = false;
|
||||||
|
animationLastTime = -1;
|
||||||
|
requestAnimationFrame(onFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAnimation = () => {
|
||||||
|
animationCanceled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Monitor changes to `disabled`
|
||||||
|
$: {
|
||||||
|
if (!disabled) {
|
||||||
|
startAnimation();
|
||||||
|
} else {
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (playing) {
|
||||||
|
lyricPlayer.resume();
|
||||||
|
} else {
|
||||||
|
lyricPlayer.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricPlayer && wrapperEl) {
|
||||||
|
wrapperEl.appendChild(lyricPlayer.getElement());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (alignAnchor !== undefined) {
|
||||||
|
lyricPlayer.setAlignAnchor(alignAnchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setLyricLines(lyricLines);
|
||||||
|
lyricPlayer.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setAlignPosition(alignPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setEnableSpring(enableSpring);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setEnableScale(enableScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setEnableBlur(enableBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
lyricPlayer.setCurrentTime(currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
const handler = (e: Event) => onLyricLineClick(e as LyricLineMouseEvent);
|
||||||
|
lyricPlayer.addEventListener('line-click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up on component unmount
|
||||||
|
onDestroy(() => {
|
||||||
|
animationCanceled = true;
|
||||||
|
lyricPlayer.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={wrapperEl}
|
||||||
|
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-[45vw]
|
||||||
|
xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar overflow-y-auto z-[1] pt-16 font-semibold mix-blend-plus-lighter"
|
||||||
|
></div>
|
18
src/lib/lyrics/mapLyric.ts
Normal file
18
src/lib/lyrics/mapLyric.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||||
|
import {
|
||||||
|
type LyricLine as RawLyricLine,
|
||||||
|
parseLrc,
|
||||||
|
parseYrc,
|
||||||
|
parseLys,
|
||||||
|
parseQrc,
|
||||||
|
} from "@applemusic-like-lyrics/lyric";
|
||||||
|
|
||||||
|
export const mapLyric = (line: RawLyricLine, i: number, lines: RawLyricLine[]): LyricLine => ({
|
||||||
|
words: line.words,
|
||||||
|
startTime: line.words[0]?.startTime ?? 0,
|
||||||
|
endTime: line.words[line.words.length - 1]?.endTime ?? Infinity,
|
||||||
|
translatedLyric: '',
|
||||||
|
romanLyric: '',
|
||||||
|
isBG: false,
|
||||||
|
isDuet: false
|
||||||
|
});
|
3
src/lib/ttml/index.ts
Normal file
3
src/lib/ttml/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./parser";
|
||||||
|
export * from "./writer";
|
||||||
|
export type * from "./ttml-types";
|
168
src/lib/ttml/parser.ts
Normal file
168
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
src/lib/ttml/ttml-types.ts
Normal file
26
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
src/lib/ttml/writer.ts
Normal file
260
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);
|
||||||
|
}
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="w-full flex my-3">
|
<div class="w-full flex my-3">
|
||||||
<h2>歌词文件</h2>
|
<h2>歌词文件</h2>
|
||||||
<FileSelector accept=".lrc" class="ml-auto top-2 relative" />
|
<FileSelector accept=".lrc,.ttml" class="ml-auto top-2 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileList />
|
<FileList />
|
||||||
|
@ -4,15 +4,19 @@
|
|||||||
import Background from '$lib/components/background.svelte';
|
import Background from '$lib/components/background.svelte';
|
||||||
import Cover from '$lib/components/cover.svelte';
|
import Cover from '$lib/components/cover.svelte';
|
||||||
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
||||||
import Lyrics from '$lib/components/lyrics.svelte';
|
|
||||||
import extractFileName from '$lib/extractFileName';
|
import extractFileName from '$lib/extractFileName';
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import lrcParser, { type LrcJsonData } from '$lib/lyrics/parser';
|
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
import { parseTTML, type TTMLLyric } from '$lib/ttml';
|
||||||
|
import type { LyricLine, LyricLineMouseEvent, LyricPlayer } from '@applemusic-like-lyrics/core';
|
||||||
|
import NewLyrics from '$lib/components/newLyrics.svelte';
|
||||||
|
import { LyricPlayer as CoreLyricPlayer } from '@applemusic-like-lyrics/core';
|
||||||
|
import { parseLrc } from '@applemusic-like-lyrics/lyric';
|
||||||
|
import { mapLyric } from '$lib/lyrics/mapLyric';
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
let audioPlayer: HTMLAudioElement | null = null;
|
let audioPlayer: HTMLAudioElement | null = null;
|
||||||
@ -25,9 +29,9 @@
|
|||||||
let paused: boolean = true;
|
let paused: boolean = true;
|
||||||
let launched = false;
|
let launched = false;
|
||||||
let prepared: string[] = [];
|
let prepared: string[] = [];
|
||||||
let originalLyrics: LrcJsonData;
|
let lyricLines: LyricLine[];
|
||||||
let lyricsText: string[] = [];
|
|
||||||
let hasLyrics: boolean;
|
let hasLyrics: boolean;
|
||||||
|
let lyricPlayer: LyricPlayer = new CoreLyricPlayer();
|
||||||
const coverPath = writable('');
|
const coverPath = writable('');
|
||||||
let mainInterval: ReturnType<typeof setInterval>;
|
let mainInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
@ -44,26 +48,26 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
ms.setActionHandler('play', function () {
|
ms.setActionHandler('play', function () {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.play();
|
audioPlayer.play();
|
||||||
paused = false;
|
paused = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('pause', function () {
|
ms.setActionHandler('pause', function () {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
paused = true;
|
paused = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('seekbackward', function () {
|
ms.setActionHandler('seekbackward', function () {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.currentTime > 4) {
|
if (audioPlayer.currentTime > 4) {
|
||||||
audioPlayer.currentTime = 0;
|
audioPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('previoustrack', function () {
|
ms.setActionHandler('previoustrack', function () {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.currentTime > 4) {
|
if (audioPlayer.currentTime > 4) {
|
||||||
audioPlayer.currentTime = 0;
|
audioPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
@ -85,7 +89,7 @@
|
|||||||
prepared.push('cover');
|
prepared.push('cover');
|
||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
if (file) {
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
audioFile = f;
|
audioFile = f;
|
||||||
@ -99,10 +103,26 @@
|
|||||||
if (file) {
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
f.text().then((lr) => {
|
f.text().then((lr) => {
|
||||||
originalLyrics = lrcParser(lr);
|
if (f.name.endsWith('.ttml')) {
|
||||||
if (!originalLyrics.scripts) return;
|
lyricLines = parseTTML(lr).lyricLines;
|
||||||
for (const line of originalLyrics.scripts) {
|
hasLyrics = true;
|
||||||
lyricsText.push(line.text);
|
} else if (f.name.endsWith('.lrc')) {
|
||||||
|
lyricLines = parseLrc(lr).map((line, i, lines) => ({
|
||||||
|
words: [
|
||||||
|
{
|
||||||
|
word: line.words[0]?.word ?? '',
|
||||||
|
startTime: line.words[0]?.startTime ?? 0,
|
||||||
|
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity
|
||||||
|
}
|
||||||
|
],
|
||||||
|
startTime: line.words[0]?.startTime ?? 0,
|
||||||
|
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity,
|
||||||
|
translatedLyric: '',
|
||||||
|
romanLyric: '',
|
||||||
|
isBG: false,
|
||||||
|
isDuet: false
|
||||||
|
}));
|
||||||
|
hasLyrics = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -110,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playAudio() {
|
function playAudio() {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.duration) {
|
if (audioPlayer.duration) {
|
||||||
duration = audioPlayer.duration;
|
duration = audioPlayer.duration;
|
||||||
}
|
}
|
||||||
@ -140,6 +160,7 @@
|
|||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
audioPlayer.currentTime = duration * progress;
|
audioPlayer.currentTime = duration * progress;
|
||||||
currentProgress = duration * progress;
|
currentProgress = duration * progress;
|
||||||
|
lyricPlayer.calcLayout(false, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,17 +179,19 @@
|
|||||||
$: {
|
$: {
|
||||||
clearInterval(mainInterval);
|
clearInterval(mainInterval);
|
||||||
mainInterval = setInterval(() => {
|
mainInterval = setInterval(() => {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
if ($userAdjustingProgress === false)
|
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
|
||||||
currentProgress = audioPlayer.currentTime;
|
|
||||||
progressBarRaw.set(audioPlayer.currentTime);
|
progressBarRaw.set(audioPlayer.currentTime);
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||||
});
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
|
});
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
@ -177,7 +200,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: hasLyrics = !!originalLyrics;
|
function onLyricLineClick(e: LyricLineMouseEvent) {
|
||||||
|
lyricPlayer.resetScroll();
|
||||||
|
adjustProgress(lyricLines[e.lineIndex].startTime / 1000 / duration);
|
||||||
|
}
|
||||||
|
|
||||||
readDB();
|
readDB();
|
||||||
</script>
|
</script>
|
||||||
@ -202,18 +228,24 @@
|
|||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
<NewLyrics
|
||||||
|
{lyricPlayer}
|
||||||
|
{lyricLines}
|
||||||
|
currentTime={Math.round(currentProgress * 1000)}
|
||||||
|
playing={!paused}
|
||||||
|
{onLyricLineClick}
|
||||||
|
/>
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioPlayer}
|
bind:this={audioPlayer}
|
||||||
controls
|
controls
|
||||||
style="display: none"
|
style="display: none"
|
||||||
on:play={() => {
|
on:play={() => {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
paused = audioPlayer.paused;
|
paused = audioPlayer.paused;
|
||||||
}}
|
}}
|
||||||
on:pause={() => {
|
on:pause={() => {
|
||||||
if (audioPlayer===null) return;
|
if (audioPlayer === null) return;
|
||||||
paused = audioPlayer.paused;
|
paused = audioPlayer.paused;
|
||||||
}}
|
}}
|
||||||
on:ended={() => {
|
on:ended={() => {
|
||||||
|
@ -2,9 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
||||||
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
||||||
|
import wasm from 'vite-plugin-wasm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit(), wasm()],
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
},
|
},
|
||||||
@ -27,7 +28,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
fs: {
|
fs: {
|
||||||
allow: ["./package.json"]
|
allow: ['./package.json']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user