improve: better lyric effect & performance
add: support for TTML
This commit is contained in:
parent
e8b7a6e3d2
commit
ba31bc4b98
@ -6,6 +6,7 @@
|
|||||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||||
import nextUpdate from '$lib/state/nextUpdate';
|
import nextUpdate from '$lib/state/nextUpdate';
|
||||||
import truncate from '$lib/truncate';
|
import truncate from '$lib/truncate';
|
||||||
|
import { blur } from 'svelte/transition';
|
||||||
|
|
||||||
// Component input properties
|
// Component input properties
|
||||||
export let lyrics: string[];
|
export let lyrics: string[];
|
||||||
@ -88,7 +89,7 @@
|
|||||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
const lyric = refs[i];
|
const lyric = refs[i];
|
||||||
lyric.style.transition =
|
lyric.style.transition =
|
||||||
`transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease,
|
`transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||||
font-size 200ms ease, scale 250ms ease`;
|
font-size 200ms ease, scale 250ms ease`;
|
||||||
lyric.style.transform = `translateY(${-h}px)`;
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
processingLineIndex = i;
|
processingLineIndex = i;
|
||||||
@ -101,7 +102,7 @@
|
|||||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
const lyric = refs[i];
|
const lyric = refs[i];
|
||||||
lyric.style.transition =
|
lyric.style.transition =
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
'transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||||
lyric.style.transform = `translateY(${-h}px)`;
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
processingLineIndex = i;
|
processingLineIndex = i;
|
||||||
await sleep(75);
|
await sleep(75);
|
||||||
@ -200,9 +201,13 @@
|
|||||||
const currentLyric = refs[currentPositionIndex];
|
const currentLyric = refs[currentPositionIndex];
|
||||||
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
for (let i = 0; i < refs.length; i++) {
|
||||||
const offset = Math.abs(i - currentPositionIndex);
|
const offset = Math.abs(i - currentPositionIndex);
|
||||||
const blurRadius = Math.min(offset * 0.96, 16);
|
let blurRadius = Math.min(offset * 1.25, 16);
|
||||||
|
const rect = refs[i].getBoundingClientRect();
|
||||||
|
if (rect.top + rect.height < 0 || rect.top > lyricsContainer.getBoundingClientRect().height) {
|
||||||
|
blurRadius = 0;
|
||||||
|
}
|
||||||
if (refs[i]) {
|
if (refs[i]) {
|
||||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||||
}
|
}
|
||||||
@ -210,6 +215,21 @@
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getViewportRange() {
|
||||||
|
let min = 0;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const element = refs[i];
|
||||||
|
if (element.getBoundingClientRect().top < 0) {
|
||||||
|
min = i;
|
||||||
|
}
|
||||||
|
else if (element.getBoundingClientRect().bottom < 0) {
|
||||||
|
max = i;
|
||||||
|
return [min, max];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main function that control's lyrics update during playing
|
// Main function that control's lyrics update during playing
|
||||||
// triggered by nextUpdate's update
|
// triggered by nextUpdate's update
|
||||||
async function lyricsUpdate(){
|
async function lyricsUpdate(){
|
||||||
@ -228,18 +248,20 @@
|
|||||||
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
||||||
|
|
||||||
// prepare current line
|
// prepare current line
|
||||||
currentLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
currentLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
|
|
||||||
|
// prepare past lines
|
||||||
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
||||||
refs[i].style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
refs[i].style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
}
|
}
|
||||||
|
await sleep(75);
|
||||||
if (currentPositionIndex + 1 < refs.length) {
|
if (currentPositionIndex + 1 < refs.length) {
|
||||||
const nextLyric = refs[currentPositionIndex + 1];
|
const nextLyric = refs[currentPositionIndex + 1];
|
||||||
nextLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
nextLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
await moveToNextLine(offsetHeight);
|
await moveToNextLine(offsetHeight);
|
||||||
@ -347,9 +369,9 @@
|
|||||||
--lyric-mobile-line-height: 2.4rem;
|
--lyric-mobile-line-height: 2.4rem;
|
||||||
--lyric-mobile-margin: 1.5rem 0;
|
--lyric-mobile-margin: 1.5rem 0;
|
||||||
--lyric-mobile-font-weight: 600;
|
--lyric-mobile-font-weight: 600;
|
||||||
--lyric-desktop-font-size: 3.5rem;
|
--lyric-desktop-font-size: 3rem;
|
||||||
--lyric-desktop-line-height: 4.5rem;
|
--lyric-desktop-line-height: 4.5rem;
|
||||||
--lyric-desktop-margin: 1.75rem 0;
|
--lyric-desktop-margin: 2.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics {
|
.lyrics {
|
||||||
|
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 />
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
import { parseTTML, type LyricLine, type LyricWord } from '$lib/ttml';
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
let audioPlayer: HTMLAudioElement | null = null;
|
let audioPlayer: HTMLAudioElement | null = null;
|
||||||
@ -44,26 +45,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 +86,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 +100,28 @@
|
|||||||
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;
|
const lyricLines = parseTTML(lr).lyricLines;
|
||||||
for (const line of originalLyrics.scripts) {
|
originalLyrics = {
|
||||||
lyricsText.push(line.text);
|
scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => {
|
||||||
|
return {
|
||||||
|
text: value.words.map(word => word.word).join(''),
|
||||||
|
start: value.startTime / 1000,
|
||||||
|
end: value.endTime / 1000,
|
||||||
|
translation: value.translatedLyric || undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
for (const line of originalLyrics.scripts!) {
|
||||||
|
lyricsText.push(line.text);
|
||||||
|
}
|
||||||
|
hasLyrics = true;
|
||||||
|
} else if (f.name.endsWith('.lrc')) {
|
||||||
|
originalLyrics = lrcParser(lr);
|
||||||
|
if (!originalLyrics.scripts) return;
|
||||||
|
for (const line of originalLyrics.scripts) {
|
||||||
|
lyricsText.push(line.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -110,7 +129,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;
|
||||||
}
|
}
|
||||||
@ -158,17 +177,16 @@
|
|||||||
$: {
|
$: {
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
@ -202,18 +220,18 @@
|
|||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} />
|
||||||
|
|
||||||
<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={() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user