初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+99
View File
@@ -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
View File
@@ -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);
}
}
+185
View File
@@ -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();
}
}
+186
View File
@@ -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
View File
@@ -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();
}
}
+976
View File
@@ -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
View File
@@ -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();
}
}
+171
View File
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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,
};