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 nextUpdate from '$lib/state/nextUpdate';
|
||||
import truncate from '$lib/truncate';
|
||||
import { blur } from 'svelte/transition';
|
||||
|
||||
// Component input properties
|
||||
export let lyrics: string[];
|
||||
@ -88,7 +89,7 @@
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
const lyric = refs[i];
|
||||
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`;
|
||||
lyric.style.transform = `translateY(${-h}px)`;
|
||||
processingLineIndex = i;
|
||||
@ -101,7 +102,7 @@
|
||||
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||
const lyric = refs[i];
|
||||
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)`;
|
||||
processingLineIndex = i;
|
||||
await sleep(75);
|
||||
@ -199,10 +200,14 @@
|
||||
|
||||
const currentLyric = refs[currentPositionIndex];
|
||||
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 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]) {
|
||||
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
|
||||
// triggered by nextUpdate's update
|
||||
async function lyricsUpdate(){
|
||||
@ -228,18 +248,20 @@
|
||||
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
||||
|
||||
// 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`;
|
||||
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||
|
||||
// prepare past lines
|
||||
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`;
|
||||
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
||||
}
|
||||
await sleep(75);
|
||||
if (currentPositionIndex + 1 < refs.length) {
|
||||
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`;
|
||||
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||
await moveToNextLine(offsetHeight);
|
||||
@ -347,9 +369,9 @@
|
||||
--lyric-mobile-line-height: 2.4rem;
|
||||
--lyric-mobile-margin: 1.5rem 0;
|
||||
--lyric-mobile-font-weight: 600;
|
||||
--lyric-desktop-font-size: 3.5rem;
|
||||
--lyric-desktop-font-size: 3rem;
|
||||
--lyric-desktop-line-height: 4.5rem;
|
||||
--lyric-desktop-margin: 1.75rem 0;
|
||||
--lyric-desktop-margin: 2.75rem 0;
|
||||
}
|
||||
|
||||
.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">
|
||||
<h2>歌词文件</h2>
|
||||
<FileSelector accept=".lrc" class="ml-auto top-2 relative" />
|
||||
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
|
||||
</div>
|
||||
|
||||
<FileList />
|
||||
|
@ -13,6 +13,7 @@
|
||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||
import { onMount } from 'svelte';
|
||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||
import { parseTTML, type LyricLine, type LyricWord } from '$lib/ttml';
|
||||
|
||||
const audioId = $page.params.id;
|
||||
let audioPlayer: HTMLAudioElement | null = null;
|
||||
@ -44,26 +45,26 @@
|
||||
]
|
||||
});
|
||||
ms.setActionHandler('play', function () {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.play();
|
||||
paused = false;
|
||||
});
|
||||
|
||||
ms.setActionHandler('pause', function () {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.pause();
|
||||
paused = true;
|
||||
});
|
||||
|
||||
ms.setActionHandler('seekbackward', function () {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
ms.setActionHandler('previoustrack', function () {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
@ -85,7 +86,7 @@
|
||||
prepared.push('cover');
|
||||
});
|
||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
audioFile = f;
|
||||
@ -99,10 +100,28 @@
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
f.text().then((lr) => {
|
||||
originalLyrics = lrcParser(lr);
|
||||
if (!originalLyrics.scripts) return;
|
||||
for (const line of originalLyrics.scripts) {
|
||||
lyricsText.push(line.text);
|
||||
if (f.name.endsWith('.ttml')) {
|
||||
const lyricLines = parseTTML(lr).lyricLines;
|
||||
originalLyrics = {
|
||||
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() {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.duration) {
|
||||
duration = audioPlayer.duration;
|
||||
}
|
||||
@ -158,17 +177,16 @@
|
||||
$: {
|
||||
clearInterval(mainInterval);
|
||||
mainInterval = setInterval(() => {
|
||||
if (audioPlayer===null) return;
|
||||
if ($userAdjustingProgress === false)
|
||||
currentProgress = audioPlayer.currentTime;
|
||||
if (audioPlayer === null) return;
|
||||
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
|
||||
progressBarRaw.set(audioPlayer.currentTime);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (audioPlayer===null) return;
|
||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||
});
|
||||
onMount(() => {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||
});
|
||||
|
||||
$: {
|
||||
if (audioPlayer) {
|
||||
@ -202,18 +220,18 @@
|
||||
{hasLyrics}
|
||||
/>
|
||||
|
||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} />
|
||||
|
||||
<audio
|
||||
bind:this={audioPlayer}
|
||||
controls
|
||||
style="display: none"
|
||||
on:play={() => {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:pause={() => {
|
||||
if (audioPlayer===null) return;
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:ended={() => {
|
||||
|
Loading…
Reference in New Issue
Block a user