初次提交
This commit is contained in:
+1095
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
import type { Disposable, HasElement } from "../interfaces.ts";
|
||||
import styles from "../styles/lyric-player.module.css";
|
||||
import { measure } from "../utils/schedule.ts";
|
||||
import { Spring } from "../utils/spring.ts";
|
||||
import type { LyricPlayerBase } from "./base.ts";
|
||||
|
||||
export class BottomLineEl implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private delay = 0;
|
||||
// 由 LyricPlayer 来设置
|
||||
lineSize: [number, number] = [0, 0];
|
||||
readonly lineTransforms = {
|
||||
posX: new Spring(0),
|
||||
posY: new Spring(0),
|
||||
};
|
||||
constructor(private lyricPlayer: LyricPlayerBase) {
|
||||
this.element.setAttribute("class", styles.lyricLine);
|
||||
this.rebuildStyle();
|
||||
}
|
||||
async measureSize(): Promise<[number, number]> {
|
||||
const size: [number, number] = await measure(() => [
|
||||
this.element.clientWidth,
|
||||
this.element.clientHeight,
|
||||
]);
|
||||
return size;
|
||||
}
|
||||
private lastStyle = "";
|
||||
show() {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
hide() {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
private rebuildStyle() {
|
||||
let style = `transform:translate(${this.lineTransforms.posX
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px,${this.lineTransforms.posY
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px);`;
|
||||
if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
|
||||
style += `transition-delay:${this.delay}ms;`;
|
||||
}
|
||||
if (style !== this.lastStyle) {
|
||||
this.lastStyle = style;
|
||||
this.element.setAttribute("style", style);
|
||||
}
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(
|
||||
left: number = this.left,
|
||||
top: number = this.top,
|
||||
force = false,
|
||||
delay = 0,
|
||||
) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.delay = (delay * 1000) | 0;
|
||||
if (force || !this.lyricPlayer.getEnableSpring()) {
|
||||
if (force) this.element.classList.add(styles.tmpDisableTransition);
|
||||
this.lineTransforms.posX.setPosition(left);
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
if (!this.lyricPlayer.getEnableSpring()) this.show();
|
||||
else this.rebuildStyle();
|
||||
if (force)
|
||||
requestAnimationFrame(() => {
|
||||
this.element.classList.remove(styles.tmpDisableTransition);
|
||||
});
|
||||
} else {
|
||||
this.lineTransforms.posX.setTargetPosition(left, delay);
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
}
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.lyricPlayer.getEnableSpring()) return;
|
||||
this.lineTransforms.posX.update(delta);
|
||||
this.lineTransforms.posY.update(delta);
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
get isInSight() {
|
||||
const l = this.lineTransforms.posX.getCurrentPosition();
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const r = l + this.lineSize[0];
|
||||
const b = t + this.lineSize[1];
|
||||
const pr = this.lyricPlayer.size[0];
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
return !(l > pr || t > pb || r < 0 || b < 0);
|
||||
}
|
||||
dispose(): void {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import { LyricPlayerBase } from "../base.ts";
|
||||
import { CanvasLyricLine } from "./lyric-line.ts";
|
||||
|
||||
export class CanvasLyricPlayer extends LyricPlayerBase {
|
||||
private canvasElement = document.createElement("canvas");
|
||||
override currentLyricLineObjects: CanvasLyricLine[] = [];
|
||||
/** @internal */
|
||||
readonly ctx = this.canvasElement.getContext("2d")!;
|
||||
/** @internal */
|
||||
baseLineHeight = 1;
|
||||
/** @internal */
|
||||
baseFontSize = 30;
|
||||
/** @internal */
|
||||
baseFontFamily = "sans-serif";
|
||||
constructor() {
|
||||
super();
|
||||
this.element.classList.add("amll-lyric-player", "dom");
|
||||
this.canvasElement.style.width = "100%";
|
||||
this.canvasElement.style.height = "100%";
|
||||
this.canvasElement.style.display = "block";
|
||||
this.canvasElement.style.position = "absolute";
|
||||
this.onResize();
|
||||
this.update();
|
||||
// TODO: 添加鼠标事件监听
|
||||
this.element.addEventListener("mousemove", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.addEventListener("contextmenu", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.appendChild(this.canvasElement);
|
||||
this.element.appendChild(this.interludeDots.getElement());
|
||||
this.element.appendChild(this.bottomLine.getElement());
|
||||
}
|
||||
override setLyricLines(lines: LyricLine[], initialTime?: number): void {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
|
||||
this.currentLyricLineObjects = this.processedLines.map(
|
||||
(line) => new CanvasLyricLine(this, line),
|
||||
);
|
||||
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true);
|
||||
}
|
||||
override onResize(): void {
|
||||
const computedStyle = getComputedStyle(this.element);
|
||||
this.baseFontSize = Number.parseFloat(computedStyle.fontSize) || 30;
|
||||
this.baseFontFamily = computedStyle.fontFamily;
|
||||
const realWidth = this.canvasElement.clientWidth;
|
||||
const realHeight = this.canvasElement.clientHeight;
|
||||
this.size[0] = realWidth - this.baseFontSize * 2;
|
||||
this.size[1] = realHeight;
|
||||
this.canvasElement.width = realWidth * devicePixelRatio;
|
||||
this.canvasElement.height = realHeight * devicePixelRatio;
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.relayout();
|
||||
}
|
||||
console.log("CanvasLyricPlayer.onResize", this.size);
|
||||
this.calcLayout(true);
|
||||
}
|
||||
/**
|
||||
* @internal
|
||||
* @param size
|
||||
*/
|
||||
setFontSize(emSize: number): void {
|
||||
this.ctx.font = `${this.baseFontSize * emSize}px ${this.baseFontFamily}`;
|
||||
}
|
||||
override update(delta = 0): void {
|
||||
super.update(delta);
|
||||
|
||||
const ctx = this.ctx;
|
||||
const width = this.size[0];
|
||||
const height = this.size[1];
|
||||
ctx.resetTransform();
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);
|
||||
ctx.fillStyle = "currentColor";
|
||||
ctx.font = `${this.baseFontSize}px ${this.baseFontFamily}`;
|
||||
ctx.textRendering = "optimizeSpeed";
|
||||
ctx.textAlign = "left";
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.baseFontSize, 0);
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(delta / 1000);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
ctx.font = `15px ${this.baseFontFamily}`;
|
||||
ctx.fillStyle = "#FFFFFF55";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("CanvasLyricPlayer 播放器", width - 16, height - 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
|
||||
import { LyricLineBase } from "../base.ts";
|
||||
import type { CanvasLyricPlayer } from "./index.ts";
|
||||
import {
|
||||
layoutLine,
|
||||
type TextLayoutConfig,
|
||||
type TextLayoutResult,
|
||||
} from "./text-layout";
|
||||
|
||||
export class CanvasLyricLine extends LyricLineBase {
|
||||
constructor(
|
||||
private player: CanvasLyricPlayer,
|
||||
private line: LyricLine = {
|
||||
words: [],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
override getLine(): LyricLine {
|
||||
return this.line;
|
||||
}
|
||||
private lineSize: [number, number] = [0, 0];
|
||||
measureSize(): [number, number] {
|
||||
const maxMainLineIndex = Math.max(
|
||||
0,
|
||||
...this.layoutWords.flat().map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const maxTranslatedLineIndex = Math.max(
|
||||
0,
|
||||
...this.translatedLayoutWords.map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const maxRomanLineIndex = Math.max(
|
||||
0,
|
||||
...this.romanLayoutWords.map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const lineHeight = this.player.baseFontSize;
|
||||
this.lineSize = [
|
||||
this.player.size[0],
|
||||
(maxMainLineIndex + maxTranslatedLineIndex + maxRomanLineIndex) *
|
||||
lineHeight +
|
||||
this.player.size[1] * 0.04,
|
||||
];
|
||||
return [...this.lineSize];
|
||||
}
|
||||
|
||||
private layoutWords: TextLayoutResult[][] = [];
|
||||
private translatedLayoutWords: TextLayoutResult[] = [];
|
||||
private romanLayoutWords: TextLayoutResult[] = [];
|
||||
private lineCanvas: HTMLCanvasElement = document.createElement("canvas");
|
||||
/** @internal */
|
||||
relayout(): void {
|
||||
const config: TextLayoutConfig = {
|
||||
fontSize: this.player.baseFontSize,
|
||||
maxWidth: this.player.size[0] - 50,
|
||||
lineHeight: this.player.baseFontSize,
|
||||
uniformSpace: true,
|
||||
};
|
||||
const ctx = this.player.ctx;
|
||||
this.player.setFontSize(1);
|
||||
for (const chunk of chunkAndSplitLyricWords(this.line.words)) {
|
||||
if (Array.isArray(chunk)) {
|
||||
if (chunk.length === 0) continue;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
this.layoutWords = [
|
||||
[...layoutLine(ctx, this.line.words.map((w) => w.word).join(""), config)],
|
||||
];
|
||||
this.player.setFontSize(0.5);
|
||||
this.translatedLayoutWords = [
|
||||
...layoutLine(ctx, this.line.translatedLyric, config),
|
||||
];
|
||||
this.romanLayoutWords = [...layoutLine(ctx, this.line.romanLyric, config)];
|
||||
|
||||
// 在自己的 Canvas 上绘制
|
||||
this.measureSize();
|
||||
|
||||
this.lineCanvas.width = this.player.ctx.canvas.width;
|
||||
this.lineCanvas.height = this.lineSize[1] * devicePixelRatio;
|
||||
|
||||
const lctx = this.lineCanvas.getContext("2d")!;
|
||||
lctx.globalAlpha = 1;
|
||||
this.player.setFontSize(1);
|
||||
lctx.font = ctx.font;
|
||||
lctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
lctx.fillStyle = "white";
|
||||
lctx.textBaseline = "top";
|
||||
lctx.textAlign = "left";
|
||||
lctx.font = `${this.player.baseFontSize}px ${this.player.baseFontFamily}`;
|
||||
let lineIndex = 0;
|
||||
for (const word of this.layoutWords) {
|
||||
for (const layout of word) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
lineIndex = layout.lineIndex;
|
||||
}
|
||||
}
|
||||
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
|
||||
this.player.setFontSize(0.5);
|
||||
lctx.font = ctx.font;
|
||||
lctx.globalAlpha = 0.5;
|
||||
lineIndex = 0;
|
||||
for (const layout of this.translatedLayoutWords) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
lineIndex = layout.lineIndex;
|
||||
}
|
||||
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
|
||||
for (const layout of this.romanLayoutWords) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
override enable(): void {}
|
||||
override disable(): void {}
|
||||
override resume(): void {}
|
||||
override pause(): void {}
|
||||
override setTransform(
|
||||
top = this.top,
|
||||
scale = this.scale,
|
||||
opacity = this.opacity,
|
||||
blur = this.blur,
|
||||
force = false,
|
||||
delay = this.delay,
|
||||
): void {
|
||||
const targetBlur = Math.min(32, blur);
|
||||
// TODO: 实现模糊效果和不透明度效果的动画化
|
||||
this.blur = targetBlur;
|
||||
this.opacity = opacity;
|
||||
if (force) {
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
this.lineTransforms.scale.setPosition(scale);
|
||||
} else {
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
this.lineTransforms.scale.setTargetPosition(scale);
|
||||
}
|
||||
}
|
||||
get isInSight() {
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const r = this.lineSize[0];
|
||||
const b = t + this.lineSize[1];
|
||||
const pb = this.player.size[1];
|
||||
return !(t > pb || r < 0 || b < 0);
|
||||
}
|
||||
override update(delta?: number): void {
|
||||
this.lineTransforms.posY.update(delta);
|
||||
this.lineTransforms.scale.update(delta);
|
||||
if (!this.isInSight) return;
|
||||
const ctx = this.player.ctx;
|
||||
ctx.save();
|
||||
ctx.fillStyle = "white";
|
||||
ctx.filter = `blur(${this.blur}px)`;
|
||||
ctx.textRendering = "geometricPrecision";
|
||||
ctx.globalAlpha = this.opacity;
|
||||
ctx.translate(0, this.lineTransforms.posY.getCurrentPosition());
|
||||
ctx.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
|
||||
if (this.lineCanvas.width * this.lineCanvas.height > 0)
|
||||
ctx.drawImage(this.lineCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
const WHITE_SPACE = /^\s+/;
|
||||
const LATIN = /^[\p{L}0-9!"#$%&’()*+,-./:;<=>?@\[\]^_`\{|\}~]+/iu;
|
||||
|
||||
export interface TextLayoutResult {
|
||||
text: string;
|
||||
index: number;
|
||||
lineIndex: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
}
|
||||
|
||||
export interface TextLayoutConfig {
|
||||
fontSize: number;
|
||||
maxWidth: number;
|
||||
lineHeight: number;
|
||||
/**
|
||||
* 是否统一空格宽度,即不论空白字符有多少个,都只占用一个字符的宽度
|
||||
*/
|
||||
uniformSpace: boolean;
|
||||
}
|
||||
|
||||
export interface TextLayoutFinalState {
|
||||
x: number;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定文本进行布局,返回每个字符的位置信息
|
||||
* 目前仅可支持普通拉丁字符和 CJK 字符
|
||||
* @param ctx 2D 画板上下文
|
||||
* @param text 文本
|
||||
* @param config 字体大小
|
||||
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
|
||||
*/
|
||||
export function* layoutWord(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
config: TextLayoutConfig,
|
||||
initialX = 0,
|
||||
): Generator<TextLayoutResult, TextLayoutFinalState, void> {
|
||||
if (import.meta.env.DEV) {
|
||||
if (!ctx) throw new Error("ctx is null");
|
||||
if (!(config.fontSize > 0)) throw new Error("fontSize is invalid");
|
||||
}
|
||||
|
||||
let x = initialX;
|
||||
let lineIndex = 0;
|
||||
let lastWhitespaceIndex = 0;
|
||||
let curLayoutIndex = 0;
|
||||
let shouldWhitespace = false;
|
||||
let match: RegExpMatchArray | null = null;
|
||||
const spaceMetrics = ctx.measureText(" ");
|
||||
|
||||
while (curLayoutIndex < text.length) {
|
||||
const rest = text.substring(curLayoutIndex);
|
||||
match = rest.match(WHITE_SPACE);
|
||||
if (match) {
|
||||
shouldWhitespace = true;
|
||||
lastWhitespaceIndex = curLayoutIndex;
|
||||
curLayoutIndex += match[0].length;
|
||||
continue;
|
||||
}
|
||||
// 拉丁文字应尽量保持连续而不在中间换行
|
||||
match = rest.match(LATIN);
|
||||
if (match) {
|
||||
curLayoutIndex += match[0].length;
|
||||
const text = match[0];
|
||||
const wholeMetrics = ctx.measureText(text);
|
||||
if (x + wholeMetrics.width > config.maxWidth) {
|
||||
// 在当前行放不下,换行
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
shouldWhitespace = false;
|
||||
}
|
||||
if (shouldWhitespace) {
|
||||
shouldWhitespace = false;
|
||||
yield {
|
||||
text: " ",
|
||||
index: lastWhitespaceIndex,
|
||||
lineIndex,
|
||||
width: 0,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += spaceMetrics.width;
|
||||
}
|
||||
let prevChar = "";
|
||||
let lastMetrics: TextMetrics | null = null;
|
||||
for (const c of text) {
|
||||
const lastMergedMetrics = ctx.measureText(`${prevChar}${c}`);
|
||||
const metrics = ctx.measureText(c);
|
||||
if (prevChar !== "") {
|
||||
x = x + lastMergedMetrics.width - metrics.width;
|
||||
}
|
||||
if (x + metrics.width > config.maxWidth) {
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
}
|
||||
yield {
|
||||
text: c,
|
||||
index: curLayoutIndex,
|
||||
lineIndex,
|
||||
width: metrics.width,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
prevChar = c;
|
||||
lastMetrics = metrics;
|
||||
}
|
||||
if (lastMetrics) x += lastMetrics.width;
|
||||
continue;
|
||||
}
|
||||
// 无需保持连续的字符,直接布局,推进游标
|
||||
{
|
||||
if (shouldWhitespace) {
|
||||
shouldWhitespace = false;
|
||||
yield {
|
||||
text: " ",
|
||||
index: lastWhitespaceIndex,
|
||||
lineIndex,
|
||||
width: 0,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += spaceMetrics.width;
|
||||
}
|
||||
const metrics = ctx.measureText(text[curLayoutIndex]);
|
||||
if (x + metrics.width > config.maxWidth) {
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
}
|
||||
yield {
|
||||
text: text[curLayoutIndex],
|
||||
index: curLayoutIndex,
|
||||
lineIndex,
|
||||
width: metrics.width,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += metrics.width;
|
||||
}
|
||||
|
||||
curLayoutIndex++;
|
||||
}
|
||||
|
||||
return { x, lineIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定文本进行布局,返回每段文字的位置信息
|
||||
* 目前仅可支持普通拉丁字符和 CJK 字符
|
||||
* @param ctx 2D 画板上下文
|
||||
* @param text 文本
|
||||
* @param config 字体大小
|
||||
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
|
||||
*/
|
||||
export function* layoutLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
config: TextLayoutConfig,
|
||||
initialX = 0,
|
||||
): Generator<TextLayoutResult, void, void> {
|
||||
let currentLine: TextLayoutResult = {
|
||||
text: "",
|
||||
index: 0,
|
||||
lineIndex: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
};
|
||||
|
||||
for (const word of layoutWord(ctx, text, config, initialX)) {
|
||||
if (word.lineIndex !== currentLine.lineIndex) {
|
||||
if (currentLine.text.length) yield currentLine;
|
||||
currentLine = {
|
||||
...word,
|
||||
};
|
||||
} else {
|
||||
currentLine.text += word.text;
|
||||
currentLine.width = word.x + word.width;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.text.length) yield currentLine;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
给各个内部元素使用的样式
|
||||
*/
|
||||
|
||||
/* 给每行歌词应用的样式 */
|
||||
.lyricLine {
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 2vh 1em;
|
||||
contain: content;
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.25em;
|
||||
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
|
||||
&:has(> *):hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
|
||||
&:has(> *):active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
scale 0.5s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
|
||||
padding: 1vh
|
||||
calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
|
||||
|
||||
&.active {
|
||||
transition:
|
||||
opacity 0.5s 0.25s,
|
||||
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
&:hover .lyricLine {
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
transition: opacity 0.3s 0.1s;
|
||||
contain: content paint;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
transition: opacity 0.25s;
|
||||
transform-origin: center;
|
||||
width: fit-content;
|
||||
padding: 2.5% 0.75em;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
transition: none !important;
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件,但是进行了部分效果的阉割精简,以尝试改善在低性能设备上的性能问题
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import "../../styles/index.css";
|
||||
import { debounce } from "../../utils/debounce.ts";
|
||||
import { LyricPlayerBase } from "../base.ts";
|
||||
import { LyricLineMouseEvent } from "../dom/index.ts";
|
||||
import styles from "./index.module.css";
|
||||
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
|
||||
|
||||
/**
|
||||
* 歌词播放组件,本框架的核心组件
|
||||
*
|
||||
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
|
||||
*/
|
||||
export class DomSlimLyricPlayer extends LyricPlayerBase {
|
||||
override currentLyricLineObjects: LyricLineEl[] = [];
|
||||
|
||||
private debounceCalcLayout = debounce(
|
||||
() =>
|
||||
this.calcLayout(true).then(() =>
|
||||
this.currentLyricLineObjects.map(async (el, i) => {
|
||||
el.markMaskImageDirty("DomLyricPlayer onResize");
|
||||
await el.waitMaskImageUpdated();
|
||||
if (this.hotLines.has(i)) {
|
||||
el.enable(this.currentTime);
|
||||
el.resume();
|
||||
}
|
||||
}),
|
||||
),
|
||||
1000,
|
||||
);
|
||||
|
||||
override onResize(): void {
|
||||
const computedStyles = getComputedStyle(this.element);
|
||||
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
|
||||
const innerWidth =
|
||||
this.element.clientWidth -
|
||||
Number.parseFloat(computedStyles.paddingLeft) -
|
||||
Number.parseFloat(computedStyles.paddingRight);
|
||||
const innerHeight =
|
||||
this.element.clientHeight -
|
||||
Number.parseFloat(computedStyles.paddingTop) -
|
||||
Number.parseFloat(computedStyles.paddingBottom);
|
||||
this.innerSize[0] = innerWidth;
|
||||
this.innerSize[1] = innerHeight;
|
||||
this.rebuildStyle();
|
||||
// for (const obj of this.currentLyricLineObjects) {
|
||||
// if (!obj.getElement().classList.contains(styles.dirty))
|
||||
// obj.getElement().classList.add(styles.dirty);
|
||||
// }
|
||||
this.debounceCalcLayout();
|
||||
}
|
||||
|
||||
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
|
||||
readonly supportMaskImage = CSS.supports("mask-image", "none");
|
||||
readonly innerSize: [number, number] = [0, 0];
|
||||
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
|
||||
const evt = new LyricLineMouseEvent(
|
||||
this.lyricLinesIndexes.get(e.line) ?? -1,
|
||||
e.line,
|
||||
e,
|
||||
);
|
||||
if (!this.dispatchEvent(evt)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 是否为非逐词歌词
|
||||
* @internal
|
||||
*/
|
||||
_getIsNonDynamic() {
|
||||
return this.isNonDynamic;
|
||||
}
|
||||
private _baseFontSize = Number.parseFloat(
|
||||
getComputedStyle(this.element).fontSize,
|
||||
);
|
||||
public get baseFontSize() {
|
||||
return this._baseFontSize;
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.onResize();
|
||||
this.element.classList.add("amll-lyric-player", "dom-slim");
|
||||
if (this.disableSpring) {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStyle() {
|
||||
const width = this.innerSize[0];
|
||||
const height = this.innerSize[1];
|
||||
this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
|
||||
this.element.style.setProperty(
|
||||
"--amll-lp-height",
|
||||
`${height.toFixed(4)}px`,
|
||||
);
|
||||
}
|
||||
|
||||
override setWordFadeWidth(value = 0.5) {
|
||||
super.setWordFadeWidth(value);
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.markMaskImageDirty("DomLyricPlayer setWordFadeWidth");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
override setLyricLines(lines: LyricLine[], initialTime = 0) {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
if (this.hasDuetLine) {
|
||||
this.element.classList.add(styles.hasDuetLine);
|
||||
} else {
|
||||
this.element.classList.remove(styles.hasDuetLine);
|
||||
}
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.removeMouseEventListener("click", this.onLineClickedHandler);
|
||||
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
line.dispose();
|
||||
}
|
||||
|
||||
// 创建新的歌词行元素
|
||||
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
|
||||
const lineEl = new LyricLineEl(this, line);
|
||||
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
|
||||
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
this.element.appendChild(lineEl.getElement());
|
||||
this.lyricLinesIndexes.set(lineEl, i);
|
||||
lineEl.markMaskImageDirty("DomLyricPlayer setLyricLines");
|
||||
return lineEl;
|
||||
});
|
||||
|
||||
this.setLinePosXSpringParams({});
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true).then(() => {
|
||||
this.initialLayoutFinished = true;
|
||||
});
|
||||
}
|
||||
|
||||
override pause() {
|
||||
super.pause();
|
||||
this.interludeDots.pause();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.pause();
|
||||
}
|
||||
}
|
||||
|
||||
override resume() {
|
||||
super.resume();
|
||||
this.interludeDots.resume();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.resume();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta = 0) {
|
||||
if (!this.initialLayoutFinished) return;
|
||||
super.update(delta);
|
||||
if (!this.isPageVisible) return;
|
||||
const deltaS = delta / 1000;
|
||||
this.interludeDots.update(delta);
|
||||
this.bottomLine.update(deltaS);
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(deltaS);
|
||||
}
|
||||
}
|
||||
|
||||
override async calcLayout(sync?: boolean): Promise<void> {
|
||||
await super.calcLayout(sync);
|
||||
const curLine = this.currentLyricLineObjects[this.targetAlignIndex];
|
||||
const curLineEl = curLine.getElement();
|
||||
const curLineVisibility = curLineEl.checkVisibility({
|
||||
contentVisibilityAuto: true,
|
||||
});
|
||||
const playerTop = this.element.getBoundingClientRect().top;
|
||||
if (!curLineVisibility) {
|
||||
curLineEl.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
const curLineHeight = curLineEl.clientHeight;
|
||||
const curLineRect = curLineEl.getBoundingClientRect();
|
||||
let scrollToPos =
|
||||
curLineRect.top - playerTop - this.size[1] * this.alignPosition;
|
||||
if (curLine) {
|
||||
switch (this.alignAnchor) {
|
||||
case "bottom":
|
||||
scrollToPos += curLineHeight;
|
||||
break;
|
||||
case "center":
|
||||
scrollToPos += curLineHeight / 2;
|
||||
break;
|
||||
case "top":
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.element.scrollBy({
|
||||
top: scrollToPos,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.element.remove();
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.dispose();
|
||||
}
|
||||
this.bottomLine.dispose();
|
||||
this.interludeDots.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Disposable, HasElement } from "../../interfaces.ts";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
|
||||
function easeInOutBack(x: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c2 = c1 * 1.525;
|
||||
|
||||
return x < 0.5
|
||||
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
|
||||
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutExpo(x: number): number {
|
||||
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
|
||||
}
|
||||
|
||||
const clamp = (min: number, cur: number, max: number) =>
|
||||
Math.max(min, Math.min(cur, max));
|
||||
|
||||
export class InterludeDots implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private dot0: HTMLElement = document.createElement("span");
|
||||
private dot1: HTMLElement = document.createElement("span");
|
||||
private dot2: HTMLElement = document.createElement("span");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private playing = true;
|
||||
private lastStyle = "";
|
||||
private currentInterlude?: [number, number];
|
||||
private currentTime = 0;
|
||||
private targetBreatheDuration = 1500;
|
||||
constructor() {
|
||||
this.element.className = styles.interludeDots;
|
||||
this.element.appendChild(this.dot0);
|
||||
this.element.appendChild(this.dot1);
|
||||
this.element.appendChild(this.dot2);
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(left: number = this.left, top: number = this.top) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.update();
|
||||
}
|
||||
setInterlude(interlude?: [number, number]) {
|
||||
this.currentInterlude = interlude;
|
||||
this.currentTime = interlude?.[0] ?? 0;
|
||||
if (interlude) {
|
||||
this.element.classList.add(styles.enabled);
|
||||
} else {
|
||||
this.element.classList.remove(styles.enabled);
|
||||
}
|
||||
}
|
||||
pause() {
|
||||
this.playing = false;
|
||||
this.element.classList.remove(styles.playing);
|
||||
}
|
||||
resume() {
|
||||
this.playing = true;
|
||||
this.element.classList.add(styles.playing);
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.playing) return;
|
||||
this.currentTime += delta;
|
||||
let curStyle = "";
|
||||
|
||||
// 计算缩放大小
|
||||
|
||||
if (this.currentInterlude) {
|
||||
const interludeDuration =
|
||||
this.currentInterlude[1] - this.currentInterlude[0];
|
||||
const currentDuration = this.currentTime - this.currentInterlude[0];
|
||||
if (currentDuration <= interludeDuration) {
|
||||
const breatheDuration =
|
||||
interludeDuration /
|
||||
Math.ceil(interludeDuration / this.targetBreatheDuration);
|
||||
let scale = 1;
|
||||
let globalOpacity = 1;
|
||||
|
||||
scale *=
|
||||
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
|
||||
20 +
|
||||
1;
|
||||
|
||||
if (currentDuration < 2000) {
|
||||
scale *= easeOutExpo(currentDuration / 2000);
|
||||
}
|
||||
|
||||
if (currentDuration < 500) {
|
||||
globalOpacity = 0;
|
||||
} else if (currentDuration < 1000) {
|
||||
globalOpacity *= (currentDuration - 500) / 500;
|
||||
}
|
||||
|
||||
if (interludeDuration - currentDuration < 750) {
|
||||
scale *=
|
||||
1 -
|
||||
easeInOutBack(
|
||||
(750 - (interludeDuration - currentDuration)) / 750 / 2,
|
||||
);
|
||||
}
|
||||
if (interludeDuration - currentDuration < 375) {
|
||||
globalOpacity *= clamp(
|
||||
0,
|
||||
(interludeDuration - currentDuration) / 375,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const dotsDuration = Math.max(0, interludeDuration - 750);
|
||||
|
||||
scale = Math.max(0, scale) * 0.7;
|
||||
|
||||
curStyle += ` scale(${scale})`;
|
||||
|
||||
const dot0Opacity = clamp(
|
||||
0.25,
|
||||
((currentDuration * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot1Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot2Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
this.dot0.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot0Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot1.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot1Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot2.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot2Opacity),
|
||||
1,
|
||||
)}`;
|
||||
} else {
|
||||
curStyle += " scale(0)";
|
||||
this.dot0.style.opacity = "0";
|
||||
this.dot1.style.opacity = "0";
|
||||
this.dot2.style.opacity = "0";
|
||||
}
|
||||
|
||||
curStyle += ";";
|
||||
|
||||
if (this.lastStyle !== curStyle) {
|
||||
this.element.setAttribute("style", curStyle);
|
||||
this.lastStyle = curStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
import bezier from "bezier-easing";
|
||||
import type { LyricLine, LyricWord } from "../../interfaces.ts";
|
||||
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
|
||||
import {
|
||||
createMatrix4,
|
||||
matrix4ToCSS,
|
||||
scaleMatrix4,
|
||||
} from "../../utils/matrix.ts";
|
||||
import { mutexifyFunction } from "../../utils/mutex.ts";
|
||||
import { measure, mutate } from "../../utils/schedule.ts";
|
||||
import { LyricLineBase } from "../base.ts";
|
||||
import styles from "./index.module.css";
|
||||
import type { DomSlimLyricPlayer } from "./index.ts";
|
||||
|
||||
interface RealWord extends LyricWord {
|
||||
mainElement: HTMLSpanElement;
|
||||
subElements: HTMLSpanElement[];
|
||||
elementAnimations: Animation[];
|
||||
maskAnimations: Animation[];
|
||||
width: number;
|
||||
height: number;
|
||||
padding: number;
|
||||
shouldEmphasize: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_FRAME_QUANTITY = 32;
|
||||
|
||||
const norNum = (min: number, max: number) => (x: number) =>
|
||||
Math.min(1, Math.max(0, (x - min) / (max - min)));
|
||||
const EMP_EASING_MID = 0.5;
|
||||
const beginNum = norNum(0, EMP_EASING_MID);
|
||||
const endNum = norNum(EMP_EASING_MID, 1);
|
||||
|
||||
const bezIn = bezier(0.2, 0.4, 0.58, 1.0);
|
||||
const bezOut = bezier(0.3, 0.0, 0.58, 1.0);
|
||||
|
||||
const makeEmpEasing = (mid: number) => {
|
||||
return (x: number) => (x < mid ? bezIn(beginNum(x)) : 1 - bezOut(endNum(x)));
|
||||
};
|
||||
|
||||
function generateFadeGradient(
|
||||
width: number,
|
||||
padding = 0,
|
||||
bright = "rgba(0,0,0,var(--bright-mask-alpha, 1.0))",
|
||||
dark = "rgba(0,0,0,var(--dark-mask-alpha, 1.0))",
|
||||
): [string, number] {
|
||||
const totalAspect = 2 + width + padding;
|
||||
const widthInTotal = width / totalAspect;
|
||||
const leftPos = (1 - widthInTotal) / 2;
|
||||
return [
|
||||
`linear-gradient(to right,${bright} ${leftPos * 100}%,${dark} ${
|
||||
(leftPos + widthInTotal) * 100
|
||||
}%)`,
|
||||
totalAspect,
|
||||
];
|
||||
}
|
||||
|
||||
export class RawLyricLineMouseEvent extends MouseEvent {
|
||||
constructor(
|
||||
public readonly line: LyricLineEl,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
super(event.type, event);
|
||||
}
|
||||
}
|
||||
|
||||
function getScaleFromTransform(transform: string): number {
|
||||
const match = transform.match(/matrix\(([^)]+)\)/);
|
||||
if (match) {
|
||||
const values = match[1].split(", ");
|
||||
const scaleX = Number.parseFloat(values[0]);
|
||||
const scaleY = Number.parseFloat(values[3]);
|
||||
return (scaleX + scaleY) / 2; // Average of scaleX and scaleY
|
||||
}
|
||||
return 1; // Default scale value if not found
|
||||
}
|
||||
|
||||
type MouseEventMap = {
|
||||
[evt in keyof HTMLElementEventMap]: HTMLElementEventMap[evt] extends MouseEvent
|
||||
? evt
|
||||
: never;
|
||||
};
|
||||
type MouseEventTypes = MouseEventMap[keyof MouseEventMap];
|
||||
type MouseEventListener = (
|
||||
this: LyricLineEl,
|
||||
ev: RawLyricLineMouseEvent,
|
||||
) => void;
|
||||
|
||||
export class LyricLineEl extends LyricLineBase {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private splittedWords: RealWord[] = [];
|
||||
// 由 LyricPlayer 来设置
|
||||
lineSize: number[] = [0, 0];
|
||||
|
||||
constructor(
|
||||
private lyricPlayer: DomSlimLyricPlayer,
|
||||
private lyricLine: LyricLine = {
|
||||
words: [],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.element.setAttribute("class", styles.lyricLine);
|
||||
if (this.lyricLine.isBG) {
|
||||
this.element.classList.add(styles.lyricBgLine);
|
||||
}
|
||||
if (this.lyricLine.isDuet) {
|
||||
this.element.classList.add(styles.lyricDuetLine);
|
||||
}
|
||||
this.element.appendChild(document.createElement("div")); // 歌词行
|
||||
this.element.appendChild(document.createElement("div")); // 翻译行
|
||||
this.element.appendChild(document.createElement("div")); // 音译行
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
const trans = this.element.children[1] as HTMLDivElement;
|
||||
const roman = this.element.children[2] as HTMLDivElement;
|
||||
main.setAttribute("class", styles.lyricMainLine);
|
||||
trans.setAttribute("class", styles.lyricSubLine);
|
||||
roman.setAttribute("class", styles.lyricSubLine);
|
||||
this.rebuildElement();
|
||||
this.rebuildStyle();
|
||||
this.markMaskImageDirty("Initial construction");
|
||||
}
|
||||
private listenersMap = new Map<string, Set<MouseEventListener>>();
|
||||
private readonly onMouseEvent = (e: MouseEvent) => {
|
||||
const wrapped = new RawLyricLineMouseEvent(this, e);
|
||||
for (const listener of this.listenersMap.get(e.type) ?? []) {
|
||||
listener.call(this, wrapped);
|
||||
}
|
||||
if (!this.dispatchEvent(wrapped) || wrapped.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
addMouseEventListener(
|
||||
type: MouseEventTypes,
|
||||
callback: MouseEventListener | null,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void {
|
||||
if (callback) {
|
||||
const listeners = this.listenersMap.get(type) ?? new Set();
|
||||
if (listeners.size === 0)
|
||||
this.element.addEventListener(type, this.onMouseEvent, options);
|
||||
listeners.add(callback);
|
||||
this.listenersMap.set(type, listeners);
|
||||
}
|
||||
}
|
||||
|
||||
removeMouseEventListener(
|
||||
type: MouseEventTypes,
|
||||
callback: MouseEventListener | null,
|
||||
options?: boolean | EventListenerOptions | undefined,
|
||||
): void {
|
||||
if (callback) {
|
||||
const listeners = this.listenersMap.get(type);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0)
|
||||
this.element.removeEventListener(type, this.onMouseEvent, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areWordsOnSameLine(word1: RealWord, word2: RealWord) {
|
||||
if (word1?.mainElement && word2?.mainElement) {
|
||||
const word1el = word1.mainElement;
|
||||
const word2el = word2.mainElement;
|
||||
|
||||
const rect1 = word1el.getBoundingClientRect();
|
||||
const rect2 = word2el.getBoundingClientRect();
|
||||
|
||||
// 检查两个单词的顶部距离是否相等(或者差值很小)
|
||||
const topDifference = Math.abs(rect1.top - rect2.top);
|
||||
|
||||
// 如果顶部距离相差很小,可以认为它们在同一行上
|
||||
return topDifference < 10;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isEnabled = false;
|
||||
async enable(maskAnimationTime = this.lyricLine.startTime) {
|
||||
this.isEnabled = true;
|
||||
this.element.classList.add(styles.active);
|
||||
await this.waitMaskImageUpdated();
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
a.currentTime = 0;
|
||||
a.playbackRate = 1;
|
||||
a.play();
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
a.currentTime = Math.min(
|
||||
this.totalDuration,
|
||||
Math.max(0, maskAnimationTime - this.lyricLine.startTime),
|
||||
);
|
||||
a.playbackRate = 1;
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
main.classList.add(styles.active);
|
||||
}
|
||||
disable() {
|
||||
this.isEnabled = false;
|
||||
this.element.classList.remove(styles.active);
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
if (
|
||||
a.id === "float-word" ||
|
||||
a.id.includes("emphasize-word-float-only")
|
||||
) {
|
||||
a.playbackRate = -1;
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
main.classList.remove(styles.active);
|
||||
}
|
||||
private lastWord?: RealWord;
|
||||
async resume() {
|
||||
await this.waitMaskImageUpdated();
|
||||
if (!this.isEnabled) return;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
if (
|
||||
!this.lastWord ||
|
||||
this.splittedWords.indexOf(this.lastWord) <
|
||||
this.splittedWords.indexOf(word)
|
||||
) {
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
if (
|
||||
!this.lastWord ||
|
||||
this.splittedWords.indexOf(this.lastWord) <
|
||||
this.splittedWords.indexOf(word)
|
||||
) {
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async pause() {
|
||||
await this.waitMaskImageUpdated();
|
||||
if (!this.isEnabled) return;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
a.pause();
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
a.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
setMaskAnimationState(maskAnimationTime = 0) {
|
||||
const t = maskAnimationTime - this.lyricLine.startTime;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.maskAnimations) {
|
||||
a.currentTime = Math.min(this.totalDuration, Math.max(0, t));
|
||||
a.playbackRate = 1;
|
||||
if (t >= 0 && t < this.totalDuration) a.play();
|
||||
else a.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
private measureLockMark = false;
|
||||
private measureLock = mutexifyFunction(
|
||||
async (callback: () => Promise<void>): Promise<void> => {
|
||||
if (this.measureLockMark) return;
|
||||
this.measureLockMark = true;
|
||||
// if (this._hide) {
|
||||
// await mutate(() => {
|
||||
// this._prevParentEl.appendChild(this.element);
|
||||
// this.element.style.display = "";
|
||||
// this.element.style.visibility = "hidden";
|
||||
// });
|
||||
// }
|
||||
await callback();
|
||||
// if (this._hide) {
|
||||
// await mutate(() => {
|
||||
// this._prevParentEl.removeChild(this.element);
|
||||
// this.element.style.display = "none";
|
||||
// this.element.style.visibility = "";
|
||||
// });
|
||||
// }
|
||||
this.measureLockMark = false;
|
||||
},
|
||||
);
|
||||
|
||||
getLine() {
|
||||
return this.lyricLine;
|
||||
}
|
||||
show() {
|
||||
// this._hide = false;
|
||||
// if (!this.measureLockMark && !this.element.parentElement) {
|
||||
// this._prevParentEl.appendChild(this.element);
|
||||
// }
|
||||
this.rebuildStyle();
|
||||
}
|
||||
hide() {
|
||||
// this._hide = true;
|
||||
// if (!this.measureLockMark && this.element.parentElement) {
|
||||
// this._prevParentEl.removeChild(this.element);
|
||||
// }
|
||||
}
|
||||
private rebuildStyle() {
|
||||
// let style = "";
|
||||
// if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
|
||||
// style += `transition-delay:${this.delay}ms;`;
|
||||
// }
|
||||
// style += `filter:blur(${Math.min(32, this.blur)}px);`;
|
||||
// if (style !== this.lastStyle) {
|
||||
// this.lastStyle = style;
|
||||
// this.element.setAttribute("style", style);
|
||||
// }
|
||||
}
|
||||
rebuildElement() {
|
||||
this.disposeElements();
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
const trans = this.element.children[1] as HTMLDivElement;
|
||||
const roman = this.element.children[2] as HTMLDivElement;
|
||||
// 如果是非动态歌词,那么就不需要分词了
|
||||
if (this.lyricPlayer._getIsNonDynamic()) {
|
||||
main.innerText = this.lyricLine.words.map((w) => w.word).join("");
|
||||
trans.innerText = this.lyricLine.translatedLyric;
|
||||
roman.innerText = this.lyricLine.romanLyric;
|
||||
return;
|
||||
}
|
||||
const chunkedWords = chunkAndSplitLyricWords(this.lyricLine.words);
|
||||
main.innerHTML = "";
|
||||
for (const chunk of chunkedWords) {
|
||||
if (Array.isArray(chunk)) {
|
||||
// 多个没有空格的单词组合成的一个单词数组
|
||||
if (chunk.length === 0) continue;
|
||||
const merged = chunk.reduce(
|
||||
(a, b) => {
|
||||
a.endTime = Math.max(a.endTime, b.endTime);
|
||||
a.startTime = Math.min(a.startTime, b.startTime);
|
||||
a.word += b.word;
|
||||
return a;
|
||||
},
|
||||
{
|
||||
word: "",
|
||||
romanWord: "",
|
||||
startTime: Number.POSITIVE_INFINITY,
|
||||
endTime: Number.NEGATIVE_INFINITY,
|
||||
wordType: "normal",
|
||||
obscene: false,
|
||||
} as LyricWord,
|
||||
);
|
||||
const emp = chunk
|
||||
.map((word) => LyricLineBase.shouldEmphasize(word))
|
||||
.reduce((a, b) => a || b, LyricLineBase.shouldEmphasize(merged));
|
||||
const wrapperWordEl = document.createElement("span");
|
||||
wrapperWordEl.classList.add(styles.emphasizeWrapper);
|
||||
const characterElements: HTMLElement[] = [];
|
||||
for (const word of chunk) {
|
||||
const mainWordEl = document.createElement("span");
|
||||
// const mainWordFloatAnimation = this.initFloatAnimation(
|
||||
// merged,
|
||||
// mainWordEl,
|
||||
// );
|
||||
if (emp) {
|
||||
mainWordEl.classList.add(styles.emphasize);
|
||||
const charEls: HTMLSpanElement[] = [];
|
||||
for (const char of word.word.trim()) {
|
||||
const charEl = document.createElement("span");
|
||||
charEl.innerText = char;
|
||||
charEls.push(charEl);
|
||||
characterElements.push(charEl);
|
||||
mainWordEl.appendChild(charEl);
|
||||
}
|
||||
const realWord: RealWord = {
|
||||
...word,
|
||||
mainElement: mainWordEl,
|
||||
subElements: charEls,
|
||||
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
};
|
||||
this.splittedWords.push(realWord);
|
||||
} else {
|
||||
mainWordEl.innerText = word.word;
|
||||
this.splittedWords.push({
|
||||
...word,
|
||||
mainElement: mainWordEl,
|
||||
subElements: [],
|
||||
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
});
|
||||
}
|
||||
wrapperWordEl.appendChild(mainWordEl);
|
||||
}
|
||||
if (emp) {
|
||||
this.splittedWords[
|
||||
this.splittedWords.length - 1
|
||||
].elementAnimations.push(
|
||||
...this.initEmphasizeAnimation(
|
||||
merged,
|
||||
characterElements,
|
||||
merged.endTime - merged.startTime,
|
||||
merged.startTime - this.lyricLine.startTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (merged.word.trimStart() !== merged.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
main.appendChild(wrapperWordEl);
|
||||
if (
|
||||
merged.word.trimEnd() !== merged.word &&
|
||||
LyricLineBase.shouldEmphasize(merged)
|
||||
) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
} else if (chunk.word.trim().length === 0) {
|
||||
// 纯空格
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
} else {
|
||||
// 单个单词
|
||||
const emp = LyricLineBase.shouldEmphasize(chunk);
|
||||
const mainWordEl = document.createElement("span");
|
||||
const realWord: RealWord = {
|
||||
...chunk,
|
||||
mainElement: mainWordEl,
|
||||
subElements: [],
|
||||
// elementAnimations: [this.initFloatAnimation(chunk, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(chunk, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
};
|
||||
if (LyricLineBase.shouldEmphasize(chunk)) {
|
||||
mainWordEl.classList.add(styles.emphasize);
|
||||
const charEls: HTMLSpanElement[] = [];
|
||||
for (const char of chunk.word.trim()) {
|
||||
const charEl = document.createElement("span");
|
||||
charEl.innerText = char;
|
||||
charEls.push(charEl);
|
||||
mainWordEl.appendChild(charEl);
|
||||
}
|
||||
realWord.subElements = charEls;
|
||||
const duration = Math.abs(realWord.endTime - realWord.startTime);
|
||||
realWord.elementAnimations.push(
|
||||
...this.initEmphasizeAnimation(
|
||||
chunk,
|
||||
charEls,
|
||||
duration,
|
||||
realWord.startTime - this.lyricLine.startTime,
|
||||
),
|
||||
);
|
||||
// realWord.elementAnimations = this.initEmphasizeAnimation(realWord);
|
||||
} else {
|
||||
mainWordEl.innerText = chunk.word.trim();
|
||||
}
|
||||
if (chunk.word.trimStart() !== chunk.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
main.appendChild(mainWordEl);
|
||||
if (chunk.word.trimEnd() !== chunk.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
this.splittedWords.push(realWord);
|
||||
}
|
||||
}
|
||||
trans.innerText = this.lyricLine.translatedLyric;
|
||||
roman.innerText = this.lyricLine.romanLyric;
|
||||
}
|
||||
// 按照原 Apple Music 参考,强调效果只应用缩放、轻微左右位移和辉光效果,原主要的悬浮位移效果不变
|
||||
// 为了避免产生锯齿抖动感,使用 matrix3d 来实现缩放和位移
|
||||
private initEmphasizeAnimation(
|
||||
word: LyricWord,
|
||||
characterElements: HTMLElement[],
|
||||
duration: number,
|
||||
delay: number,
|
||||
): Animation[] {
|
||||
const de = Math.max(0, delay);
|
||||
let du = Math.max(1000, duration);
|
||||
|
||||
let result: Animation[] = [];
|
||||
|
||||
let amount = du / 2000;
|
||||
amount = amount > 1 ? Math.sqrt(amount) : amount ** 3;
|
||||
let blur = du / 3000;
|
||||
blur = blur > 1 ? Math.sqrt(blur) : blur ** 3;
|
||||
amount *= 0.6;
|
||||
blur *= 0.5;
|
||||
if (
|
||||
this.lyricLine.words.length > 0 &&
|
||||
word.word.includes(
|
||||
this.lyricLine.words[this.lyricLine.words.length - 1].word,
|
||||
)
|
||||
) {
|
||||
amount *= 1.6;
|
||||
blur *= 1.5;
|
||||
du *= 1.2;
|
||||
}
|
||||
amount = Math.min(1.2, amount);
|
||||
blur = Math.min(0.8, blur);
|
||||
|
||||
const animateDu = Number.isFinite(du) ? du : 0;
|
||||
const empEasing = makeEmpEasing(EMP_EASING_MID);
|
||||
|
||||
result = characterElements.flatMap((el, i, arr) => {
|
||||
const wordDe = de + (du / 2.5 / arr.length) * i;
|
||||
const result: Animation[] = [];
|
||||
|
||||
const frames: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
|
||||
.fill(0)
|
||||
.map((_, j) => {
|
||||
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
|
||||
const transX = empEasing(x);
|
||||
const glowLevel = empEasing(x) * blur;
|
||||
|
||||
const mat = scaleMatrix4(createMatrix4(), 1 + transX * 0.1 * amount);
|
||||
const offsetX = -transX * 0.03 * amount * (arr.length / 2 - i);
|
||||
const offsetY = -transX * 0.025 * amount;
|
||||
|
||||
return {
|
||||
offset: x,
|
||||
transform: `${matrix4ToCSS(
|
||||
mat,
|
||||
4,
|
||||
)} translate(${offsetX}em, ${offsetY}em)`,
|
||||
textShadow: `0 0 ${Math.min(
|
||||
0.3,
|
||||
blur * 0.3,
|
||||
)}em rgba(255, 255, 255, ${glowLevel})`,
|
||||
};
|
||||
});
|
||||
|
||||
const glow = el.animate(frames, {
|
||||
duration: animateDu,
|
||||
delay: Number.isFinite(wordDe) ? wordDe : 0,
|
||||
id: `emphasize-word-${el.innerText}-${i}`,
|
||||
iterations: 1,
|
||||
composite: "replace",
|
||||
fill: "both",
|
||||
});
|
||||
glow.onfinish = () => {
|
||||
glow.pause();
|
||||
};
|
||||
glow.pause();
|
||||
result.push(glow);
|
||||
|
||||
const floatFrame: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
|
||||
.fill(0)
|
||||
.map((_, j) => {
|
||||
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
|
||||
let y = Math.sin(x * Math.PI);
|
||||
// y = x < 0.5 ? y : Math.max(y, 1.0);
|
||||
if (this.lyricLine.isBG) {
|
||||
y *= 2;
|
||||
}
|
||||
|
||||
return {
|
||||
offset: x,
|
||||
transform: `translateY(${-y * 0.05}em)`,
|
||||
};
|
||||
});
|
||||
const float = el.animate(floatFrame, {
|
||||
duration: animateDu * 1.4,
|
||||
delay: Number.isFinite(wordDe) ? wordDe - 400 : 0,
|
||||
id: "emphasize-word-float",
|
||||
iterations: 1,
|
||||
composite: "add",
|
||||
fill: "both",
|
||||
});
|
||||
float.onfinish = () => {
|
||||
float.pause();
|
||||
};
|
||||
float.pause();
|
||||
result.push(float);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private get totalDuration() {
|
||||
return this.lyricLine.endTime - this.lyricLine.startTime;
|
||||
}
|
||||
private maskImageDirty = false;
|
||||
private markImageDirtyPromiseResolve: Set<() => void> = new Set();
|
||||
private markImageDirtyPromise: Promise<void> = new Promise((resolve) => {
|
||||
this.markImageDirtyPromiseResolve.add(resolve);
|
||||
});
|
||||
markMaskImageDirty(_debugReason = ""): Promise<void> {
|
||||
this.maskImageDirty = true;
|
||||
if (!this.element.classList.contains(styles.dirty))
|
||||
this.element.classList.add(styles.dirty);
|
||||
// if (import.meta.env.DEV) {
|
||||
// console.log("Mark mask image dirty: ", _debugReason);
|
||||
// }
|
||||
const newPromise = Promise.all([
|
||||
this.markImageDirtyPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
this.markImageDirtyPromiseResolve.add(resolve);
|
||||
}),
|
||||
]).then(() => {});
|
||||
this.markImageDirtyPromise = newPromise;
|
||||
return newPromise;
|
||||
}
|
||||
waitMaskImageUpdated(): Promise<void> {
|
||||
return this.markImageDirtyPromise;
|
||||
}
|
||||
async updateMaskImage() {
|
||||
if (
|
||||
!this.element.checkVisibility({
|
||||
contentVisibilityAuto: true,
|
||||
})
|
||||
)
|
||||
return;
|
||||
this.maskImageDirty = false;
|
||||
await this.measureLock(async () => {
|
||||
await Promise.all(
|
||||
this.splittedWords.map(async (word) => {
|
||||
const el = word.mainElement;
|
||||
if (el) {
|
||||
await measure(() => {
|
||||
word.padding = Number.parseFloat(
|
||||
getComputedStyle(el).paddingLeft,
|
||||
);
|
||||
word.width = el.clientWidth - word.padding * 2;
|
||||
word.height = el.clientHeight - word.padding * 2;
|
||||
});
|
||||
} else {
|
||||
word.width = 0;
|
||||
word.height = 0;
|
||||
word.padding = 0;
|
||||
}
|
||||
if (word.width * word.height === 0) {
|
||||
console.warn("Word size is zero");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await mutate(() => {
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
this.generateWebAnimationBasedMaskImage();
|
||||
} else {
|
||||
this.generateCalcBasedMaskImage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const resolve of this.markImageDirtyPromiseResolve) {
|
||||
resolve();
|
||||
this.markImageDirtyPromiseResolve.delete(resolve);
|
||||
}
|
||||
await mutate(() => {
|
||||
this.element.classList.remove(styles.dirty);
|
||||
});
|
||||
}
|
||||
private generateCalcBasedMaskImage() {
|
||||
for (const word of this.splittedWords) {
|
||||
const wordEl = word.mainElement;
|
||||
if (wordEl) {
|
||||
word.width = wordEl.clientWidth;
|
||||
word.height = wordEl.clientHeight;
|
||||
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
|
||||
const [maskImage, totalAspect] = generateFadeGradient(
|
||||
fadeWidth / word.width,
|
||||
);
|
||||
const totalAspectStr = `${totalAspect * 100}% 100%`;
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
wordEl.style.maskImage = maskImage;
|
||||
wordEl.style.maskRepeat = "no-repeat";
|
||||
wordEl.style.maskOrigin = "left";
|
||||
wordEl.style.maskSize = totalAspectStr;
|
||||
} else {
|
||||
wordEl.style.webkitMaskImage = maskImage;
|
||||
wordEl.style.webkitMaskRepeat = "no-repeat";
|
||||
wordEl.style.webkitMaskOrigin = "left";
|
||||
wordEl.style.webkitMaskSize = totalAspectStr;
|
||||
}
|
||||
const w = word.width + fadeWidth;
|
||||
const maskPos = `clamp(${-w}px,calc(${-w}px + (var(--amll-player-time) - ${
|
||||
word.startTime
|
||||
})*${
|
||||
w / Math.abs(word.endTime - word.startTime)
|
||||
}px),0px) 0px, left top`;
|
||||
wordEl.style.maskPosition = maskPos;
|
||||
wordEl.style.webkitMaskPosition = maskPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
private generateWebAnimationBasedMaskImage() {
|
||||
// 因为歌词行有可能比行内单词的结束时间早,有可能导致过渡动画提早停止出现瑕疵
|
||||
// 所以要以单词的结束时间为准
|
||||
const totalFadeDuration =
|
||||
Math.max(
|
||||
this.splittedWords.reduce((pv, w) => Math.max(w.endTime, pv), 0),
|
||||
this.lyricLine.endTime,
|
||||
) - this.lyricLine.startTime;
|
||||
this.splittedWords.forEach((word, i) => {
|
||||
const wordEl = word.mainElement;
|
||||
if (wordEl) {
|
||||
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
|
||||
const [maskImage, totalAspect] = generateFadeGradient(
|
||||
fadeWidth / (word.width + word.padding * 2),
|
||||
);
|
||||
const totalAspectStr = `${totalAspect * 100}% 100%`;
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
wordEl.style.maskImage = maskImage;
|
||||
wordEl.style.maskRepeat = "no-repeat";
|
||||
wordEl.style.maskOrigin = "left";
|
||||
wordEl.style.maskSize = totalAspectStr;
|
||||
} else {
|
||||
wordEl.style.webkitMaskImage = maskImage;
|
||||
wordEl.style.webkitMaskRepeat = "no-repeat";
|
||||
wordEl.style.webkitMaskOrigin = "left";
|
||||
wordEl.style.webkitMaskSize = totalAspectStr;
|
||||
}
|
||||
// 为了尽可能将渐变动画在相连的每个单词间近似衔接起来
|
||||
// 要综合每个单词的效果时间和间隙生成动画帧数组
|
||||
const widthBeforeSelf =
|
||||
this.splittedWords.slice(0, i).reduce((a, b) => a + b.width, 0) +
|
||||
(this.splittedWords[0] ? fadeWidth : 0);
|
||||
const minOffset = -(word.width + word.padding * 2 + fadeWidth);
|
||||
const clampOffset = (x: number) => Math.max(minOffset, Math.min(0, x));
|
||||
let curPos = -widthBeforeSelf - word.width - word.padding - fadeWidth;
|
||||
let timeOffset = 0;
|
||||
const frames: Keyframe[] = [];
|
||||
let lastPos = curPos;
|
||||
let lastTime = 0;
|
||||
const pushFrame = () => {
|
||||
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
|
||||
// const easing = "cubic-bezier(.33,.12,.83,.9)";
|
||||
const moveOffset = curPos - lastPos;
|
||||
const time = Math.max(0, Math.min(1, timeOffset));
|
||||
const duration = time - lastTime;
|
||||
const d = Math.abs(duration / moveOffset);
|
||||
// 因为有可能会和之前的动画有边界
|
||||
if (curPos > minOffset && lastPos < minOffset) {
|
||||
const staticTime = Math.abs(lastPos - minOffset) * d;
|
||||
const value = `${clampOffset(lastPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: lastTime + staticTime,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
}
|
||||
if (curPos > 0 && lastPos < 0) {
|
||||
const staticTime = Math.abs(lastPos) * d;
|
||||
const value = `${clampOffset(curPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: lastTime + staticTime,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
}
|
||||
const value = `${clampOffset(curPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: time,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
lastPos = curPos;
|
||||
lastTime = time;
|
||||
};
|
||||
pushFrame();
|
||||
let lastTimeStamp = 0;
|
||||
this.splittedWords.forEach((otherWord, j) => {
|
||||
// 停顿
|
||||
{
|
||||
const curTimeStamp = otherWord.startTime - this.lyricLine.startTime;
|
||||
const staticDuration = curTimeStamp - lastTimeStamp;
|
||||
timeOffset += staticDuration / totalFadeDuration;
|
||||
if (staticDuration > 0) pushFrame();
|
||||
lastTimeStamp = curTimeStamp;
|
||||
}
|
||||
// 移动
|
||||
{
|
||||
const fadeDuration = otherWord.endTime - otherWord.startTime;
|
||||
timeOffset += fadeDuration / totalFadeDuration;
|
||||
curPos += otherWord.width;
|
||||
if (j === 0) {
|
||||
curPos += fadeWidth * 1.5;
|
||||
}
|
||||
if (j === this.splittedWords.length - 1) {
|
||||
curPos += fadeWidth * 0.5;
|
||||
}
|
||||
if (fadeDuration > 0) pushFrame();
|
||||
lastTimeStamp += fadeDuration;
|
||||
}
|
||||
});
|
||||
for (const a of word.maskAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
try {
|
||||
// TODO: 如果此处动画帧计算出错,需要一个后备方案
|
||||
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
|
||||
const ani = wordEl.animate(frames, {
|
||||
duration: totalFadeDuration || 1,
|
||||
id: `fade-word-${word.word}-${i}`,
|
||||
fill: "both",
|
||||
});
|
||||
ani.pause();
|
||||
word.maskAnimations = [ani];
|
||||
} catch (err) {
|
||||
console.warn("应用渐变动画发生错误", frames, totalFadeDuration, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
override setTransform(
|
||||
top: number = this.top,
|
||||
scale: number = this.scale,
|
||||
opacity = 1,
|
||||
blur = 0,
|
||||
force = false,
|
||||
delay = 0,
|
||||
) {
|
||||
super.setTransform(top, scale, opacity, blur, force, delay);
|
||||
const beforeInSight = this.isInSight;
|
||||
const enableSpring = this.lyricPlayer.getEnableSpring();
|
||||
this.top = top;
|
||||
this.scale = scale;
|
||||
this.delay = (delay * 1000) | 0;
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
// main.style.opacity = `${opacity *
|
||||
// (!this.hasFaded ? 1 : this.lyricPlayer._getIsNonDynamic() ? 1 : 0.3)
|
||||
// }`;
|
||||
main.style.opacity = `${opacity}`;
|
||||
// trans.style.opacity = `${subopacity}`;
|
||||
// roman.style.opacity = `${subopacity}`;
|
||||
if (force || !enableSpring) {
|
||||
if (force) this.element.classList.add(styles.tmpDisableTransition);
|
||||
// this.lineWebAnimationTransforms.posX.setTargetPosition(left);
|
||||
// this.lineWebAnimationTransforms.posY.setTargetPosition(top);
|
||||
// this.lineWebAnimationTransforms.scale.setTargetPosition(scale);
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
this.lineTransforms.scale.setPosition(scale);
|
||||
if (!enableSpring) {
|
||||
const afterInSight = this.isInSight;
|
||||
if (beforeInSight || afterInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
} else this.rebuildStyle();
|
||||
if (force)
|
||||
requestAnimationFrame(() => {
|
||||
this.element.classList.remove(styles.tmpDisableTransition);
|
||||
});
|
||||
} else {
|
||||
// this.lineWebAnimationTransforms.posX.stop();
|
||||
// this.lineWebAnimationTransforms.posY.stop();
|
||||
// this.lineWebAnimationTransforms.scale.stop();
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
this.lineTransforms.scale.setTargetPosition(scale);
|
||||
}
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.lyricPlayer.getEnableSpring()) return;
|
||||
this.lineTransforms.posY.update(delta);
|
||||
this.lineTransforms.scale.update(delta);
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
if (this.maskImageDirty) {
|
||||
this.updateMaskImage();
|
||||
}
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
if (this.lyricPlayer.getEnableSpring()) {
|
||||
this.element.style.setProperty(
|
||||
"--bright-mask-alpha",
|
||||
`${
|
||||
Math.max(
|
||||
0.0,
|
||||
Math.min(
|
||||
1.0,
|
||||
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
|
||||
) / 0.03,
|
||||
) *
|
||||
0.8 +
|
||||
0.2
|
||||
}`,
|
||||
);
|
||||
this.element.style.setProperty(
|
||||
"--dark-mask-alpha",
|
||||
`${
|
||||
Math.max(
|
||||
0.0,
|
||||
Math.min(
|
||||
1.0,
|
||||
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
|
||||
) / 0.03,
|
||||
) *
|
||||
0.2 +
|
||||
0.2
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
const computedStyle = window.getComputedStyle(this.element);
|
||||
const transform = computedStyle.transform;
|
||||
|
||||
// Extract the scale value from the transform property
|
||||
const scale = getScaleFromTransform(transform);
|
||||
|
||||
this.element.style.setProperty(
|
||||
"--bright-mask-alpha",
|
||||
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.8 + 0.2}`,
|
||||
);
|
||||
this.element.style.setProperty(
|
||||
"--dark-mask-alpha",
|
||||
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.2 + 0.2}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getDebugTargetPos(): string {
|
||||
return `[位移: ${this.top}; 缩放: ${this.scale}; 延时: ${this.delay}]`;
|
||||
}
|
||||
|
||||
get isInSight() {
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const h = this.lineSize[1];
|
||||
const b = t + h;
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
return !(t > pb + h || b < -h);
|
||||
}
|
||||
private disposeElements() {
|
||||
for (const realWord of this.splittedWords) {
|
||||
for (const a of realWord.elementAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
for (const a of realWord.maskAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
for (const sub of realWord.subElements) {
|
||||
sub.remove();
|
||||
sub.parentNode?.removeChild(sub);
|
||||
}
|
||||
realWord.elementAnimations = [];
|
||||
realWord.maskAnimations = [];
|
||||
realWord.subElements = [];
|
||||
realWord.mainElement.remove();
|
||||
realWord.mainElement.parentNode?.removeChild(realWord.mainElement);
|
||||
}
|
||||
this.splittedWords = [];
|
||||
}
|
||||
override dispose(): void {
|
||||
this.disposeElements();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import "../../styles/index.css";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
import { type LyricLineBase, LyricPlayerBase } from "../base.ts";
|
||||
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
|
||||
|
||||
/**
|
||||
* 歌词行鼠标相关事件,可以获取到歌词行的索引和歌词行元素
|
||||
*/
|
||||
export class LyricLineMouseEvent extends MouseEvent {
|
||||
constructor(
|
||||
/**
|
||||
* 歌词行索引
|
||||
*/
|
||||
public readonly lineIndex: number,
|
||||
/**
|
||||
* 歌词行元素
|
||||
*/
|
||||
public readonly line: LyricLineBase,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
super(`line-${event.type}`, event);
|
||||
}
|
||||
}
|
||||
|
||||
export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 歌词播放组件,本框架的核心组件
|
||||
*
|
||||
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
|
||||
*/
|
||||
export class DomLyricPlayer extends LyricPlayerBase {
|
||||
override currentLyricLineObjects: LyricLineEl[] = [];
|
||||
|
||||
override onResize(): void {
|
||||
const computedStyles = getComputedStyle(this.element);
|
||||
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
|
||||
this.rebuildStyle();
|
||||
}
|
||||
|
||||
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
|
||||
readonly supportMaskImage = CSS.supports("mask-image", "none");
|
||||
readonly innerSize: [number, number] = [0, 0];
|
||||
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
|
||||
const evt = new LyricLineMouseEvent(
|
||||
this.lyricLinesIndexes.get(e.line) ?? -1,
|
||||
e.line,
|
||||
e,
|
||||
);
|
||||
if (!this.dispatchEvent(evt)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 是否为非逐词歌词
|
||||
* @internal
|
||||
*/
|
||||
_getIsNonDynamic() {
|
||||
return this.isNonDynamic;
|
||||
}
|
||||
private _baseFontSize = Number.parseFloat(
|
||||
getComputedStyle(this.element).fontSize,
|
||||
);
|
||||
public get baseFontSize() {
|
||||
return this._baseFontSize;
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.onResize();
|
||||
this.element.classList.add("amll-lyric-player", "dom");
|
||||
if (this.disableSpring) {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStyle() {
|
||||
// const width = this.innerSize[0];
|
||||
// const height = this.innerSize[1];
|
||||
// this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
|
||||
// this.element.style.setProperty(
|
||||
// "--amll-lp-height",
|
||||
// `${height.toFixed(4)}px`,
|
||||
// );
|
||||
}
|
||||
|
||||
override setWordFadeWidth(value = 0.5) {
|
||||
super.setWordFadeWidth(value);
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.updateMaskImageSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
override setLyricLines(lines: LyricLine[], initialTime = 0) {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
if (this.hasDuetLine) {
|
||||
this.element.classList.add(styles.hasDuetLine);
|
||||
} else {
|
||||
this.element.classList.remove(styles.hasDuetLine);
|
||||
}
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty("--amll-player-time", `${initialTime}`);
|
||||
}
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.removeMouseEventListener("click", this.onLineClickedHandler);
|
||||
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
line.dispose();
|
||||
}
|
||||
|
||||
// 创建新的歌词行元素
|
||||
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
|
||||
const lineEl = new LyricLineEl(this, line);
|
||||
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
|
||||
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
// 不立即挂载到 DOM,进入视图(含 overscan)后在 LyricLineEl 内部挂载
|
||||
this.lyricLinesIndexes.set(lineEl, i);
|
||||
// 仍需建立元素到行对象的映射,供 ResizeObserver 使用
|
||||
this.lyricLineElementMap.set(lineEl.getElement(), lineEl);
|
||||
return lineEl;
|
||||
});
|
||||
|
||||
this.setLinePosXSpringParams({});
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true);
|
||||
// 触发一次更新以便立即挂载在视区/overscan 内的行元素
|
||||
this.update(0);
|
||||
}
|
||||
|
||||
override pause() {
|
||||
super.pause();
|
||||
this.element.classList.remove("playing");
|
||||
this.interludeDots.pause();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.pause();
|
||||
}
|
||||
}
|
||||
|
||||
override resume() {
|
||||
super.resume();
|
||||
this.element.classList.add("playing");
|
||||
this.interludeDots.resume();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.resume();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta = 0) {
|
||||
if (!this.initialLayoutFinished) return;
|
||||
super.update(delta);
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty(
|
||||
"--amll-player-time",
|
||||
`${this.currentTime}`,
|
||||
);
|
||||
}
|
||||
if (!this.isPageVisible) return;
|
||||
const deltaS = delta / 1000;
|
||||
this.interludeDots.update(delta);
|
||||
this.bottomLine.update(deltaS);
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(deltaS);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.element.remove();
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.dispose();
|
||||
}
|
||||
this.bottomLine.dispose();
|
||||
this.interludeDots.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Disposable, HasElement } from "../../interfaces.ts";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
|
||||
function easeInOutBack(x: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c2 = c1 * 1.525;
|
||||
|
||||
return x < 0.5
|
||||
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
|
||||
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutExpo(x: number): number {
|
||||
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
|
||||
}
|
||||
|
||||
const clamp = (min: number, cur: number, max: number) =>
|
||||
Math.max(min, Math.min(cur, max));
|
||||
|
||||
export class InterludeDots implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private dot0: HTMLElement = document.createElement("span");
|
||||
private dot1: HTMLElement = document.createElement("span");
|
||||
private dot2: HTMLElement = document.createElement("span");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private playing = true;
|
||||
private lastStyle = "";
|
||||
private currentInterlude?: [number, number];
|
||||
private currentTime = 0;
|
||||
private targetBreatheDuration = 1500;
|
||||
constructor() {
|
||||
this.element.className = styles.interludeDots;
|
||||
this.element.appendChild(this.dot0);
|
||||
this.element.appendChild(this.dot1);
|
||||
this.element.appendChild(this.dot2);
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(left: number = this.left, top: number = this.top) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.update();
|
||||
}
|
||||
setInterlude(interlude?: [number, number]) {
|
||||
this.currentInterlude = interlude;
|
||||
this.currentTime = interlude?.[0] ?? 0;
|
||||
if (interlude) {
|
||||
this.element.classList.add(styles.enabled);
|
||||
} else {
|
||||
this.element.classList.remove(styles.enabled);
|
||||
}
|
||||
}
|
||||
pause() {
|
||||
this.playing = false;
|
||||
this.element.classList.remove(styles.playing);
|
||||
}
|
||||
resume() {
|
||||
this.playing = true;
|
||||
this.element.classList.add(styles.playing);
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.playing) return;
|
||||
this.currentTime += delta;
|
||||
let curStyle = "";
|
||||
|
||||
curStyle += `transform:translate(${this.left.toFixed(
|
||||
2,
|
||||
)}px, ${this.top.toFixed(2)}px)`;
|
||||
|
||||
// 计算缩放大小
|
||||
|
||||
if (this.currentInterlude) {
|
||||
const interludeDuration =
|
||||
this.currentInterlude[1] - this.currentInterlude[0];
|
||||
const currentDuration = this.currentTime - this.currentInterlude[0];
|
||||
if (currentDuration <= interludeDuration) {
|
||||
const breatheDuration =
|
||||
interludeDuration /
|
||||
Math.ceil(interludeDuration / this.targetBreatheDuration);
|
||||
let scale = 1;
|
||||
let globalOpacity = 1;
|
||||
|
||||
scale *=
|
||||
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
|
||||
20 +
|
||||
1;
|
||||
|
||||
if (currentDuration < 2000) {
|
||||
scale *= easeOutExpo(currentDuration / 2000);
|
||||
}
|
||||
|
||||
if (currentDuration < 500) {
|
||||
globalOpacity = 0;
|
||||
} else if (currentDuration < 1000) {
|
||||
globalOpacity *= (currentDuration - 500) / 500;
|
||||
}
|
||||
|
||||
if (interludeDuration - currentDuration < 750) {
|
||||
scale *=
|
||||
1 -
|
||||
easeInOutBack(
|
||||
(750 - (interludeDuration - currentDuration)) / 750 / 2,
|
||||
);
|
||||
}
|
||||
if (interludeDuration - currentDuration < 375) {
|
||||
globalOpacity *= clamp(
|
||||
0,
|
||||
(interludeDuration - currentDuration) / 375,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const dotsDuration = Math.max(0, interludeDuration - 750);
|
||||
|
||||
scale = Math.max(0, scale) * 0.7;
|
||||
|
||||
curStyle += ` scale(${scale})`;
|
||||
|
||||
const dot0Opacity = clamp(
|
||||
0.25,
|
||||
((currentDuration * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot1Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot2Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
this.dot0.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot0Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot1.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot1Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot2.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot2Opacity),
|
||||
1,
|
||||
)}`;
|
||||
} else {
|
||||
curStyle += " scale(0)";
|
||||
this.dot0.style.opacity = "0";
|
||||
this.dot1.style.opacity = "0";
|
||||
this.dot2.style.opacity = "0";
|
||||
}
|
||||
|
||||
curStyle += ";";
|
||||
|
||||
if (this.lastStyle !== curStyle) {
|
||||
this.element.setAttribute("style", curStyle);
|
||||
this.lastStyle = curStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+1050
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
import { DomLyricPlayer } from "./dom/index.ts";
|
||||
|
||||
export { LyricPlayerBase } from "./base.ts";
|
||||
export * from "./canvas/index.ts";
|
||||
export * from "./dom-slim/index.ts";
|
||||
export * from "./dom/index.ts";
|
||||
|
||||
/**
|
||||
* 歌词中不雅用语的掩码模式
|
||||
*/
|
||||
enum MaskObsceneWordsMode {
|
||||
/** 禁用任何不雅用语掩码 */
|
||||
Disabled = "",
|
||||
/** 完全掩码所有不雅用语 */
|
||||
FullMask = "full-mask",
|
||||
// TODO: 更多模式
|
||||
}
|
||||
|
||||
export {
|
||||
MaskObsceneWordsMode,
|
||||
/**
|
||||
* 默认导出的歌词播放组件
|
||||
*/
|
||||
DomLyricPlayer as LyricPlayer,
|
||||
};
|
||||
Reference in New Issue
Block a user