fork(fix): Clone AMLL 并修复 BUG

- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork)
- 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
lqtmcstudio
2026-06-07 00:02:14 +08:00
parent 783d2c3dee
commit 72f4510dc8
458 changed files with 86075 additions and 1665 deletions

View File

@@ -0,0 +1,76 @@
## 0.5.1 (2026-05-17)
### Patch Changes
- **fix:** 修复含对唱时歌词错误提前导致的多行高亮 ([#521](https://github.com/amll-dev/applemusic-like-lyrics/pull/521))
- **refactor:** 引入歌词组来包装主歌词和背景人声 & 前置背景人声 ([#531](https://github.com/amll-dev/applemusic-like-lyrics/pull/531))
- **chore:** 更正 package.json 协议声明 ([#534](https://github.com/amll-dev/applemusic-like-lyrics/pull/534))
仓库根目录的 LICENSE 文件为 AGPL v3.0 协议,但是 package.json 中的 `license` 字段为 `GPL-3.0`。经与原开发者确认package.json 中的 `license` 字段有误。仓库与其所有产出的 npm 包均应为 AGPL v3 only 协议SPDX: `AGPL-3.0-only`。因此,更正各包 `package.json``license` 字段为 `AGPL-3.0-only`
- **chore(core):** 优化类型定义 ([#519](https://github.com/amll-dev/applemusic-like-lyrics/pull/519))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)
## 0.5.0 (2026-05-12)
### Minor Changes
- **refactor:** 整理核心播放器代码结构,将抽象接口部分集中到统一目录 ([#508](https://github.com/amll-dev/applemusic-like-lyrics/pull/508))
- **refactor:** 整理核心播放器抽象类中时间线、滚动与单行布局部分的结构与状态管理 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
### Patch Changes
- **fix:** 修复 setCurrentTime 在提供 isSeek 标志时,实际排版未遵守标志导致布局异常漂移的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
- **fix:** 修复在同一行时间内拖拽进度条时逐字动画不同步的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
- **fix:** 修复暂停状态下点击行跳转时仍播放逐字动画的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
### Contributors
- Linho [@Linho1219](https://github.com/Linho1219)
## 0.4.2 (2026-05-01)
### Patch Changes
- **feat(core):** 平衡行长度时优先在标点处换行 ([#503](https://github.com/amll-dev/applemusic-like-lyrics/pull/503))
- **fix:** 修复背景行注音高度错误 ([#497](https://github.com/amll-dev/applemusic-like-lyrics/pull/497))
- **fix(core):** 修正平衡行长度时的行宽度计算 ([#502](https://github.com/amll-dev/applemusic-like-lyrics/pull/502))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)
## 0.4.1 (2026-04-23)
### Patch Changes
- **fix:** 在各绑定中暴露歌词优化选项 ([#492](https://github.com/amll-dev/applemusic-like-lyrics/pull/492))
- **fix(vue):** 修复掩码模式错误的类型 ([#496](https://github.com/amll-dev/applemusic-like-lyrics/pull/496))
- **refactor(core):** 重构平均行长度实现 ([#494](https://github.com/amll-dev/applemusic-like-lyrics/pull/494))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
## 0.4.0 (2026-04-14)
### Minor Changes
- **chore:** 移除 canvas 歌词渲染器 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
### Patch Changes
- **refactor:** 重构核心库测试组织模式 ([3db83c93](https://github.com/amll-dev/applemusic-like-lyrics/commit/3db83c93))
- **docs:** 修正 optimize-lyric.ts 和 OptimizeLyricOptions 里 cleanUnintentionalOverlaps 的文档和注释 ([75a8c0bb](https://github.com/amll-dev/applemusic-like-lyrics/commit/75a8c0bb))
- **chore:** 更换工具链 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
- **chore:** 在项目范围内启用 isolatedDeclarations ([#480](https://github.com/amll-dev/applemusic-like-lyrics/pull/480))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)
- MoYingJi [@MoYingJi](https://github.com/MoYingJi)

View File

@@ -0,0 +1,83 @@
# AMLL Core
[English](./README.md) / 简体中文
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件。
此处的东西都是 UI 框架无关的,所以可以间接在各种动态页面框架下引用。
或者如果你需要使用组件绑定的话,这里有 [React 绑定版本](../react/README.md) 和 [Vue 绑定版本](../vue/README.md)
## 特性
- 纯前端渲染的歌词展示与背景渲染
- 动态逐字歌词、翻译与音译显示
- 支持多行、对唱与背景行
- 可通过 CSS 变量自定义颜色与部分表现
## 安装
安装使用的依赖(如果以下列出的依赖包没有安装的话需要自行安装):
```bash
npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 npm
yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 yarn
```
安装本体框架:
```bash
npm install @applemusic-like-lyrics/core # 使用 npm
yarn add @applemusic-like-lyrics/core # 使用 yarn
```
## 使用方式摘要
详细的 API 文档请参考 [./docs/modules.md](./docs/modules.md)
一个测试用途的程序可以在 [../playground/core/src/test.ts](../playground/core/src/test.ts) 里找到。
```typescript
import { LyricPlayer } from "@applemusic-like-lyrics/core";
import "@applemusic-like-lyrics/core/style.css"; // 导入需要的样式
const player = new LyricPlayer(); // 创建歌词播放组件
document.body.appendChild(player.getElement()); // 将组件的元素添加到页面
player.setLyricLines([]) // 设置歌词
player.setCurrentTime(0) // 设定当前播放时间(需要逐帧调用)
player.update(0) // 更新歌词组件动画(需要逐帧调用)
```
每次通过 `LyricPlayer.setLyricLines` 设置的歌词是一个 `LyricLine[]` 参数,具体可以参考 [./src/interfaces.ts](./src/interfaces.ts) 中的代码。
## 数据结构
歌词的输入结构为 `LyricLine[]`,其中每行包含:
- `words`: 逐字歌词数组,每个单词包含 `startTime` / `endTime` / `word`,并可选包含 `romanWord``ruby``obscene` 等字段
- `translatedLyric`: 翻译行文本
- `romanLyric`: 音译行文本
- `startTime` / `endTime`: 行级时间戳
- `isBG` / `isDuet`: 背景行与对唱行标记
## 样式定制
主要样式由 `@applemusic-like-lyrics/core/style.css` 提供,常用自定义方式为覆写 CSS 变量,例如:
```css
.amll-lyric-player {
--amll-lp-color: #ffffff;
--amll-lp-bg-color: rgba(0, 0, 0, 0.35);
}
```
## 开发与构建
```bash
pnpm --filter @applemusic-like-lyrics/core dev
pnpm --filter @applemusic-like-lyrics/core build
```

View File

@@ -0,0 +1,83 @@
# AMLL Core
English / [简体中文](./README-CN.md)
> Warning: This is a personal project and is still under development. There may still be many issues, so please do not use it directly in production environments!
![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
AMLL's pure JS core component framework, including lyric display components and background components and other reusable components.
Everything here is UI framework-independent, so it can be indirectly referenced under various dynamic page frameworks.
Or if you need to use component bindings, there's a [React binding version](../react/README.md) and a [Vue binding version](../vue/README.md)
## Features
- Pure frontend rendering for lyrics and background
- Word-level timed lyrics with translation and romanization
- Multi-line, duet, and background line support
- Style customization via CSS variables
## Installation
Install the required dependencies (if the dependencies listed below are not installed, you need to install them yourself):
```bash
npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using npm
yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using yarn
```
Install the framework:
```bash
npm install @applemusic-like-lyrics/core # using npm
yarn add @applemusic-like-lyrics/core # using yarn
```
## Usage Summary
For detailed API documentation, please refer to [./docs/modules.md](./docs/modules.md)
A test program can be found in [../playground/core/src/test.ts](../playground/core/src/test.ts).
```typescript
import { LyricPlayer } from "@applemusic-like-lyrics/core";
import "@applemusic-like-lyrics/core/style.css"; // Import required styles
const player = new LyricPlayer(); // Create a lyric player component
document.body.appendChild(player.getElement()); // Add the component's element to the page
player.setLyricLines([]) // Set lyrics
player.setCurrentTime(0) // Set current playback time (needs to be called every frame)
player.update(0) // Update lyric component animation (needs to be called every frame)
```
The lyrics set through `LyricPlayer.setLyricLines` is a `LyricLine[]` parameter. For details, please refer to the code in [./src/interfaces.ts](./src/interfaces.ts).
## Data Model
Lyrics input is `LyricLine[]`, with each line containing:
- `words`: timed word array, each word includes `startTime` / `endTime` / `word`, with optional `romanWord`, `ruby`, and `obscene`
- `translatedLyric`: translation text
- `romanLyric`: romanization text
- `startTime` / `endTime`: line timestamps
- `isBG` / `isDuet`: background and duet flags
## Styling
The main styles are provided by `@applemusic-like-lyrics/core/style.css`. Common overrides are via CSS variables:
```css
.amll-lyric-player {
--amll-lp-color: #ffffff;
--amll-lp-bg-color: rgba(0, 0, 0, 0.35);
}
```
## Development
```bash
pnpm --filter @applemusic-like-lyrics/core dev
pnpm --filter @applemusic-like-lyrics/core build
```

View File

@@ -0,0 +1,88 @@
{
"name": "@applemusic-like-lyrics/core",
"version": "0.5.1",
"description": "AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件",
"repository": {
"url": "https://github.com/amll-dev/applemusic-like-lyrics.git",
"directory": "packages/core",
"type": "git"
},
"license": "AGPL-3.0-only",
"nx": {
"tags": [
"library"
],
"targets": {
"nx-release-publish": {
"executor": "@nx/js:release-publish",
"dependsOn": []
}
}
},
"files": [
"dist"
],
"scripts": {
"build:docs": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts",
"typecheck": "tsgo -b",
"build-only": "tsdown",
"build": "run-p typecheck \"build-only {@}\" --",
"build:dev": "tsdown",
"fmt": "biome format --write ./src",
"dev": "nx run @applemusic-like-lyrics/playground-core:dev"
},
"type": "module",
"main": "./dist/amll-core.cjs",
"module": "./dist/amll-core.mjs",
"typings": "./dist/amll-core.d.mts",
"imports": {
"#interfaces": "./src/interfaces.ts",
"#utils/*": "./src/utils/*",
"#styles/*": "./src/styles/*",
"#lyric/*": "./src/lyric-player/*",
"#bg/*": "./src/bg-player/*"
},
"exports": {
".": {
"import": {
"types": "./dist/amll-core.d.mts",
"default": "./dist/amll-core.mjs"
},
"require": {
"types": "./dist/amll-core.d.cts",
"default": "./dist/amll-core.cjs"
}
},
"./style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"
}
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/deep-freeze": "^0.1.5",
"@types/stats.js": "^0.17.3",
"@types/ungap__structured-clone": "^1.2.0",
"lil-gui": "^0.21.0",
"stats.js": "^0.17.0",
"typedoc": "^0.28.18",
"typedoc-plugin-markdown": "^4.11.0",
"@tsdown/css": "^0.22.0",
"tsdown": "^0.22.0"
},
"peerDependencies": {
"@pixi/app": "*",
"@pixi/core": "*",
"@pixi/display": "*",
"@pixi/filter-blur": "*",
"@pixi/filter-bulge-pinch": "*",
"@pixi/filter-color-matrix": "*",
"@pixi/sprite": "*"
},
"dependencies": {
"@ungap/structured-clone": "^1.3.0",
"bezier-easing": "^3.0.0",
"deep-freeze": "^0.0.1",
"gl-matrix": "4.0.0-beta.2"
}
}

View File

@@ -0,0 +1,154 @@
import type { Disposable, HasElement } from "../interfaces.ts";
export abstract class AbstractBaseRenderer implements Disposable, HasElement {
/**
* 修改背景的流动速度,数字越大越快,默认为 8
* @param speed 背景的流动速度,默认为 8
*/
abstract setFlowSpeed(speed: number): void;
/**
* 修改背景的渲染比例,默认是 0.5
*
* 一般情况下这个程度既没有明显瑕疵也不会特别吃性能
* @param scale 背景的渲染比例
*/
abstract setRenderScale(scale: number): void;
/**
* 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能
* @param enable 是否启用静态模式
*/
abstract setStaticMode(enable: boolean): void;
/**
* 修改背景动画帧率,默认是 30 FPS
*
* 如果设置成 0 则会停止动画
* @param fps 目标帧率,默认 30 FPS
*/
abstract setFPS(fps: number): void;
/**
* 暂停背景动画,画面即便是更新了图片也不会发生变化
*/
abstract pause(): void;
/**
* 恢复播放背景动画
*/
abstract resume(): void;
/**
* 设置背景专辑资源,纹理加载并设置完成后会返回
* @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数
*/
abstract setAlbum(
albumSource: string | HTMLImageElement | HTMLVideoElement,
isVideo?: boolean,
): Promise<void>;
/**
* 设置低频的音量大小,范围在 80hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间
*
* 部分渲染器会根据音量大小调整背景效果(例如根据鼓点跳动)
*
* 如果无法获取到类似的数据,请传入 1.0 作为默认值,或不做任何处理(默认值即 1.0
* @param volume 低频的音量大小,范围在 50hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间
*/
abstract setLowFreqVolume(volume: number): void;
/**
* 设置背景是否根据“是否有歌词”这个特征调整自身效果,例如有歌词时会变得更加活跃
*
* 部分渲染器会根据这个特征调整自身效果
*
* 如果不确定是否需要赋值或无法知晓是否包含歌词,请传入 true 或不做任何处理(默认值为 true
*
* @param hasLyric 是否有歌词,如不确定是否需要赋值,请传入 true 或不做任何处理(默认值为 true
*/
abstract setHasLyric(hasLyric: boolean): void;
abstract dispose(): void;
abstract getElement(): HTMLElement;
}
function clamp1(x: number): number {
return Math.max(1, x);
}
export abstract class BaseRenderer extends AbstractBaseRenderer {
private observer: ResizeObserver;
protected flowSpeed = 1;
protected currerntRenderScale = 0.75;
constructor(protected canvas: HTMLCanvasElement) {
super();
this.observer = new ResizeObserver(() => {
const width = clamp1(
canvas.clientWidth * window.devicePixelRatio * this.currerntRenderScale,
);
const height = clamp1(
canvas.clientHeight *
window.devicePixelRatio *
this.currerntRenderScale,
);
this.onResize(width, height);
});
this.observer.observe(canvas);
}
setRenderScale(scale: number): void {
this.currerntRenderScale = scale;
this.onResize(
this.canvas.clientWidth *
window.devicePixelRatio *
this.currerntRenderScale,
this.canvas.clientHeight *
window.devicePixelRatio *
this.currerntRenderScale,
);
}
/**
* 当画板元素大小发生变化时此函数会被调用
* 可以在此处重设和渲染器相关的尺寸设置
* 考虑到初始化的时候元素不一定在文档中或出于某些特殊样式状态,尺寸长宽有可能会为 0请注意进行特判处理
* @param width 画板元素实际的物理像素宽度,有可能为 0
* @param height 画板元素实际的物理像素高度,有可能为 0
*/
protected onResize(width: number, height: number): void {
this.canvas.width = width;
this.canvas.height = height;
}
/**
* 修改背景的流动速度,数字越大越快,默认为 1
* @param speed 背景的流动速度,默认为 1
*/
setFlowSpeed(speed: number): void {
this.flowSpeed = speed;
}
/**
* 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能
* @param enable 是否启用静态模式
*/
abstract override setStaticMode(enable: boolean): void;
/**
* 修改背景动画帧率,默认是 30 FPS
*
* 如果设置成 0 则会停止动画
* @param fps 目标帧率,默认 30 FPS
*/
abstract override setFPS(fps: number): void;
/**
* 暂停背景动画,画面即便是更新了图片也不会发生变化
*/
abstract override pause(): void;
/**
* 恢复播放背景动画
*/
abstract override resume(): void;
/**
* 设置背景专辑资源,纹理加载并设置完成后会返回
* @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数
*/
abstract override setAlbum(
albumSource: string | HTMLImageElement | HTMLVideoElement,
isVideo?: boolean,
): Promise<void>;
dispose(): void {
this.observer.disconnect();
this.canvas.remove();
}
override getElement(): HTMLElement {
return this.canvas;
}
}

View File

@@ -0,0 +1,168 @@
export function blurImage(
imageData: ImageData,
radius: number,
quality: number,
): void {
const pixels = imageData.data;
const width = imageData.width;
const height = imageData.height;
let rsum: number;
let gsum: number;
let bsum: number;
let asum: number;
let x: number;
let y: number;
let i: number;
let p: number;
let p1: number;
let p2: number;
let yp: number;
let yi: number;
let yw: number;
const wm = width - 1;
const hm = height - 1;
const rad1x = radius + 1;
const divx = radius + rad1x;
const rad1y = radius + 1;
const divy = radius + rad1y;
const div2 = 1 / (divx * divy);
const r: number[] = [];
const g: number[] = [];
const b: number[] = [];
const a: number[] = [];
const vmin: number[] = [];
const vmax: number[] = [];
while (quality-- > 0) {
yw = yi = 0;
for (y = 0; y < height; y++) {
rsum = pixels[yw] * rad1x;
gsum = pixels[yw + 1] * rad1x;
bsum = pixels[yw + 2] * rad1x;
asum = pixels[yw + 3] * rad1x;
for (i = 1; i <= radius; i++) {
p = yw + ((i > wm ? wm : i) << 2);
rsum += pixels[p++];
gsum += pixels[p++];
bsum += pixels[p++];
asum += pixels[p];
}
for (x = 0; x < width; x++) {
r[yi] = rsum;
g[yi] = gsum;
b[yi] = bsum;
a[yi] = asum;
if (y === 0) {
vmin[x] = Math.min(x + rad1x, wm) << 2;
vmax[x] = Math.max(x - radius, 0) << 2;
}
p1 = yw + vmin[x];
p2 = yw + vmax[x];
rsum += pixels[p1++] - pixels[p2++];
gsum += pixels[p1++] - pixels[p2++];
bsum += pixels[p1++] - pixels[p2++];
asum += pixels[p1] - pixels[p2];
yi++;
}
yw += width << 2;
}
for (x = 0; x < width; x++) {
yp = x;
rsum = r[yp] * rad1y;
gsum = g[yp] * rad1y;
bsum = b[yp] * rad1y;
asum = a[yp] * rad1y;
for (i = 1; i <= radius; i++) {
yp += i > hm ? 0 : width;
rsum += r[yp];
gsum += g[yp];
bsum += b[yp];
asum += a[yp];
}
yi = x << 2;
for (y = 0; y < height; y++) {
pixels[yi] = (rsum * div2 + 0.5) | 0;
pixels[yi + 1] = (gsum * div2 + 0.5) | 0;
pixels[yi + 2] = (bsum * div2 + 0.5) | 0;
pixels[yi + 3] = (asum * div2 + 0.5) | 0;
if (x === 0) {
vmin[y] = Math.min(y + rad1y, hm) * width;
vmax[y] = Math.max(y - radius, 0) * width;
}
p1 = x + vmin[y];
p2 = x + vmax[y];
rsum += r[p1] - r[p2];
gsum += g[p1] - g[p2];
bsum += b[p1] - b[p2];
asum += a[p1] - a[p2];
yi += width << 2;
}
}
}
}
export function saturateImage(imageData: ImageData, saturation: number): void {
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
const gray = r * 0.3 + g * 0.59 + b * 0.11;
pixels[i] = gray * (1 - saturation) + r * saturation;
pixels[i + 1] = gray * (1 - saturation) + g * saturation;
pixels[i + 2] = gray * (1 - saturation) + b * saturation;
pixels[i + 3] = a;
}
}
export function brightnessImage(
imageData: ImageData,
brightness: number,
): void {
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
pixels[i] = r * brightness;
pixels[i + 1] = g * brightness;
pixels[i + 2] = b * brightness;
pixels[i + 3] = a;
}
}
export function contrastImage(imageData: ImageData, contrast: number): void {
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
pixels[i] = (r - 128) * contrast + 128;
pixels[i + 1] = (g - 128) * contrast + 128;
pixels[i + 2] = (b - 128) * contrast + 128;
pixels[i + 3] = a;
}
}

View File

@@ -0,0 +1,71 @@
/**
* @fileoverview
* 一个播放歌词的组件
* @author SteveXMH
*/
export { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
export { MeshGradientRenderer } from "./mesh-renderer/index.ts";
export { PixiRenderer } from "./pixi-renderer.ts";
import type { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
export class BackgroundRender<Renderer extends BaseRenderer>
implements AbstractBaseRenderer
{
private element: HTMLCanvasElement;
private renderer: Renderer;
constructor(renderer: Renderer, canvas: HTMLCanvasElement) {
this.renderer = renderer;
this.element = canvas;
canvas.style.pointerEvents = "none";
canvas.style.zIndex = "-1";
canvas.style.contain = "strict";
}
static new<Renderer extends BaseRenderer>(type: {
new (canvas: HTMLCanvasElement): Renderer;
}): BackgroundRender<Renderer> {
const newCanvas = document.createElement("canvas");
return new BackgroundRender(new type(newCanvas), newCanvas);
}
setRenderScale(scale: number): void {
this.renderer.setRenderScale(scale);
}
setFlowSpeed(speed: number): void {
this.renderer.setFlowSpeed(speed);
}
setStaticMode(enable: boolean): void {
this.renderer.setStaticMode(enable);
}
setFPS(fps: number): void {
this.renderer.setFPS(fps);
}
pause(): void {
this.renderer.pause();
}
resume(): void {
this.renderer.resume();
}
setLowFreqVolume(volume: number): void {
this.renderer.setLowFreqVolume(volume);
}
setHasLyric(hasLyric: boolean): void {
this.renderer.setHasLyric(hasLyric);
}
setAlbum(
albumSource: string | HTMLImageElement | HTMLVideoElement,
isVideo?: boolean,
): Promise<void> {
return this.renderer.setAlbum(albumSource, isVideo);
}
getElement(): HTMLCanvasElement {
return this.element;
}
dispose(): void {
this.renderer.dispose();
this.element.remove();
}
}

View File

@@ -0,0 +1,211 @@
/**
* @fileoverview
* 实验性的随机控制点生成函数算法
* 目的是取代原先大量的预设控制点代码
*/
import { clamp01 } from "#utils/clamp.ts";
import {
type ControlPointConf,
type ControlPointPreset,
p,
preset,
} from "./cp-presets.ts";
const randomRange = (min: number, max: number): number =>
Math.random() * (max - min) + min;
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp01((x - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
function smoothifyControlPoints(
conf: ControlPointConf[],
w: number,
h: number,
iterations = 2,
factor = 0.5,
factorIterationModifier = 0.1,
): void {
let grid: ControlPointConf[][] = [];
let f = factor;
for (let j = 0; j < h; j++) {
grid[j] = [];
for (let i = 0; i < w; i++) {
grid[j][i] = conf[j * w + i];
}
}
const kernel = [
[1, 2, 1],
[2, 4, 2],
[1, 2, 1],
];
const kernelSum = 16;
for (let iter = 0; iter < iterations; iter++) {
const newGrid: ControlPointConf[][] = [];
for (let j = 0; j < h; j++) {
newGrid[j] = [];
for (let i = 0; i < w; i++) {
if (i === 0 || i === w - 1 || j === 0 || j === h - 1) {
newGrid[j][i] = grid[j][i];
continue;
}
let sumX = 0;
let sumY = 0;
let sumUR = 0;
let sumVR = 0;
let sumUP = 0;
let sumVP = 0;
for (let dj = -1; dj <= 1; dj++) {
for (let di = -1; di <= 1; di++) {
const weight = kernel[dj + 1][di + 1];
const nb = grid[j + dj][i + di];
sumX += nb.x * weight;
sumY += nb.y * weight;
sumUR += nb.ur * weight;
sumVR += nb.vr * weight;
sumUP += nb.up * weight;
sumVP += nb.vp * weight;
}
}
const avgX = sumX / kernelSum;
const avgY = sumY / kernelSum;
const avgUR = sumUR / kernelSum;
const avgVR = sumVR / kernelSum;
const avgUP = sumUP / kernelSum;
const avgVP = sumVP / kernelSum;
const cur = grid[j][i];
const newX = cur.x * (1 - f) + avgX * f;
const newY = cur.y * (1 - f) + avgY * f;
const newUR = cur.ur * (1 - f) + avgUR * f;
const newVR = cur.vr * (1 - f) + avgVR * f;
const newUP = cur.up * (1 - f) + avgUP * f;
const newVP = cur.vp * (1 - f) + avgVP * f;
newGrid[j][i] = p(i, j, newX, newY, newUR, newVR, newUP, newVP);
}
}
grid = newGrid;
f = clamp01(f + factorIterationModifier);
}
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
conf[j * w + i] = grid[j][i];
}
}
}
function noise(x: number, y: number): number {
return fract(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453);
}
function fract(x: number): number {
return x - Math.floor(x);
}
function smoothNoise(x: number, y: number): number {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = x0 + 1;
const y1 = y0 + 1;
const xf = x - x0;
const yf = y - y0;
const u = xf * xf * (3 - 2 * xf);
const v = yf * yf * (3 - 2 * yf);
const n00 = noise(x0, y0);
const n10 = noise(x1, y0);
const n01 = noise(x0, y1);
const n11 = noise(x1, y1);
const nx0 = n00 * (1 - u) + n10 * u;
const nx1 = n01 * (1 - u) + n11 * u;
return nx0 * (1 - v) + nx1 * v;
}
function computeNoiseGradient(
perlinFn: (x: number, y: number) => number,
x: number,
y: number,
epsilon = 0.001,
): [number, number] {
const n1 = perlinFn(x + epsilon, y);
const n2 = perlinFn(x - epsilon, y);
const n3 = perlinFn(x, y + epsilon);
const n4 = perlinFn(x, y - epsilon);
const dx = (n1 - n2) / (2 * epsilon);
const dy = (n3 - n4) / (2 * epsilon);
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return [dx / len, dy / len];
}
export function generateControlPoints(
width: number,
height: number,
variationFraction: number = randomRange(0.4, 0.6), // = 0.2,
normalOffset: number = randomRange(0.3, 0.6), // = 0.3,
blendFactor = 0.8,
smoothIters: number = Math.floor(randomRange(3, 5)), // = 3,
smoothFactor: number = randomRange(0.2, 0.3), // = 0.3,
smoothModifier: number = randomRange(-0.1, -0.05), // = -0.05,
): ControlPointPreset {
const w = width ?? Math.floor(randomRange(3, 6));
const h = height ?? Math.floor(randomRange(3, 6));
const conf: ControlPointConf[] = [];
const dx = w === 1 ? 0 : 2 / (w - 1);
const dy = h === 1 ? 0 : 2 / (h - 1);
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const baseX = (w === 1 ? 0 : i / (w - 1)) * 2 - 1;
const baseY = (h === 1 ? 0 : j / (h - 1)) * 2 - 1;
const isBorder = i === 0 || i === w - 1 || j === 0 || j === h - 1;
const pertX = isBorder
? 0
: randomRange(-variationFraction * dx, variationFraction * dx);
const pertY = isBorder
? 0
: randomRange(-variationFraction * dy, variationFraction * dy);
let x = baseX + pertX;
let y = baseY + pertY;
const ur = isBorder ? 0 : randomRange(-60, 60);
const vr = isBorder ? 0 : randomRange(-60, 60);
const up = isBorder ? 1 : randomRange(0.8, 1.2);
const vp = isBorder ? 1 : randomRange(0.8, 1.2);
if (!isBorder) {
const uNorm = (baseX + 1) / 2;
const vNorm = (baseY + 1) / 2;
const [nx, ny] = computeNoiseGradient(smoothNoise, uNorm, vNorm, 0.001);
let offsetX = nx * normalOffset;
let offsetY = ny * normalOffset;
const distToBorder = Math.min(uNorm, 1 - uNorm, vNorm, 1 - vNorm); // in [0,0.5]
const weight = smoothstep(0, 1.0, distToBorder);
offsetX *= weight;
offsetY *= weight;
x = x * (1 - blendFactor) + (x + offsetX) * blendFactor;
y = y * (1 - blendFactor) + (y + offsetY) * blendFactor;
}
conf.push(p(i, j, x, y, ur, vr, up, vp));
}
}
smoothifyControlPoints(conf, w, h, smoothIters, smoothFactor, smoothModifier);
return preset(w, h, conf);
}

View File

@@ -0,0 +1,185 @@
/** @internal */
export interface ControlPointConf {
cx: number;
cy: number;
x: number;
y: number;
ur: number;
vr: number;
up: number;
vp: number;
}
/** @internal */
export interface ControlPointPreset {
width: number;
height: number;
conf: ControlPointConf[];
}
/** @internal */
export const p = (
cx: number,
cy: number,
x: number,
y: number,
ur = 0,
vr = 0,
up = 1,
vp = 1,
) => Object.freeze({ cx, cy, x, y, ur, vr, up, vp }) as ControlPointConf;
/** @internal */
export const preset = (
width: number,
height: number,
conf: ControlPointConf[],
) => Object.freeze({ width, height, conf }) as ControlPointPreset;
export const CONTROL_POINT_PRESETS: ControlPointPreset[] = [
// TODO: 竖屏推荐
preset(5, 5, [
p(0, 0, -1, -1, 0, 0, 1, 1),
p(1, 0, -0.5, -1, 0, 0, 1, 1),
p(2, 0, 0, -1, 0, 0, 1, 1),
p(3, 0, 0.5, -1, 0, 0, 1, 1),
p(4, 0, 1, -1, 0, 0, 1, 1),
p(0, 1, -1, -0.5, 0, 0, 1, 1),
p(1, 1, -0.5, -0.5, 0, 0, 1, 1),
p(2, 1, -0.0052029684413368305, -0.6131420587090777, 0, 0, 1, 1),
p(3, 1, 0.5884227308309977, -0.3990805107556692, 0, 0, 1, 1),
p(4, 1, 1, -0.5, 0, 0, 1, 1),
p(0, 2, -1, 0, 0, 0, 1, 1),
p(1, 2, -0.4210024670505933, -0.11895058380429502, 0, 0, 1, 1),
p(2, 2, -0.1019613423315412, -0.023812118047224606, 0, -47, 0.629, 0.849),
p(3, 2, 0.40275125660925437, -0.06345314544600389, 0, 0, 1, 1),
p(4, 2, 1, 0, 0, 0, 1, 1),
p(0, 3, -1, 0.5, 0, 0, 1, 1),
p(1, 3, 0.06801958477287173, 0.5205913248960121, -31, -45, 1, 1),
p(2, 3, 0.21446469120128908, 0.29331610114301043, 6, -56, 0.566, 1.321),
p(3, 3, 0.5, 0.5, 0, 0, 1, 1),
p(4, 3, 1, 0.5, 0, 0, 1, 1),
p(0, 4, -1, 1, 0, 0, 1, 1),
p(1, 4, -0.31378372841550195, 1, 0, 0, 1, 1),
p(2, 4, 0.26153633255328046, 1, 0, 0, 1, 1),
p(3, 4, 0.5, 1, 0, 0, 1, 1),
p(4, 4, 1, 1, 0, 0, 1, 1),
]),
// TODO: 横屏推荐
preset(4, 4, [
p(0, 0, -1, -1, 0, 0, 1, 1),
p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1),
p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1),
p(3, 0, 1, -1, 0, 0, 1, 1),
p(0, 1, -1, -0.04495399932657351, 0, 0, 1, 1),
p(1, 1, -0.24056117520129328, -0.22465999020104, 0, 0, 1, 1),
p(2, 1, 0.334758885767489, -0.00531297192779423, 0, 0, 1, 1),
p(3, 1, 0.9989920470678106, -0.3382976020775408, 8, 0, 0.566, 1.792),
p(0, 2, -1, 0.33333333333333326, 0, 0, 1, 1),
p(1, 2, -0.3425497314639411, -0.000027501607956947893, 0, 0, 1, 1),
p(2, 2, 0.3321437945812673, 0.1981776353859399, 0, 0, 1, 1),
p(3, 2, 1, 0.0766118180296832, 0, 0, 1, 1),
p(0, 3, -1, 1, 0, 0, 1, 1),
p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1),
p(2, 3, 0.33333333333333326, 1, 0, 0, 1, 1),
p(3, 3, 1, 1, 0, 0, 1, 1),
]),
preset(4, 4, [
p(0, 0, -1, -1, 0, 0, 1, 2.075),
p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1),
p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1),
p(3, 0, 1, -1, 0, 0, 1, 1),
p(0, 1, -1, -0.4545779491139603, 0, 0, 1, 1),
p(1, 1, -0.33333333333333337, -0.33333333333333337, 0, 0, 1, 1),
p(2, 1, 0.0889403142626457, -0.6025711180694033, -32, 45, 1, 1),
p(3, 1, 1, -0.33333333333333337, 0, 0, 1, 1),
p(0, 2, -1, -0.07402408608567845, 1, 0, 1, 0.094),
p(1, 2, -0.2719422694359541, 0.09775369930903222, 25, -18, 1.321, 0),
p(2, 2, 0.19877414408395877, 0.4307383294587789, 48, -40, 0.755, 0.975),
p(3, 2, 1, 0.33333333333333326, -37, 0, 1, 1),
p(0, 3, -1, 1, 0, 0, 1, 1),
p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1),
p(2, 3, 0.5125850864305672, 1, -20, -18, 0, 1.604),
p(3, 3, 1, 1, 0, 0, 1, 1),
]),
preset(5, 5, [
p(0, 0, -1, -1, 0, 0, 1, 1),
p(1, 0, -0.4501953125, -1, 0, 55, 1, 2.075),
p(2, 0, 0.1953125, -1, 0, 0, 1, 1),
p(3, 0, 0.4580078125, -1, 0, -25, 1, 1),
p(4, 0, 1, -1, 0, 0, 1, 1),
p(0, 1, -1, -0.2514475377525607, -16, 0, 2.327, 0.943),
p(1, 1, -0.55859375, -0.6609325945787148, 47, 0, 2.358, 0.377),
p(2, 1, 0.232421875, -0.5244375756366635, -66, -25, 1.855, 1.164),
p(3, 1, 0.685546875, -0.3753706470552125, 0, 0, 1, 1),
p(4, 1, 1, -0.6699125300354287, 0, 0, 1, 1),
p(0, 2, -1, 0.035910396862284255, 0, 0, 1, 1),
p(1, 2, -0.4921875, 0.005378616309457018, 90, 23, 1, 1.981),
p(2, 2, 0.021484375, -0.1365043639066228, 0, 42, 1, 1),
p(3, 2, 0.4765625, 0.05925822904974043, -30, 0, 1.95, 0.44),
p(4, 2, 1, 0.251428847823418, 0, 0, 1, 1),
p(0, 3, -1, 0.6968336464764276, -68, 0, 1, 0.786),
p(1, 3, -0.6904296875, 0.5890744209958608, -68, 0, 1, 1),
p(2, 3, 0.1845703125, 0.3879238667654693, 61, 0, 1, 1),
p(3, 3, 0.60546875, 0.4633553246018661, -47, -59, 0.849, 1.73),
p(4, 3, 1, 0.6214021886400309, -33, 0, 0.377, 1.604),
p(0, 4, -1, 1, 0, 0, 1, 1),
p(1, 4, -0.5, 1, 0, -73, 1, 1),
p(2, 4, -0.3271484375, 1, 0, -24, 0.314, 2.704),
p(3, 4, 0.5, 1, 0, 0, 1, 1),
p(4, 4, 1, 1, 0, 0, 1, 1),
]),
preset(5, 5, [
p(0, 0, -1, -1),
p(1, 0, -0.6393, -1, 0, 0, 1, 2.3884),
p(2, 0, 0, -1),
p(3, 0, 0.5, -1),
p(4, 0, 1, -1),
p(0, 1, -1, -0.2301),
p(1, 1, -0.6934, -0.331, 0, -0.7188, 1, 1.063),
p(2, 1, -0.0082, -0.6814, -0.2583, 0, 1.0964, 1),
p(3, 1, 0.5836, -0.531, 0.7029, 0, 1.5466, 1),
p(4, 1, 1, -0.6407),
p(0, 2, -1, 0.2973, 0, 0, 1.8352, 1),
p(1, 2, -0.4082, 0.0602),
p(2, 2, -0.1803, -0.3646, -0.2998, 0, 1.1513, 1),
p(3, 2, 0.477, -0.1027, 0.8903, -0.1882, 1.0807, 0.8551),
p(4, 2, 1, -0.2973),
p(0, 3, -1, 0.7628, 0, 0, 2.3868, 1),
p(1, 3, -0.2525, 0.4814, -0.8406, -1.6199, 1.4093, 1.2215),
p(2, 3, 0.3607, 0.2814, -1.0713, -0.0529, 1.0025, 0.7611),
p(3, 3, 0.4885, 0.623, 0, 0.8184, 1, 1.2876),
p(4, 3, 1, 0.5),
p(0, 4, -1, 1),
p(1, 4, -0.4033, 1),
p(2, 4, 0.2672, 1),
p(3, 4, 0.5967, 1),
p(4, 4, 1, 1),
]),
preset(5, 5, [
p(0, 0, -1, -1),
p(1, 0, -0.2197, -1),
p(2, 0, 0.0197, -1),
p(3, 0, 0.8033, -1),
p(4, 0, 1, -1),
p(0, 1, -1, -0.5451),
p(1, 1, -0.4885, -0.4035, -1.0246, -0.2268, 1.1936, 0.8005),
p(2, 1, -0.1213, -0.2867, 0, -0.6981, 1, 0.809),
p(3, 1, 0.3246, -0.5628, 0, -1.2188, 1, 1.044),
p(4, 1, 1, -0.3292),
p(0, 2, -1, 0.1416),
p(1, 2, -0.341, -0.0142, 0, -0.4004, 1, 1.1293),
p(2, 2, -0.0393, -0.023, 0.2915, -0.373, 1.044, 0.9879),
p(3, 2, 0.3148, -0.0673, -0.7853, -0.8962, 1.4709, 1.0247),
p(4, 2, 1, 0.1912),
p(0, 3, -1, 0.5),
p(1, 3, -0.2689, 0.2743, 0.3404, -0.5248, 1.0184, 0.4391),
p(2, 3, 0.0721, 0.269, 0.5302, 0.1244, 0.6723, 0.3225),
p(3, 3, 0.4148, 0.3894, -0.6977, -0.6783, 0.8094, 0.9247),
p(4, 3, 1, 0.446),
p(0, 4, -1, 1),
p(1, 4, -0.7311, 1),
p(2, 4, 0.323, 1),
p(3, 4, 0.6393, 1),
p(4, 4, 1, 1),
]),
] as const;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
precision highp float;
varying vec3 v_color;
varying vec2 v_uv;
uniform sampler2D u_texture;
uniform float u_time;
uniform float u_volume;
uniform float u_alpha;
// 预计算常量
const float INV_255 = 1.0 / 255.0;
const float HALF_INV_255 = 0.5 / 255.0;
const float GRADIENT_NOISE_A = 52.9829189;
const vec2 GRADIENT_NOISE_B = vec2(0.06711056, 0.00583715);
/* Gradient noise from Jorge Jimenez's presentation: */
/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */
float gradientNoise(in vec2 uv) {
return fract(GRADIENT_NOISE_A * fract(dot(uv, GRADIENT_NOISE_B)));
}
// 优化的旋转函数避免重复计算sin/cos
vec2 rot(vec2 v, float angle) {
float s = sin(angle);
float c = cos(angle);
return vec2(c * v.x - s * v.y, s * v.x + c * v.y);
}
void main() {
// 合并计算以减少指令数
float volumeEffect = u_volume * 2.0;
float timeVolume = u_time + u_volume;
float dither = INV_255 * gradientNoise(gl_FragCoord.xy) - HALF_INV_255;
vec2 centeredUV = v_uv - vec2(0.2);
vec2 rotatedUV = rot(centeredUV, timeVolume * 2.0);
vec2 finalUV = rotatedUV * max(0.001, 1.0 - volumeEffect) + vec2(0.5);
vec4 result = texture2D(u_texture, finalUV);
float alphaVolumeFactor = u_alpha * max(0.5, 1.0 - u_volume * 0.5);
result.rgb *= v_color * alphaVolumeFactor;
result.a *= alphaVolumeFactor;
result.rgb += vec3(dither);
float dist = distance(v_uv, vec2(0.5));
float vignette = smoothstep(0.8, 0.3, dist);
float mask = 0.6 + vignette * 0.4;
result.rgb *= mask;
gl_FragColor = result;
}

View File

@@ -0,0 +1,21 @@
precision highp float;
attribute vec2 a_pos;
attribute vec3 a_color;
attribute vec2 a_uv;
varying vec3 v_color;
varying vec2 v_uv;
uniform float u_aspect;
void main() {
v_color = a_color;
v_uv = a_uv;
vec2 pos = a_pos;
if (u_aspect > 1.0) {
pos.y *= u_aspect;
} else {
pos.x /= u_aspect;
}
gl_Position = vec4(pos, 0.0, 1.0);
}

View File

@@ -0,0 +1,264 @@
import { Application } from "@pixi/app";
import { Texture } from "@pixi/core";
import { Container } from "@pixi/display";
import { BlurFilter } from "@pixi/filter-blur";
import { BulgePinchFilter } from "@pixi/filter-bulge-pinch";
import { ColorMatrixFilter } from "@pixi/filter-color-matrix";
import { Sprite } from "@pixi/sprite";
import {
loadResourceFromElement,
loadResourceFromUrl,
} from "../utils/resource";
import { BaseRenderer } from "./base";
import { clampPositive } from "#utils/clamp.ts";
class TimedContainer extends Container {
public time = 0;
}
export class PixiRenderer extends BaseRenderer {
private app: Application;
private curContainer?: TimedContainer;
private staticMode = false;
private lastContainer: Set<TimedContainer> = new Set();
private onTick = (delta: number): void => {
for (const lastContainer of this.lastContainer) {
lastContainer.alpha = clampPositive(lastContainer.alpha - delta / 60);
if (lastContainer.alpha <= 0) {
this.app.stage.removeChild(lastContainer);
this.lastContainer.delete(lastContainer);
lastContainer.destroy(true);
}
}
if (this.curContainer) {
this.curContainer.alpha = Math.min(
1,
this.curContainer.alpha + delta / 60,
);
const [s1, s2, s3, s4] = this.curContainer.children as Sprite[];
const maxSize = Math.max(this.app.screen.width, this.app.screen.height);
s1.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
s2.position.set(
this.app.screen.width / 2.5,
this.app.screen.height / 2.5,
);
s3.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
s4.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
s1.width = maxSize * Math.sqrt(2);
s1.height = s1.width;
s2.width = maxSize * 0.8;
s2.height = s2.width;
s3.width = maxSize * 0.5;
s3.height = s3.width;
s4.width = maxSize * 0.25;
s4.height = s4.width;
this.curContainer.time += delta * this.flowSpeed;
s1.rotation += (delta / 1000) * this.flowSpeed;
s2.rotation -= (delta / 500) * this.flowSpeed;
s3.rotation += (delta / 1000) * this.flowSpeed;
s4.rotation -= (delta / 750) * this.flowSpeed;
s3.x =
this.app.screen.width / 2 +
(this.app.screen.width / 4) *
Math.cos((this.curContainer.time / 1000) * 0.75);
s3.y =
this.app.screen.height / 2 +
(this.app.screen.width / 4) *
Math.cos((this.curContainer.time / 1000) * 0.75);
s4.x =
this.app.screen.width / 2 +
(this.app.screen.width / 4) * 0.1 +
Math.cos(this.curContainer.time * 0.006 * 0.75);
s4.y =
this.app.screen.height / 2 +
(this.app.screen.width / 4) * 0.1 +
Math.cos(this.curContainer.time * 0.006 * 0.75);
if (
this.curContainer.alpha >= 1 &&
this.lastContainer.size === 0 &&
this.staticMode
) {
this.app.ticker.stop();
}
}
};
constructor(protected override canvas: HTMLCanvasElement) {
super(canvas);
this.app = new Application({
view: canvas,
resizeTo: this.canvas,
powerPreference: "low-power",
backgroundAlpha: 1,
});
this.rebuildFilters();
this.app.ticker.maxFPS = 30;
this.app.ticker.add(this.onTick);
this.app.ticker.start();
}
protected override onResize(width: number, height: number): void {
super.onResize(width, height);
this.app.resize();
this.rebuildFilters();
}
override setRenderScale(scale: number): void {
super.setRenderScale(scale);
this.rebuildFilters();
}
private rebuildFilters() {
const minBorder = Math.min(this.canvas.width, this.canvas.height);
const maxBorder = Math.max(this.canvas.width, this.canvas.height);
const c0 = new ColorMatrixFilter();
c0.saturate(1.2, false);
const c1 = new ColorMatrixFilter();
c1.brightness(0.6, false);
const c2 = new ColorMatrixFilter();
c2.contrast(0.3, true);
for (const filter of this.app.stage.filters ?? []) {
filter.destroy();
}
this.app.stage.filters = [];
this.app.stage.filters.push(new BlurFilter(5, 1));
this.app.stage.filters.push(new BlurFilter(10, 1));
this.app.stage.filters.push(new BlurFilter(20, 2));
this.app.stage.filters.push(new BlurFilter(40, 2));
this.app.stage.filters.push(new BlurFilter(80, 2));
if (minBorder > 768) this.app.stage.filters.push(new BlurFilter(160, 4));
if (minBorder > 768 * 2)
this.app.stage.filters.push(new BlurFilter(320, 4));
this.app.stage.filters.push(c0, c1, c2);
this.app.stage.filters.push(new BlurFilter(5, 1));
if (Math.random() > 0.5) {
this.app.stage.filters.push(
new BulgePinchFilter({
radius: (maxBorder + minBorder) / 2,
strength: 1,
center: [0.25, 1],
}),
);
this.app.stage.filters.push(
new BulgePinchFilter({
radius: (maxBorder + minBorder) / 2,
strength: 1,
center: [0.75, 0],
}),
);
} else {
this.app.stage.filters.push(
new BulgePinchFilter({
radius: (maxBorder + minBorder) / 2,
strength: 1,
center: [0.75, 1],
}),
);
this.app.stage.filters.push(
new BulgePinchFilter({
radius: (maxBorder + minBorder) / 2,
strength: 1,
center: [0.25, 0],
}),
);
}
}
override setStaticMode(enable = false): void {
this.staticMode = enable;
this.app.ticker.start();
}
override setFPS(fps: number): void {
this.app.ticker.maxFPS = fps;
}
override pause(): void {
this.app.ticker.stop();
this.app.render();
}
override resume(): void {
this.app.ticker.start();
}
override setLowFreqVolume(_volume: number): void {
// NOOP
}
override setHasLyric(_hasLyric: boolean): void {
// NOOP
}
override async setAlbum(
albumSource?: string | HTMLImageElement | HTMLVideoElement,
isVideo?: boolean,
): Promise<void> {
if (
!albumSource ||
(typeof albumSource === "string" && albumSource.trim().length === 0)
)
return;
let res: HTMLImageElement | HTMLVideoElement | null = null;
let remainRetryTimes = 5;
let tex: Texture | null = null;
while (!tex?.baseTexture?.resource?.valid && remainRetryTimes > 0) {
try {
if (typeof albumSource === "string") {
res = await loadResourceFromUrl(albumSource, isVideo);
} else {
res = await loadResourceFromElement(albumSource);
}
tex = Texture.from(res, {
resourceOptions: {
autoLoad: false,
},
});
await tex.baseTexture.resource.load();
} catch (error) {
console.warn(
`failed on loading album image, retrying (${remainRetryTimes})`,
albumSource,
error,
);
tex = null;
remainRetryTimes--;
}
}
if (!tex) return;
const container = new TimedContainer();
const s1 = new Sprite(tex);
const s2 = new Sprite(tex);
const s3 = new Sprite(tex);
const s4 = new Sprite(tex);
s1.anchor.set(0.5, 0.5);
s2.anchor.set(0.5, 0.5);
s3.anchor.set(0.5, 0.5);
s4.anchor.set(0.5, 0.5);
s1.rotation = Math.random() * Math.PI * 2;
s2.rotation = Math.random() * Math.PI * 2;
s3.rotation = Math.random() * Math.PI * 2;
s4.rotation = Math.random() * Math.PI * 2;
container.addChild(s1, s2, s3, s4);
if (this.curContainer) this.lastContainer.add(this.curContainer);
this.curContainer = container;
this.app.stage.addChild(container);
this.curContainer.alpha = 0;
this.app.ticker.start();
}
override dispose(): void {
super.dispose();
this.app.ticker.remove(this.onTick);
this.app.destroy(true);
}
override getElement(): HTMLElement {
return this.canvas;
}
}

View File

@@ -0,0 +1,11 @@
#version 300 es
precision highp float;
uniform sampler2D src;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
vec2 coord = f_v_coord;
fragColor = texture(src, coord);
}

View File

@@ -0,0 +1,10 @@
#version 300 es
precision highp float;
in vec2 v_coord;
out vec2 f_v_coord;
void main() {
gl_Position = vec4(v_coord, 0.0f, 1.0f);
f_v_coord = (vec2(v_coord.x, v_coord.y) + vec2(1.0f, 1.0f)) / 2.0f;
}

View File

@@ -0,0 +1,17 @@
#version 300 es
precision highp float;
uniform sampler2D src;
uniform float lerp;
uniform float scale;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
vec2 tex_coord = f_v_coord;
if(scale < 1.0f) {
tex_coord /= scale;
}
vec4 srcColor = texture(src, tex_coord);
fragColor = mix(vec4(srcColor.xyz, 0.0f), srcColor, lerp);
}

View File

@@ -0,0 +1,20 @@
#version 300 es
precision highp float;
uniform sampler2D src;
in vec2 f_v_coord;
out vec4 fragColor;
/* Gradient noise from Jorge Jimenez's presentation: */
/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */
float gradientNoise(in vec2 uv) {
return fract(52.9829189f * fract(dot(uv, vec2(0.06711056f, 0.00583715f))));
}
void main() {
vec2 tex_coord = f_v_coord;
float dither = (1.0f / 255.0f) * gradientNoise(gl_FragCoord.xy) - (0.5f / 255.0f);
vec4 color = texture(src, tex_coord);
color += dither;
fragColor = color;
}

View File

@@ -0,0 +1,42 @@
#version 300 es
precision highp float;
uniform sampler2D src;
uniform sampler2D historyFrame0;
uniform vec2 texSize;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
fragColor = texture(src, f_v_coord);
return;
// get the neighborhood min / max from this frame's render
vec3 center = texture(src, f_v_coord).rgb;
vec3 minColor = center;
vec3 maxColor = center;
for (int iy = -1; iy <= 1; ++iy)
{
for (int ix = -1; ix <= 1; ++ix)
{
if (ix == 0 && iy == 0)
continue;
vec2 offsetUV = ((f_v_coord * texSize + vec2(ix, iy))) / texSize;
vec3 color = texture(src, offsetUV).rgb;
minColor = min(minColor, color);
maxColor = max(maxColor, color);
}
}
// get last frame's pixel and clamp it to the neighborhood of this frame
vec3 old = texture(historyFrame0, f_v_coord).rgb;
old = max(minColor, old);
old = min(maxColor, old);
// interpolate from the clamped old color to the new color.
// Reject all history when the mouse moves.
float lerpAmount = 0.1f;
vec3 pixelColor = mix(old, center, lerpAmount);
fragColor = vec4(pixelColor, 1.0f);
}

View File

@@ -0,0 +1,5 @@
/// <reference path="./types.d.ts" />
export * from "./bg-render/index.ts";
export type * from "./interfaces.ts";
export * from "./lyric-player/index.ts";
export type * as spring from "./utils/spring.ts";

View File

@@ -0,0 +1,111 @@
/**
* 拥有一个 HTML 元素的接口
*
* 可以通过 `getElement` 获取这个类所对应的 HTML 元素实例
*/
export interface HasElement {
/** 获取这个类所对应的 HTML 元素实例 */
getElement(): HTMLElement;
}
/**
* 实现了这个接口的东西需要在使用完毕后
*
* 手动调用 `dispose` 函数来销毁清除占用资源
*
* 以免产生泄露
*/
export interface Disposable {
/**
* 销毁实现了该接口的对象实例,释放占用的资源
*
* 一般情况下,调用本函数后就不可以再调用对象的任何函数了
*/
dispose(): void;
}
/** 一个歌词单词 */
export interface LyricWordBase {
/** 单词的起始时间,单位为毫秒 */
startTime: number;
/** 单词的结束时间,单位为毫秒 */
endTime: number;
/** 单词内容 */
word: string;
}
export interface LyricWord extends LyricWordBase {
/** 单词的音译内容 */
romanWord?: string;
/** 单词内容是否包含冒犯性的不雅用语 */
obscene?: boolean;
/** 单词的注音内容 */
ruby?: LyricWordBase[];
}
/** 一行歌词,存储多个单词 */
export interface LyricLine {
/**
* 该行的所有单词
* 如果是 LyRiC 等只能表达一行歌词的格式,这里就只会有一个单词且通常其始末时间和本结构的 `startTime` 和 `endTime` 相同
*/
words: LyricWord[];
/** 该行的翻译歌词,将会显示在主歌词行的下方 */
translatedLyric: string;
/** 该行的音译歌词,将会显示在翻译歌词行的下方 */
romanLyric: string;
/** 句子的起始时间,单位为毫秒 */
startTime: number;
/** 句子的结束时间,单位为毫秒 */
endTime: number;
/** 该行是否为背景歌词行,当该行歌词的上一句非背景歌词被激活时,这行歌词将会显示出来,注意每个非背景歌词下方只能拥有一个背景歌词 */
isBG: boolean;
/** 该行是否为对唱歌词行(即歌词行靠右对齐) */
isDuet: boolean;
}
/**
* 优化歌词行的配置选项
*/
export interface OptimizeLyricOptions {
/**
* 规范化歌词中的空格
*
* 将多个连续空格替换为一个空格
* @default true
*/
normalizeSpaces?: boolean;
/**
* 是否将行级时间戳强行设为字级时间戳
* @default true
*/
resetLineTimestamps?: boolean;
/**
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
* @default true
*/
convertExcessiveBackgroundLines?: boolean;
/**
* 是否同步主歌词与背景人声的时间
* @default true
*/
syncMainAndBackgroundLines?: boolean;
/**
* 清洗非刻意的重叠,以免不必要的多行高亮效果
*
* 如果两行时间轴有重叠的歌词满足下列条件之一:
* * 重叠小于 100ms
* * 重叠时长不足下一行时长的 10%
*
* 则截断上一行歌词的结束时间为下一行歌词的开始时间
* @default true
*/
cleanUnintentionalOverlaps?: boolean;
/**
* 尝试让歌词提前最多 1 秒开始
*
* 有重叠则尝试最多提前 400ms 或上一行时长的 30%
* @default true
*/
tryAdvanceStartTime?: boolean;
}

View File

@@ -0,0 +1,128 @@
import type { Disposable, HasElement } from "#interfaces";
import styles from "#styles/lyric-player.module.css";
import { measure } from "#utils/schedule.ts";
import { Spring } from "#utils/spring.ts";
import type { LyricPlayerBase } from ".";
interface LineTransforms {
posX: Spring;
posY: Spring;
}
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: LineTransforms = {
posX: new Spring(0),
posY: new Spring(0),
};
private isFocused = false;
private blur = 0;
constructor(private lyricPlayer: LyricPlayerBase) {
this.element.setAttribute(
"class",
`${styles.lyricLine} ${styles.bottomLine}`,
);
this.element.dataset.bottomLine = "true";
this.rebuildStyle();
}
async measureSize(): Promise<[number, number]> {
const size: [number, number] = await measure(() => [
this.element.clientWidth,
this.element.clientHeight,
]);
return size;
}
private lastStyle = "";
show(): void {
this.rebuildStyle();
}
hide(): void {
this.rebuildStyle();
}
setFocused(focused: boolean): void {
if (this.isFocused !== focused) {
this.isFocused = focused;
if (focused) {
this.element.dataset.focused = "true";
} else {
delete this.element.dataset.focused;
}
}
}
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;`;
}
style += `filter:blur(${Math.min(5, this.blur)}px);`;
if (style !== this.lastStyle) {
this.lastStyle = style;
this.element.setAttribute("style", style);
}
}
getElement(): HTMLElement {
return this.element;
}
setTransform(
left: number = this.left,
top: number = this.top,
blur = 0,
force = false,
delay = 0,
): void {
this.left = left;
this.top = top;
this.delay = (delay * 1000) | 0;
if (force || !this.lyricPlayer.getEnableSpring()) {
this.blur = Math.min(32, blur);
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.blur = Math.min(5, blur);
this.lineTransforms.posX.setTargetPosition(left, delay);
this.lineTransforms.posY.setTargetPosition(top, delay);
}
}
update(delta = 0): void {
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(): boolean {
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();
}
}

View File

@@ -0,0 +1,39 @@
type ValueOf<T extends Record<PropertyKey, unknown>> = T[keyof T];
/** 歌词中不雅用语的掩码模式 */
export const MaskObsceneWordsMode = {
/** 禁用任何不雅用语掩码 */
Disabled: "",
/** 完全掩码所有不雅用语 */
FullMask: "full-mask",
/** 保留首尾字符,屏蔽中间字符 */
PartialMask: "partial-mask",
} as const;
/** 歌词中不雅用语的掩码模式枚举类型,见 {@link MaskObsceneWordsMode} */
export type MaskObsceneWordsMode = ValueOf<typeof MaskObsceneWordsMode>;
/**
* 歌词行的渲染模式
* @internal
*/
export const LyricLineRenderMode = {
SOLID: 0,
GRADIENT: 1,
} as const;
/**
* 歌词行的渲染模式枚举类型,见 {@link LyricLineRenderMode}
* @internal
*/
export type LyricLineRenderMode = ValueOf<typeof LyricLineRenderMode>;
/** 布局对齐锚点 */
export const LayoutAlignAnchor = {
Top: "top",
Center: "center",
Bottom: "bottom",
} as const;
/** 布局对齐锚点枚举类型,见 {@link LayoutAlignAnchor} */
export type LayoutAlignAnchor = ValueOf<typeof LayoutAlignAnchor>;

View File

@@ -0,0 +1,142 @@
import type { Disposable } from "#interfaces";
import { Spring } from "#utils/spring.ts";
import { LyricLineRenderMode } from "./consts.ts";
import type { LyricLineBase } from "./line.ts";
export interface LyricPlayerFlags {
getEnableSpring(): boolean;
getEnableScale(): boolean;
getIsPlaying(): boolean;
getAlwaysPostpositionBackground(): boolean;
}
export abstract class LyricLineGroupBase<
T extends LyricLineBase = LyricLineBase,
> implements Disposable
{
protected abstract readonly lyricPlayer: LyricPlayerFlags;
public posY: Spring = new Spring(0);
public bgSlideY: Spring = new Spring(-80);
public top = 0;
public delay = 0;
public isActive = false;
public opacity = 1;
public blur = 0;
public isBgFirst = false;
constructor(
public mainLine: T,
public bgLine?: T | undefined,
) {}
get startTime(): number {
// 优化歌词时 `syncMainAndBackgroundLines` 已经把时间同步好了,直接读取主歌词的即可
// 要是用户关掉了这个优化,我们认为在这种情况下主歌词和背景人声显示不同步是符合用户预期的
return this.mainLine.getLine().startTime;
}
get endTime(): number {
return this.mainLine.getLine().endTime;
}
onLineSizeChange(size: [number, number]): void {
this.mainLine.onLineSizeChange(size);
this.bgLine?.onLineSizeChange(size);
}
setTransform(
top: number,
force: boolean,
delay: number,
isActive: boolean,
opacity: number,
blur: number,
): void {
this.top = top;
this.delay = delay;
this.isActive = isActive;
this.opacity = opacity;
this.blur = blur;
this.setLineTransformations(force, delay);
const enableSpring = this.lyricPlayer.getEnableSpring();
const alwaysPostposition =
this.lyricPlayer.getAlwaysPostpositionBackground();
const shouldBgFirst = alwaysPostposition ? false : this.isBgFirst;
const hiddenSlideY = shouldBgFirst ? 80 : -80;
const isPlaying = this.lyricPlayer.getIsPlaying();
const targetBgSlideY = isActive || !isPlaying ? 0 : hiddenSlideY;
if (force || !enableSpring) {
this.posY.setPosition(top);
this.bgSlideY.setPosition(targetBgSlideY);
this.renderStyles();
} else {
this.posY.setTargetPosition(top, delay);
this.bgSlideY.setTargetPosition(targetBgSlideY, delay);
}
}
private setLineTransformations(force: boolean, delay: number) {
const enableScale = this.lyricPlayer.getEnableScale();
const isPlaying = this.lyricPlayer.getIsPlaying();
const renderMode = this.isActive
? LyricLineRenderMode.GRADIENT
: LyricLineRenderMode.SOLID;
const SCALE_ASPECT = enableScale ? 97 : 100;
let mainScale = 100;
if (!this.isActive && isPlaying) {
mainScale = SCALE_ASPECT;
}
this.mainLine.setTransform(mainScale, 1, 0, force, delay, renderMode);
let bgScale = 100;
if (!this.isActive && isPlaying) {
bgScale = 75;
}
this.bgLine?.setTransform(bgScale, 1, 0, force, delay, renderMode);
}
protected abstract renderStyles(): void;
abstract get isInSight(): boolean;
update(delta: number): void {
if (this.lyricPlayer.getEnableSpring()) {
this.posY.update(delta);
this.bgSlideY.update(delta);
this.renderStyles();
}
this.mainLine.update(delta);
this.bgLine?.update(delta);
}
rebuildAllLines(): void {
this.mainLine.rebuildElement();
this.bgLine?.rebuildElement();
}
enable(time?: number, shouldPlay?: boolean): void {
this.mainLine.enable(time, shouldPlay);
this.bgLine?.enable(time, shouldPlay);
}
disable(): void {
this.mainLine.disable();
this.bgLine?.disable();
}
dispose(): void {
this.mainLine.dispose();
this.bgLine?.dispose();
}
}

View File

@@ -0,0 +1,854 @@
import structuredClone from "@ungap/structured-clone";
import type {
Disposable,
HasElement,
LyricLine,
LyricWord,
OptimizeLyricOptions,
} from "#interfaces";
import styles from "#styles/lyric-player.module.css";
import { clampPositive } from "#utils/clamp.ts";
import { optimizeLyricLines } from "#utils/optimize-lyric.ts";
import type { SpringParams } from "#utils/spring.ts";
import { InterludeDots } from "../dom/interlude-dots.ts";
import { BottomLineEl } from "./bottom-line.ts";
import { LayoutAlignAnchor, MaskObsceneWordsMode } from "./consts.ts";
import type { LyricLineGroupBase } from "./group.ts";
import {
computeCurrentInterlude,
computeGroupPresentation,
computeLineBlur,
computeLinePosYSpringParams,
type PlayerLayoutState,
} from "./layout.ts";
import type { LyricLineBase } from "./line.ts";
import {
attachPlayerScrollHandlers,
type PlayerScrollState,
resetPlayerScrollState,
} from "./scroll.ts";
import {
commitPlayerTimeState,
computePlayerTimeState,
type PlayerTimelineState,
} from "./timeline.ts";
export type { PlayerLayoutState } from "./layout.ts";
export type { LyricLineBase } from "./line.ts";
export type { PlayerScrollState } from "./scroll.ts";
export type { PlayerTimelineState } from "./timeline.ts";
/**
* 歌词播放器的基类,已经包含了有关歌词操作和排版的功能,
* 子类需要为其实现对应的显示展示操作
*/
export abstract class LyricPlayerBase
extends EventTarget
implements HasElement, Disposable
{
protected element: HTMLElement = document.createElement("div");
abstract get baseFontSize(): number;
/** 播放时间线状态 */
protected timelineState: PlayerTimelineState = {
currentTime: 0,
lastCurrentTime: 0,
hotGroups: new Set(),
bufferedGroups: new Set(),
scrollToIndex: 0,
isSeeking: false,
isPlaying: true,
initialLayoutFinished: false,
};
/** @internal */
lyricGroupElementMap: WeakMap<Element, LyricLineGroupBase> = new WeakMap();
protected currentLyricLines: LyricLine[] = [];
protected processedLines: LyricLine[] = [];
protected lyricLinesIndexes: WeakMap<LyricLineBase, number> = new WeakMap();
protected isNonDynamic = false;
protected hasDuetLine = false;
protected disableSpring = false;
protected layoutState: PlayerLayoutState = {
interludeDotsSize: [0, 0],
targetAlignIndex: 0,
lastInterludeState: false,
alignAnchor: LayoutAlignAnchor.Center,
alignPosition: 0.35,
overscanPx: 300,
};
protected interludeDots: InterludeDots = new InterludeDots();
protected bottomLine: BottomLineEl = new BottomLineEl(this);
protected enableBlur = true;
protected enableScale = true;
protected maskObsceneWords: MaskObsceneWordsMode =
MaskObsceneWordsMode.Disabled;
protected maskObsceneWordChar = "*";
protected hidePassedLines = false;
protected scrollState: PlayerScrollState = {
scrollBoundary: { minOffset: 0, maxOffset: 0 },
scrollOffset: 0,
allowScroll: true,
isScrolled: false,
isUserScrolling: false,
resetToken: 0,
};
public currentLyricGroups: LyricLineGroupBase[] = [];
lyricGroupSize: WeakMap<LyricLineGroupBase, [number, number]> = new WeakMap();
readonly size: [number, number] = [0, 0];
protected isPageVisible = true;
protected optimizeOptions: OptimizeLyricOptions = {};
/** 是否强制让背景人声行始终后置(即始终在主歌词下方显示,不前置背景人声) */
protected alwaysPostpositionBackground = false;
protected posXSpringParams: Partial<SpringParams> = {
mass: 1,
damping: 10,
stiffness: 100,
};
protected posYSpringParams: Partial<SpringParams> = {
mass: 0.9,
damping: 15,
stiffness: 90,
};
protected scaleSpringParams: Partial<SpringParams> = {
mass: 2,
damping: 25,
stiffness: 100,
};
protected scaleForBGSpringParams: Partial<SpringParams> = {
mass: 1,
damping: 20,
stiffness: 50,
};
private onPageShow = () => {
this.isPageVisible = true;
this.setCurrentTime(this.timelineState.currentTime, true);
};
private onPageHide = () => {
this.isPageVisible = false;
};
private scrolledHandler: ReturnType<typeof setTimeout> | undefined;
/** @internal */
resizeObserver: ResizeObserver = new ResizeObserver(((entries) => {
let shouldRelayout = false;
let shouldRebuildPlayerStyle = false;
for (const entry of entries) {
if (entry.target === this.element) {
const rect = entry.contentRect;
this.size[0] = rect.width;
this.size[1] = rect.height;
shouldRebuildPlayerStyle = true;
} else if (entry.target === this.interludeDots.getElement()) {
this.layoutState.interludeDotsSize[0] = entry.target.clientWidth;
this.layoutState.interludeDotsSize[1] = entry.target.clientHeight;
shouldRelayout = true;
} else if (entry.target === this.bottomLine.getElement()) {
const newSize: [number, number] = [
entry.target.clientWidth,
entry.target.clientHeight,
];
const oldSize: [number, number] = this.bottomLine.lineSize;
if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) {
this.bottomLine.lineSize = newSize;
shouldRelayout = true;
}
} else {
const groupObj = this.lyricGroupElementMap.get(entry.target);
if (groupObj) {
const newSize: [number, number] = [
entry.target.clientWidth,
entry.target.clientHeight,
];
const oldSize: [number, number] = this.lyricGroupSize.get(
groupObj,
) ?? [0, 0];
if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) {
this.lyricGroupSize.set(groupObj, newSize);
groupObj.onLineSizeChange(newSize);
shouldRelayout = true;
}
}
}
}
if (shouldRelayout) {
this.calcLayout(true);
}
if (shouldRebuildPlayerStyle) {
this.onResize();
}
}) as ResizeObserverCallback);
protected wordFadeWidth = 0.5;
constructor(element?: HTMLElement) {
super();
if (element) this.element = element;
this.element.classList.add("amll-lyric-player");
this.resizeObserver.observe(this.element);
this.resizeObserver.observe(this.interludeDots.getElement());
this.element.appendChild(this.interludeDots.getElement());
this.element.appendChild(this.bottomLine.getElement());
this.interludeDots.setTransform(0, 200);
window.addEventListener("pageshow", this.onPageShow);
window.addEventListener("pagehide", this.onPageHide);
attachPlayerScrollHandlers(this.element, this.scrollState, {
onBeginScroll: () => this.beginScrollHandler(),
onEndScroll: () => this.endScrollHandler(),
onLayout: (sync, force) => this.calcLayout(sync, force),
containsTarget: (target) => this.element.contains(target),
clickTarget: (target) => target.click(),
});
}
private beginScrollHandler() {
const allowed = this.scrollState.allowScroll;
if (allowed) {
this.scrollState.isScrolled = true;
clearTimeout(this.scrolledHandler);
this.scrolledHandler = setTimeout(() => {
resetPlayerScrollState(this.scrollState);
}, 5000);
}
return allowed;
}
private endScrollHandler() {}
/**
* 设置文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5,即一个全角字符的一半宽度
*
* 如果要模拟 Apple Music for Android 的效果,可以设置为 1
*
* 如果要模拟 Apple Music for iPad 的效果,可以设置为 0.5
*
* 如果想要近乎禁用渐变效果,可以设置成非常接近 0 的小数(例如 `0.0001` ),但是**不可以为 0**
*
* @param value 需要设置的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5
*/
setWordFadeWidth(value = 0.5): void {
this.wordFadeWidth = Math.max(0.0001, value);
}
/**
* 是否启用歌词行缩放效果,默认启用
*
* 如果启用,非选中的歌词行会轻微缩小以凸显当前播放歌词行效果
*
* 此效果对性能影响微乎其微,推荐启用
* @param enable 是否启用歌词行缩放效果
*/
setEnableScale(enable = true): void {
this.enableScale = enable;
this.calcLayout();
}
/**
* 获取当前是否启用了歌词行缩放效果
* @returns 是否启用歌词行缩放效果
*/
getEnableScale(): boolean {
return this.enableScale;
}
/**
* 获取当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位
* @returns 当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位
*/
getWordFadeWidth(): number {
return this.wordFadeWidth;
}
setIsSeeking(isSeeking: boolean): void {
this.timelineState.isSeeking = isSeeking;
}
/**
* 设置是否隐藏已经播放过的歌词行,默认不隐藏
* @param hide 是否隐藏已经播放过的歌词行,默认不隐藏
*/
setHidePassedLines(hide: boolean): void {
this.hidePassedLines = hide;
this.calcLayout();
}
/**
* 设置是否启用歌词行的模糊效果
* @param enable 是否启用
*/
setEnableBlur(enable: boolean): void {
if (this.enableBlur === enable) return;
this.enableBlur = enable;
this.calcLayout();
}
/**
* 设置歌词中不雅用语的掩码模式
* @param mode 掩码模式
* @see {@link MaskObsceneWordsMode}
*/
setMaskObsceneWords(mode: MaskObsceneWordsMode): void {
if (this.maskObsceneWords === mode) return;
this.maskObsceneWords = mode;
this.rebuildLyricLines();
this.calcLayout();
}
/**
* 设置不雅用语掩码使用的字符,默认为 `*`
* @param char 单个字符,用于替换不雅用语中的字符
*/
setMaskObsceneWordChar(char: string): void {
const c = char.charAt(0) || "*";
if (this.maskObsceneWordChar === c) return;
this.maskObsceneWordChar = c;
if (this.maskObsceneWords !== MaskObsceneWordsMode.Disabled) {
this.rebuildLyricLines();
this.calcLayout();
}
}
rebuildLyricLines(): void {
for (const group of this.currentLyricGroups) {
group.rebuildAllLines();
}
}
/**
* 根据当前配置处理不雅用语单词
* @param word 单词对象
* @internal
*/
processObsceneWord(word: LyricWord): string {
const text = word.word;
if (
!word.obscene ||
this.maskObsceneWords === MaskObsceneWordsMode.Disabled
) {
return text;
}
const maskChar = this.maskObsceneWordChar;
if (this.maskObsceneWords === MaskObsceneWordsMode.FullMask) {
return text.replace(/\S/g, maskChar);
}
if (this.maskObsceneWords === MaskObsceneWordsMode.PartialMask) {
const trimmed = text.trim();
if (trimmed.length <= 2) {
return text.replace(/\S/g, maskChar);
}
const startPos = text.indexOf(trimmed);
const endPos = startPos + trimmed.length - 1;
return (
text.slice(0, startPos + 1) +
text.slice(startPos + 1, endPos).replace(/\S/g, maskChar) +
text.slice(endPos)
);
}
return text;
}
/**
* 设置目标歌词行的对齐方式,默认为 `center`
*
* - 设置成 `top` 的话将会向目标歌词行的顶部对齐
* - 设置成 `bottom` 的话将会向目标歌词行的底部对齐
* - 设置成 `center` 的话将会向目标歌词行的垂直中心对齐
* @param alignAnchor 歌词行对齐方式,详情见函数说明
*/
setAlignAnchor(alignAnchor: LayoutAlignAnchor): void {
this.layoutState.alignAnchor = alignAnchor;
}
/**
* 设置默认的歌词行对齐位置,相对于整个歌词播放组件的大小位置,默认为 `0.5`
* @param alignPosition 一个 `[0.0-1.0]` 之间的任意数字,代表组件高度由上到下的比例位置
*/
setAlignPosition(alignPosition: number): void {
this.layoutState.alignPosition = alignPosition;
}
/**
* 设置 overscan视图上下额外缓冲渲染区距离单位像素。
* @param px 像素值,默认 300
*/
setOverscanPx(px: number): void {
this.layoutState.overscanPx = clampPositive(px | 0);
}
/** 获取当前 overscan 像素距离 */
getOverscanPx(): number {
return this.layoutState.overscanPx;
}
/**
* 设置是否使用物理弹簧算法实现歌词动画效果,默认启用
*
* 如果启用,则会通过弹簧算法实时处理歌词位置,但是需要性能足够强劲的电脑方可流畅运行
*
* 如果不启用,则会回退到基于 `transition` 的过渡效果,对低性能的机器比较友好,但是效果会比较单一
*/
setEnableSpring(enable = true): void {
this.disableSpring = !enable;
if (enable) {
this.element.classList.remove(styles.disableSpring);
} else {
this.element.classList.add(styles.disableSpring);
}
this.calcLayout(true);
}
/**
* 获取当前是否启用了物理弹簧
* @returns 是否启用物理弹簧
*/
getEnableSpring(): boolean {
return !this.disableSpring;
}
/**
* 设置歌词的优化配置项,这些配置项默认全部开启
*
* 注意,如果在 `setLyricLines` 之后修改此配置,需要重新调用 `setLyricLines()` 才能对当前歌词生效
* @param options 优化配置选项
* @see {@link OptimizeLyricOptions}
*/
setOptimizeOptions(options: OptimizeLyricOptions): void {
this.optimizeOptions = { ...this.optimizeOptions, ...options };
}
/**
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
* @param lines 歌词数组
* @param initialTime 初始时间,默认为 0
*/
setLyricLines(lines: LyricLine[], initialTime = 0): void {
if (import.meta.env.DEV) {
console.log("设置歌词行", lines, initialTime);
}
this.timelineState.initialLayoutFinished = true;
this.timelineState.lastCurrentTime = initialTime;
this.timelineState.currentTime = initialTime;
this.currentLyricLines = structuredClone(lines);
this.processedLines = structuredClone(this.currentLyricLines);
optimizeLyricLines(this.processedLines, this.optimizeOptions);
this.isNonDynamic = true;
for (const line of this.processedLines) {
if (line.words.length > 1) {
this.isNonDynamic = false;
break;
}
}
this.hasDuetLine = this.processedLines.some((line) => line.isDuet);
for (const group of this.currentLyricGroups) {
group.dispose();
}
this.currentLyricGroups = [];
this.interludeDots.setInterlude(undefined);
this.timelineState.hotGroups.clear();
this.timelineState.bufferedGroups.clear();
if (import.meta.env.DEV) {
console.log("歌词处理完成", this);
}
}
/**
* 获取当前是否在播放
* @returns 当前是否在播放
*/
public getIsPlaying(): boolean {
return this.timelineState.isPlaying;
}
/**
* 设置当前播放进度,此时将会更新内部的歌词进度信息。
*
* 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好。
* 调用完成后,应每帧调用 {@link update} 方法来执行歌词动画效果。**此函数本身不会触发动画效果**。
*
* @param time 当前播放进度,单位为毫秒
*/
setCurrentTime(time: number, isSeek = false): void {
// 歌词行为如下:
// 如果当前仍有缓冲行的情况下加入新热行,则不会解除当前缓冲行,且也不会修改当前滚动位置
// 如果当前所有缓冲行都将被删除且没有新热行加入,则删除所有缓冲行,且也不会修改当前滚动位置
// 如果当前所有缓冲行都将被删除且有新热行加入,则删除所有缓冲行并加入新热行作为缓冲行,然后修改当前滚动位置
time = Math.round(time);
const { timelineState } = this;
timelineState.isSeeking = Boolean(isSeek);
timelineState.currentTime = time;
if (!timelineState.initialLayoutFinished && !timelineState.isSeeking)
return;
const stateResult = computePlayerTimeState({
time,
currentGroups: this.currentLyricGroups,
timelineState,
});
const bottomEl = this.bottomLine.getElement();
const hasBottomContent = bottomEl.innerHTML.trim().length > 0;
const commitResult = commitPlayerTimeState({
timelineState: timelineState,
time,
currentGroups: this.currentLyricGroups,
hasBottomContent,
stateResult,
});
for (const id of commitResult.groupsToDisable)
this.currentLyricGroups[id]?.disable();
for (const id of commitResult.groupsToEnable)
this.currentLyricGroups[id]?.enable();
if (commitResult.shouldResetScroll) this.resetScroll();
if (commitResult.shouldLayout) {
this.calcLayout(timelineState.isSeeking, timelineState.isSeeking);
}
}
/**
* 重新布局定位歌词行的位置,调用完成后再逐帧调用 `update`
* 函数即可让歌词通过动画移动到目标位置。
*
* 函数有一个 `force` 参数,用于指定是否强制修改布局,也就是不经过动画直接调整元素位置和大小。
*
* 此函数还有一个 `reflow` 参数,用于指定是否需要重新计算布局
*
* 因为计算布局必定会导致浏览器重排布局,所以会大幅度影响流畅度和性能,故请只在以下情况下将其​设置为 true
*
* 1. 歌词页面大小发生改变时(这个组件会自行处理)
* 2. 加载了新的歌词时(不论前后歌词是否完全一样)
* 3. 用户自行跳转了歌曲播放位置(不论距离远近)
*
* @param sync 是否同步执行,通常用于初始化或 Resize 时立即布局
* @param force 是否绕过弹簧效果强制更新位置
*/
async calcLayout(sync = false, force = false): Promise<void> {
const interlude = computeCurrentInterlude({
currentTime: this.timelineState.currentTime,
scrollToIndex: this.timelineState.scrollToIndex,
currentGroups: this.currentLyricGroups,
});
const isInterludeActive = !!interlude;
if (
this.layoutState.targetAlignIndex !== this.timelineState.scrollToIndex ||
this.layoutState.lastInterludeState !== isInterludeActive
) {
this.layoutState.lastInterludeState = isInterludeActive;
const springParams = computeLinePosYSpringParams({
enabled: this.getEnableSpring(),
currentGroups: this.currentLyricGroups,
scrollToIndex: this.timelineState.scrollToIndex,
isSeeking: this.timelineState.isSeeking,
isInterludeActive,
});
if (springParams.shouldUpdate && springParams.params) {
this.setLinePosYSpringParams(springParams.params);
}
}
let curPos = -this.scrollState.scrollOffset;
const targetAlignIndex = this.timelineState.scrollToIndex;
let isNextDuet = false;
if (interlude) {
isNextDuet = interlude.isNextDuet;
} else {
this.interludeDots.setInterlude(undefined);
}
const fontSize = this.baseFontSize || 24;
const dotMargin = fontSize * 0.4;
const totalInterludeHeight =
this.layoutState.interludeDotsSize[1] + dotMargin * 2;
if (interlude) {
if (interlude.anchorLineIndex !== -1) {
curPos -= totalInterludeHeight;
}
}
// 避免一开始就让所有歌词行挤在一起
const LINE_HEIGHT_FALLBACK = this.size[1] / 5;
const scrollOffset = this.currentLyricGroups
.slice(0, targetAlignIndex)
.reduce(
(acc, group) =>
acc + (this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK),
0,
);
this.scrollState.scrollBoundary.minOffset = -scrollOffset;
curPos -= scrollOffset;
curPos += this.size[1] * this.layoutState.alignPosition;
const curGroup = this.currentLyricGroups[targetAlignIndex];
this.layoutState.targetAlignIndex = targetAlignIndex;
const isBottomFocused = targetAlignIndex === this.currentLyricGroups.length;
this.bottomLine.setFocused(isBottomFocused);
const targetLineHeight = curGroup
? (this.lyricGroupSize.get(curGroup)?.[1] ?? LINE_HEIGHT_FALLBACK)
: isBottomFocused
? this.bottomLine.lineSize[1]
: 0;
if (targetLineHeight > 0) {
switch (this.layoutState.alignAnchor) {
case LayoutAlignAnchor.Bottom:
curPos -= targetLineHeight;
break;
case LayoutAlignAnchor.Center:
curPos -= targetLineHeight / 2;
break;
case LayoutAlignAnchor.Top:
break;
}
}
const latestIndex = Math.max(...this.timelineState.bufferedGroups);
let delay = 0;
let baseDelay = sync ? 0 : 0.05;
let setDots = false;
this.currentLyricGroups.forEach((group, i) => {
const hasBuffered = this.timelineState.bufferedGroups.has(i);
const shouldShowDots = interlude && i === interlude.anchorLineIndex + 1;
if (!setDots && shouldShowDots) {
setDots = true;
curPos += dotMargin;
let targetX = 0;
if (interlude && isNextDuet) {
targetX = this.size[0] - this.layoutState.interludeDotsSize[0];
}
this.interludeDots.setTransform(targetX, curPos);
if (interlude) {
this.interludeDots.setInterlude([
interlude.startTime,
interlude.endTime,
]);
}
curPos += this.layoutState.interludeDotsSize[1];
curPos += dotMargin;
}
const presentation = computeGroupPresentation({
groupIndex: i,
scrollToIndex: this.timelineState.scrollToIndex,
latestIndex,
hasBuffered,
hidePassedLines: this.hidePassedLines,
isPlaying: this.timelineState.isPlaying,
isNonDynamic: this.isNonDynamic,
enableBlur: this.enableBlur,
isUserScrolling: this.scrollState.isUserScrolling,
isCompact: window.innerWidth <= 1024,
interlude,
});
group.setTransform(
curPos,
force,
delay,
presentation.isActive,
presentation.targetOpacity,
presentation.blurLevel,
);
curPos += this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK;
if (curPos >= 0 && !this.timelineState.isSeeking) {
delay += baseDelay;
if (i >= this.timelineState.scrollToIndex) baseDelay /= 1.05;
}
});
this.scrollState.scrollBoundary.maxOffset =
curPos + this.scrollState.scrollOffset - this.size[1] / 2;
const bottomIndex = this.currentLyricGroups.length;
const finalBottomBlur = computeLineBlur({
enableBlur: this.enableBlur,
isUserScrolling: this.scrollState.isUserScrolling,
isActive: isBottomFocused,
itemIndex: bottomIndex,
scrollToIndex: this.timelineState.scrollToIndex,
latestIndex,
isCompact: window.innerWidth <= 1024,
});
this.bottomLine.setTransform(0, curPos, finalBottomBlur, force, delay);
}
/**
* 设置所有歌词行在横坐标上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
* @deprecated 考虑到横向弹簧效果并不常见,所以这个函数将会在未来的版本中移除
*/
setLinePosXSpringParams(_params: Partial<SpringParams> = {}): void {}
/**
* 设置所有歌词行在​纵坐标上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
*/
setLinePosYSpringParams(params: Partial<SpringParams> = {}): void {
this.posYSpringParams = {
...this.posYSpringParams,
...params,
};
this.bottomLine.lineTransforms.posY.updateParams(this.posYSpringParams);
for (const group of this.currentLyricGroups) {
group.posY.updateParams(this.posYSpringParams);
group.bgSlideY.updateParams(this.posYSpringParams);
}
}
/**
* 设置所有歌词行在​缩放大小上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
*/
setLineScaleSpringParams(params: Partial<SpringParams> = {}): void {
this.scaleSpringParams = {
...this.scaleSpringParams,
...params,
};
this.scaleForBGSpringParams = {
...this.scaleForBGSpringParams,
...params,
};
for (const group of this.currentLyricGroups) {
group.mainLine.lineTransforms.scale.updateParams(this.scaleSpringParams);
group.bgLine?.lineTransforms.scale.updateParams(
this.scaleForBGSpringParams,
);
}
}
/**
* 暂停部分效果演出,目前会暂停播放间奏点的动画,且将背景歌词显示出来
*/
pause(): void {
this.interludeDots.pause();
if (this.timelineState.isPlaying) {
this.timelineState.isPlaying = false;
this.calcLayout();
}
}
/**
* 恢复部分效果演出,目前会恢复播放间奏点的动画
*/
resume(): void {
this.interludeDots.resume();
if (!this.timelineState.isPlaying) {
this.timelineState.isPlaying = true;
this.calcLayout();
}
}
/**
* 更新动画,这个函数应该被逐帧调用或者在以下情况下调用一次:
*
* 1. 刚刚调用完设置歌词函数的时候
* @param delta 距离上一次被调用到现在的时长,单位为毫秒(可为浮点数)
*/
update(delta = 0): void {
this.bottomLine.update(delta / 1000);
this.interludeDots.update(delta);
}
protected onResize(): void {}
/**
* 获取一个特殊的底栏元素,默认是空白的,可以往内部添加任意元素
*
* 这个元素始终在歌词的底部,可以用于显示歌曲创作者等信息
*
* 但是请勿删除该元素,只能在内部存放元素
*
* @returns 一个元素,可以往内部添加任意元素
*/
getBottomLineElement(): HTMLElement {
return this.bottomLine.getElement();
}
/**
* 重置用户滚动状态
*
* 请在用户完成滚动点击跳转歌词时调用本事件再调用 `calcLayout` 以正确滚动到目标位置
*/
resetScroll(): void {
resetPlayerScrollState(this.scrollState);
clearTimeout(this.scrolledHandler);
this.scrolledHandler = undefined;
}
/**
* 获取当前歌词数组
*
* 一般和最后调用 `setLyricLines` 给予的参数一样
* @returns 当前歌词数组
*/
getLyricLines(): LyricLine[] {
return this.currentLyricLines;
}
/**
* 获取当前歌词的播放位置
*
* 一般和最后调用 `setCurrentTime` 给予的参数一样
* @returns 当前播放位置
*/
getCurrentTime(): number {
return this.timelineState.currentTime;
}
/**
* 设置是否让背景人声行始终后置显示
*
* 默认情况下,如果背景歌词开始时间早于主歌词,会在主歌词上方展示;
* 如果设置为 `true`,则无论时间顺序如何,背景歌词都会始终在主歌词下方展示
* @param enable 是否启用始终后置
*/
setAlwaysPostpositionBackground(enable: boolean): void {
if (this.alwaysPostpositionBackground === enable) {
return;
}
this.alwaysPostpositionBackground = enable;
this.rebuildLyricLines();
this.calcLayout();
}
/** 获取当前是否设置了让背景人声行始终后置显示 */
getAlwaysPostpositionBackground(): boolean {
return this.alwaysPostpositionBackground;
}
getElement(): HTMLElement {
return this.element;
}
dispose(): void {
this.element.remove();
window.removeEventListener("pageshow", this.onPageShow);
window.removeEventListener("pagehide", this.onPageHide);
}
}

View File

@@ -0,0 +1,333 @@
import { clamp } from "#utils/clamp.ts";
import type { SpringParams } from "#utils/spring.ts";
import type { LayoutAlignAnchor } from "./consts.ts";
import type { LyricLineGroupBase } from "./group.ts";
import type { PlayerTimelineState } from "./timeline.ts";
/**
* 播放器布局状态。
*
* 这部分状态保存布局计算阶段所需的配置项与缓存值,
* 例如对齐方式、间奏点尺寸、上一轮布局命中的目标行等。
* 不描述播放时间线或用户滚动交互,仅记录当前歌词排布。
*/
export interface PlayerLayoutState {
/** 间奏点元素当前测量得到的尺寸 */
interludeDotsSize: [number, number];
/** 上一轮布局实际对齐的目标歌词行索引 */
targetAlignIndex: number;
/** 上一轮布局时是否处于间奏区间 */
lastInterludeState: boolean;
/** 当前歌词目标行的对齐锚点 */
alignAnchor: LayoutAlignAnchor;
/** 当前歌词目标行在播放器高度中的相对对齐位置 */
alignPosition: number;
/** 视口上下额外保留的预渲染距离,单位为像素 */
overscanPx: number;
}
/**
* 当前命中的间奏区间信息。
*
* 当播放器检测到当前时间处于两句歌词之间的较长空档期时,
* 会生成该结构,用于驱动间奏点动画的显示位置与时间范围。
*/
export interface PlayerInterlude {
/** 间奏动画的开始时间 */
startTime: number;
/** 间奏动画的结束时间 */
endTime: number;
/** 间奏点应插入到哪一行之后;`-1` 表示位于第一行之前 */
anchorLineIndex: number;
/** 间奏结束后的下一句是否为对唱歌词 */
isNextDuet: boolean;
}
/** {@link computeCurrentInterlude} 的参数类型 */
export interface ComputeCurrentInterludeInput {
currentTime: number;
scrollToIndex: number;
currentGroups: LyricLineGroupBase[];
}
/**
* 根据当前时间与当前目标行,计算当前是否处于某个可展示的间奏区间。
*
* 仅识别时间轴上的间奏空档,不涉及具体 DOM 元素的创建与摆放。
* 若当前不应展示间奏动画,则返回 `undefined`。
*/
export function computeCurrentInterlude(
input: ComputeCurrentInterludeInput,
): PlayerInterlude | undefined {
const currentTime = input.currentTime + 20;
const currentIndex = input.scrollToIndex;
const groups = input.currentGroups;
const checkGap = (k: number): PlayerInterlude | undefined => {
if (k < -1 || k >= groups.length - 1) return undefined;
const prevGroup = k === -1 ? null : groups[k];
const nextGroup = groups[k + 1];
const gapStart = prevGroup ? prevGroup.endTime : 0;
const gapEnd = Math.max(gapStart, nextGroup.startTime - 250);
if (gapEnd - gapStart < 4000) return undefined;
if (gapEnd > currentTime && gapStart < currentTime) {
return {
startTime: Math.max(gapStart, currentTime),
endTime: gapEnd,
anchorLineIndex: k,
isNextDuet: nextGroup.mainLine.getLine().isDuet,
};
}
return undefined;
};
return (
checkGap(currentIndex - 1) ||
checkGap(currentIndex) ||
checkGap(currentIndex + 1)
);
}
/**
* {@link computeLinePosYSpringParams} 的参数类型,
* 用于决定当前歌词纵向滚动动画的弹簧参数。
*/
export interface ComputeLinePosYSpringParamsInput {
/** 是否启用弹簧动画 */
enabled: boolean;
/** 当前用于布局的歌词数据 */
currentGroups: LyricLineGroupBase[];
/** 当前目标对齐行索引 */
scrollToIndex: number;
/** 是否处于 seeking 模式 */
isSeeking: boolean;
/** 是否处于间奏区间 */
isInterludeActive: boolean;
}
/** {@link computeLinePosYSpringParams} 的结果类型 */
export interface ComputeLinePosYSpringParamsResult {
/** 是否需要更新纵向弹簧参数 */
shouldUpdate: boolean;
/** 若需要更新,则返回新的参数 */
params?: Partial<SpringParams>;
}
/**
* 根据当前播放上下文计算歌词纵向滚动动画的弹簧参数。
*
* 其策略为:
* - seeking 或间奏时使用更稳定的固定参数
* - 普通播放时根据相邻歌词的时间间隔动态调整 stiffness / damping
*/
export function computeLinePosYSpringParams(
input: ComputeLinePosYSpringParamsInput,
): ComputeLinePosYSpringParamsResult {
const {
enabled,
currentGroups,
scrollToIndex,
isSeeking,
isInterludeActive,
} = input;
if (!enabled || currentGroups.length === 0) {
return { shouldUpdate: false };
}
if (isSeeking || isInterludeActive) {
return {
shouldUpdate: true,
params: { stiffness: 90, damping: 15 },
};
}
const currentGroup = currentGroups[scrollToIndex];
const prevGroup = currentGroups[scrollToIndex - 1];
if (!currentGroup || !prevGroup) {
return { shouldUpdate: false };
}
const interval = currentGroup.startTime - prevGroup.startTime;
const MIN_INTERVAL = 100;
const MAX_INTERVAL = 800;
const clampedInterval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL);
const MAX_STIFFNESS = 220;
const MIN_STIFFNESS = 170;
let ratio =
1 - (clampedInterval - MIN_INTERVAL) / (MAX_INTERVAL - MIN_INTERVAL);
ratio = ratio ** 0.2;
const targetStiffness =
MIN_STIFFNESS + ratio * (MAX_STIFFNESS - MIN_STIFFNESS);
const dampingMultiplier = 2.2;
const targetDamping = Math.sqrt(targetStiffness) * dampingMultiplier;
return {
shouldUpdate: true,
params: {
stiffness: targetStiffness,
damping: targetDamping,
},
};
}
/**
* {@link computeGroupPresentation} 的参数类型。
*
* 描述一行歌词在当前布局上下文中的全部关键信息,
* 用于计算其视觉呈现结果。
*/
export interface ComputeGroupPresentationInput {
/** 当前歌词组索引 */
groupIndex: number;
/** 当前目标对齐行索引 */
scrollToIndex: number;
/** 当前缓冲区({@link PlayerTimelineState.bufferedGroups})中最靠后的歌词行索引 */
latestIndex: number;
/** 当前歌词行是否在缓冲集合内 */
hasBuffered: boolean;
/** 是否启用隐藏已播放行 */
hidePassedLines: boolean;
/** 是否处于播放状态 */
isPlaying: boolean;
/** 当前歌词是否为非逐词歌词 */
isNonDynamic: boolean;
/** 是否启用模糊效果 */
enableBlur: boolean;
/** 是否正在进行滚动交互 */
isUserScrolling: boolean;
/** 是否处于紧凑布局环境,例如窄屏 */
isCompact: boolean;
/** 当前命中的间奏区间信息 */
interlude?: PlayerInterlude;
}
/** {@link computeGroupPresentation} 的结果类型 */
export interface ComputeGroupPresentationResult {
/** 当前歌词行是否应视为活跃行 */
isActive: boolean;
/** 当前歌词行的目标不透明度 */
targetOpacity: number;
/** 当前歌词行的目标模糊值 */
blurLevel: number;
}
/**
* 计算一组歌词在当前布局中的视觉呈现参数。
*
* 根据播放状态、缓冲状态、布局模式与间奏信息,
* 生成一组歌词最终应使用的活跃状态、不透明度与模糊值。
*/
export function computeGroupPresentation(
input: ComputeGroupPresentationInput,
): ComputeGroupPresentationResult {
const {
groupIndex,
scrollToIndex,
latestIndex,
hasBuffered,
hidePassedLines,
isPlaying,
isNonDynamic,
enableBlur,
isUserScrolling,
isCompact,
interlude,
} = input;
const isActive =
hasBuffered || (groupIndex >= scrollToIndex && groupIndex < latestIndex);
const blurLevel = computeLineBlur({
enableBlur,
isUserScrolling,
isActive,
itemIndex: groupIndex,
scrollToIndex,
latestIndex,
isCompact,
});
let targetOpacity: number;
if (hidePassedLines) {
if (
groupIndex <
(interlude ? interlude.anchorLineIndex + 1 : scrollToIndex) &&
isPlaying
) {
// 为了避免浏览器优化,这里使用了一个极小但不为零的值(几乎不可见)
targetOpacity = 1e-4;
} else if (hasBuffered) {
targetOpacity = 0.85;
} else {
targetOpacity = isNonDynamic ? 0.2 : 1;
}
} else if (hasBuffered) {
targetOpacity = 0.85;
} else {
targetOpacity = isNonDynamic ? 0.2 : 1;
}
return { isActive, targetOpacity, blurLevel };
}
/** {@link computeLineBlur} 的参数类型 */
export interface ComputeLineBlurInput {
/** 是否启用了模糊效果 */
enableBlur: boolean;
/** 用户是否正在滚动 */
isUserScrolling: boolean;
/** 当前项是否活跃 */
isActive: boolean;
/** 当前项索引 */
itemIndex: number;
/** 当前目标对齐行索引 */
scrollToIndex: number;
/** 缓冲区中最靠后的歌词行索引 */
latestIndex: number;
/** 是否处于紧凑布局环境,例如窄屏 */
isCompact: boolean;
}
/**
* 计算一行歌词在当前布局中的模糊等级。
*
* 越远离当前对齐区域的歌词会得到更高的模糊值;
* 活跃行、滚动交互中或关闭模糊效果时返回 `0`。
*/
export function computeLineBlur(input: ComputeLineBlurInput): number {
const {
enableBlur,
isUserScrolling,
isActive,
itemIndex,
scrollToIndex,
latestIndex,
isCompact,
} = input;
if (!enableBlur || isUserScrolling || isActive) {
return 0;
}
let blurLevel = 1;
if (itemIndex < scrollToIndex) {
blurLevel += Math.abs(scrollToIndex - itemIndex) + 1;
} else {
blurLevel += Math.abs(itemIndex - Math.max(scrollToIndex, latestIndex));
}
return isCompact ? blurLevel * 0.8 : blurLevel;
}

View File

@@ -0,0 +1,85 @@
import type { Disposable, LyricLine, LyricWord } from "#interfaces";
import { isCJK } from "#utils/is-cjk.ts";
import { Spring } from "#utils/spring.ts";
import { LyricLineRenderMode } from "./consts.ts";
interface LineTransforms {
scale: Spring;
}
/**
* 所有标准歌词行的基类
* @internal
*/
export abstract class LyricLineBase extends EventTarget implements Disposable {
protected top = 0;
protected scale = 1;
protected blur = 0;
protected opacity = 1;
protected delay = 0;
readonly lineTransforms: LineTransforms = {
scale: new Spring(100),
};
/**
* 用于 CJK 词语边界检测的分词器
*/
static readonly wordSegmenter: Intl.Segmenter | null =
typeof Intl !== "undefined" && Intl.Segmenter
? new Intl.Segmenter(undefined, { granularity: "word" })
: null;
/**
* Unicode 标准的全局 Grapheme Cluster 分词器
* 用于正确处理 emoji、复合字符等
*/
static readonly graphemeSegmenter: Intl.Segmenter | null =
typeof Intl !== "undefined" && Intl.Segmenter
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
abstract getLine(): LyricLine;
abstract enable(time?: number, shouldPlay?: boolean): void;
abstract disable(): void;
abstract resume(): void;
abstract pause(): void;
abstract onLineSizeChange(size: [number, number]): void;
setTransform(
scale: number = this.scale,
opacity: number = this.opacity,
blur: number = this.blur,
_force = false,
delay = 0,
_mode: LyricLineRenderMode = LyricLineRenderMode.SOLID,
): void {
this.scale = scale;
this.opacity = opacity;
this.blur = blur;
this.delay = delay;
}
rebuildElement(): void {}
/**
* 判定歌词是否可以应用强调辉光效果
*
* 果子在对辉光效果的解释是一种强调emphasized效果
*
* 条件是一个单词时长大于等于 1s 且长度小于等于 7
*
* @param word 单词
* @returns 是否可以应用强调辉光效果
*/
static shouldEmphasize(word: LyricWord): boolean {
if (isCJK(word.word)) return word.endTime - word.startTime >= 1000;
return (
word.endTime - word.startTime >= 1000 &&
word.word.trim().length <= 7 &&
word.word.trim().length > 1
);
}
abstract update(delta?: number): void;
dispose(): void {}
}

View File

@@ -0,0 +1,231 @@
import { clamp } from "#utils/clamp.ts";
/**
* 播放器滚动状态。
*
* 这部分状态描述用户手势/滚轮滚动产生的临时偏移,以及当前允许滚动的范围。
* 改状态仅记录用户如何把当前视图上下拖动,不决定应该滚动到哪一行,
* 后者由时间线状态与布局计算共同决定。
*/
export interface PlayerScrollState {
/** 允许的滚动偏移范围 */
scrollBoundary: {
/** 允许的最小偏移量 */
minOffset: number;
/** 允许的最大偏移量 */
maxOffset: number;
};
/** 当前用户滚动带来的额外偏移量 */
scrollOffset: number;
/** 是否允许用户通过手势或滚轮滚动歌词视图 */
allowScroll: boolean;
/** 是否处于用户滚动过,尚未回归自动对齐的状态 */
isScrolled: boolean;
/** 是否正在进行滚动交互或惯性滚动 */
isUserScrolling: boolean;
/** Bumped when scroll state is reset so pending inertial frames can stop. */
resetToken?: number;
}
/**
* 将滚动偏移量限制在当前允许的滚动边界内。
*
* 当手势滚动、滚轮滚动或惯性滚动更新了 {@link PlayerScrollState.scrollOffset}
* 后,应调用本函数以避免视图越界。
*/
export function clampPlayerScrollOffset(scrollState: PlayerScrollState): void {
scrollState.scrollOffset = clamp(
scrollState.scrollOffset,
scrollState.scrollBoundary.minOffset,
scrollState.scrollBoundary.maxOffset,
);
}
/**
* 重置滚动状态到未发生用户滚动时的初始状态。
*
* 本函数会清除当前偏移,并结束“已滚动”与“正在滚动”的标记;
* **不会清理**外部持有的计时器或事件监听器。
*/
export function resetPlayerScrollState(scrollState: PlayerScrollState): void {
scrollState.isScrolled = false;
scrollState.scrollOffset = 0;
scrollState.isUserScrolling = false;
scrollState.resetToken = (scrollState.resetToken ?? 0) + 1;
}
/**
* {@link attachPlayerScrollHandlers} 所需的宿主回调。
*
* 这些回调将滚动模块与具体播放器实现解耦:
* 滚动模块只负责处理输入事件和更新滚动状态,布局刷新、点击转发等副作用
* 由宿主决定如何执行。
*/
export interface AttachPlayerScrollHandlersCallbacks {
/** 开始一次滚动处理前调用,返回 `false` 可阻止本次滚动 */
onBeginScroll: () => boolean;
/** 一次滚动交互或惯性滚动结束时调用 */
onEndScroll: () => void;
/** 请求宿主重新布局 */
onLayout: (sync: boolean, force: boolean) => void;
/** 判断某个点击目标是否仍属于当前播放器视图 */
containsTarget: (target: Node) => boolean;
/** 将点击事件转发给命中的目标元素 */
clickTarget: (target: HTMLElement) => void;
}
/**
* 向指定元素挂载歌词滚动相关的交互处理器。
*
* 该函数会处理:
* - 触摸拖拽滚动
* - 触摸结束后的惯性滚动
* - 滚轮滚动
* - 轻触时的点击透传
*
* 只更新 {@link PlayerScrollState} 并通过回调通知宿主执行布局或其它副作用,
* 不直接依赖具体的播放器类实现。
*/
export function attachPlayerScrollHandlers(
element: HTMLElement,
scrollState: PlayerScrollState,
callbacks: AttachPlayerScrollHandlersCallbacks,
): void {
let startScrollY = 0;
let startTouchPosY = 0;
let startTouchStartX = 0;
let startTouchStartY = 0;
let lastMoveY = 0;
let startScrollTime = 0;
let scrollSpeed = 0;
let curScrollId = 0;
element.addEventListener("touchstart", (evt) => {
if (callbacks.onBeginScroll()) {
curScrollId += 1;
scrollState.isUserScrolling = true;
evt.preventDefault();
startScrollY = scrollState.scrollOffset;
startTouchPosY = evt.touches[0].screenY;
lastMoveY = startTouchPosY;
startTouchStartX = evt.touches[0].screenX;
startTouchStartY = evt.touches[0].screenY;
startScrollTime = Date.now();
scrollSpeed = 0;
callbacks.onLayout(true, true);
}
});
element.addEventListener("touchmove", (evt) => {
if (callbacks.onBeginScroll()) {
evt.preventDefault();
const currentY = evt.touches[0].screenY;
const deltaY = currentY - startTouchPosY;
scrollState.scrollOffset = startScrollY - deltaY;
clampPlayerScrollOffset(scrollState);
const now = Date.now();
const dt = now - startScrollTime;
if (dt > 0) {
scrollSpeed = (currentY - lastMoveY) / dt;
}
lastMoveY = currentY;
startScrollTime = now;
callbacks.onLayout(true, true);
}
});
element.addEventListener("touchend", (evt) => {
if (callbacks.onBeginScroll()) {
evt.preventDefault();
const touch = evt.changedTouches[0];
const moveX = Math.abs(touch.screenX - startTouchStartX);
const moveY = Math.abs(touch.screenY - startTouchStartY);
if (moveX < 10 && moveY < 10) {
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target instanceof HTMLElement && callbacks.containsTarget(target)) {
callbacks.clickTarget(target);
}
scrollState.isUserScrolling = false;
callbacks.onEndScroll();
return;
}
startTouchPosY = 0;
const scrollId = ++curScrollId;
const resetToken = scrollState.resetToken ?? 0;
if (Math.abs(scrollSpeed) < 0.1) scrollSpeed = 0;
let lastFrameTime = performance.now();
const onScrollFrame = (time: number) => {
if (
scrollId !== curScrollId ||
resetToken !== (scrollState.resetToken ?? 0)
) {
return;
}
const dt = time - lastFrameTime;
lastFrameTime = time;
if (dt <= 0 || dt > 100) {
requestAnimationFrame(onScrollFrame);
return;
}
if (Math.abs(scrollSpeed) > 0.05) {
scrollState.scrollOffset -= scrollSpeed * dt;
clampPlayerScrollOffset(scrollState);
const frictionFactor = 0.95 ** (dt / 16);
scrollSpeed *= frictionFactor;
callbacks.onLayout(true, true);
requestAnimationFrame(onScrollFrame);
} else {
scrollState.isUserScrolling = false;
callbacks.onEndScroll();
}
};
requestAnimationFrame(onScrollFrame);
} else {
scrollState.isUserScrolling = false;
}
});
element.addEventListener(
"wheel",
(evt) => {
if (callbacks.onBeginScroll()) {
evt.preventDefault();
if (evt.deltaMode === evt.DOM_DELTA_PIXEL) {
scrollState.scrollOffset += evt.deltaY;
clampPlayerScrollOffset(scrollState);
callbacks.onLayout(true, false);
} else {
scrollState.scrollOffset += evt.deltaY * 50;
clampPlayerScrollOffset(scrollState);
callbacks.onLayout(false, false);
}
}
},
{ passive: false },
);
}

View File

@@ -0,0 +1,236 @@
import { eqSet } from "#utils/eq-set.ts";
import type { LyricLineGroupBase } from "./group.ts";
/**
* 播放时间线状态。
*
* 描述播放器在时间轴上的当前位置,当前处于激活状态的歌词组信息
*/
export interface PlayerTimelineState {
/** 当前播放时间,单位为毫秒 */
currentTime: number;
/** 上一次提交到时间线状态的播放时间,单位为毫秒 */
lastCurrentTime: number;
/** 热行:当前时间 {@link currentTime} 正在命中的组(含主行+可能的背景行) */
hotGroups: Set<number>;
/** 缓冲组UI 上还保持激活表现的组索引,通常包含热组,和刚结束仍在过渡中的组 */
bufferedGroups: Set<number>;
/** 当前应滚动对齐到的歌词组索引 */
scrollToIndex: number;
/** 是否正在拖拽进度条。若是,更新时丢弃缓冲行,并根据当前时间直接计算热行 */
isSeeking: boolean;
/** 是否处于播放状态 */
isPlaying: boolean;
/** 是否已经完成至少一次初始布局 */
initialLayoutFinished: boolean;
}
/** {@link computePlayerTimeState} 的参数类型 */
export interface ComputePlayerTimeStateInput {
time: number;
currentGroups: LyricLineGroupBase[];
timelineState: Readonly<PlayerTimelineState>;
}
/** {@link computePlayerTimeState} 的返回类型 */
export interface ComputePlayerTimeStateResult {
/** 计算后的新热组集合 */
nextHotGroups: Set<number>;
/** 需要新加入热组集合的组索引 */
addedIds: Set<number>;
/** 需要从热组集合中移除的组索引 */
removedHotIds: Set<number>;
/** 需要从缓冲组集合中移除的组索引 */
removedBufferedIds: Set<number>;
}
/**
* 计算指定时间点的热行/缓冲行状态转移的纯函数。其行为包括:
*
* - 根据当前时间和已有的热行状态,计算出新的热行状态,并返回应新增的热行 ID 和应移除的热行 ID
* - 根据新的热行状态和已有的缓冲行状态,计算出应移除的缓冲行 ID
*/
export function computePlayerTimeState(
input: ComputePlayerTimeStateInput,
): ComputePlayerTimeStateResult {
const {
time,
currentGroups,
timelineState: { hotGroups, bufferedGroups },
} = input;
const nextHotGroups = new Set(hotGroups);
const addedIds = new Set<number>();
const removedHotIds = new Set<number>();
const removedBufferedIds = new Set<number>();
for (const lastHotId of hotGroups) {
const group = currentGroups[lastHotId];
if (!group || time < group.startTime || group.endTime <= time) {
nextHotGroups.delete(lastHotId);
removedHotIds.add(lastHotId);
}
}
for (let id = 0; id < currentGroups.length; id++) {
const group = currentGroups[id];
if (!group) continue;
if (
group.startTime <= time &&
group.endTime > time &&
!nextHotGroups.has(id)
) {
nextHotGroups.add(id);
addedIds.add(id);
}
}
for (const id of bufferedGroups) {
if (!nextHotGroups.has(id)) {
removedBufferedIds.add(id);
}
}
return {
nextHotGroups,
addedIds,
removedHotIds,
removedBufferedIds,
};
}
/**
* 在 seeking 场景下,根据当前时间选出应对齐滚动到的目标行索引。
*
* 若当前仍存在缓冲行,则优先对齐到最靠前的缓冲行;
* 否则对齐到第一条开始时间不小于当前时间的歌词行。
*/
export function pickScrollToIndexForSeek(
time: number,
currentGroups: LyricLineGroupBase[],
bufferedGroups: ReadonlySet<number>,
): number {
if (bufferedGroups.size > 0) {
return Math.min(...bufferedGroups);
}
const foundIndex = currentGroups.findIndex(
(group) => group.startTime >= time,
);
return foundIndex === -1 ? currentGroups.length : foundIndex;
}
/**
* {@link commitPlayerTimeState} 的参数类型。
*
* 用于将一次时间线状态转移提交回 {@link PlayerTimelineState}
* 并生成供宿主执行的副作用应用计划。
*/
export interface CommitPlayerTimeStateInput {
/** 要被更新的时间线状态对象 */
timelineState: PlayerTimelineState;
/** 当前播放时间,单位为毫秒 */
time: number;
/** 当前用于计算的歌词数据 */
currentGroups: LyricLineGroupBase[];
/** 底部附加区域当前是否有可见内容 */
hasBottomContent: boolean;
/** 由 {@link computePlayerTimeState} 得到的状态转移结果 */
stateResult: ComputePlayerTimeStateResult;
}
/** {@link commitPlayerTimeState} 的返回类型 */
export interface CommitPlayerTimeStateResult {
/** 提交后是否需要重新布局 */
shouldLayout: boolean;
/** 提交后是否需要重置用户滚动状态 */
shouldResetScroll: boolean;
/** 需要启用的歌词组索引列表 */
groupsToEnable: number[];
/** 需要禁用的歌词组索引列表 */
groupsToDisable: number[];
}
/**
* 提交时间线状态转移的纯函数。
*
* 把一次时间线状态转移写回 {@link PlayerTimelineState}
* 并返回一份供宿主执行的副作用应用计划,例如启用/禁用哪些歌词行、
* 是否需要重置用户滚动状态、是否需要触发布局。
*/
export function commitPlayerTimeState(
input: CommitPlayerTimeStateInput,
): CommitPlayerTimeStateResult {
const { timelineState, time, currentGroups, hasBottomContent, stateResult } =
input;
const { addedIds, removedHotIds, removedBufferedIds } = stateResult;
const { isSeeking } = timelineState;
timelineState.currentTime = time;
timelineState.hotGroups = stateResult.nextHotGroups;
let shouldLayout = false;
let shouldResetScroll = false;
const groupsToEnable: number[] = [];
const groupsToDisable = new Set<number>();
if (isSeeking) {
timelineState.bufferedGroups = new Set([...timelineState.hotGroups]);
timelineState.scrollToIndex = pickScrollToIndexForSeek(
time,
currentGroups,
timelineState.bufferedGroups,
);
for (const id of removedHotIds) groupsToDisable.add(id);
for (const id of timelineState.hotGroups) groupsToEnable.push(id);
for (const id of removedBufferedIds) groupsToDisable.add(id);
shouldResetScroll = true;
shouldLayout = true;
} else if (addedIds.size > 0) {
for (const id of addedIds) {
timelineState.bufferedGroups.add(id);
groupsToEnable.push(id);
}
for (const id of removedBufferedIds) {
timelineState.bufferedGroups.delete(id);
groupsToDisable.add(id);
}
if (timelineState.bufferedGroups.size > 0) {
timelineState.scrollToIndex = Math.min(...timelineState.bufferedGroups);
}
shouldLayout = true;
} else if (
removedBufferedIds.size > 0 &&
eqSet(removedBufferedIds, timelineState.bufferedGroups)
) {
for (const id of timelineState.bufferedGroups) {
if (timelineState.hotGroups.has(id)) continue;
timelineState.bufferedGroups.delete(id);
groupsToDisable.add(id);
}
shouldLayout = true;
}
if (timelineState.bufferedGroups.size === 0 && currentGroups.length > 0) {
const lastGroup = currentGroups[currentGroups.length - 1];
if (time >= lastGroup.endTime) {
const targetIndex = hasBottomContent
? currentGroups.length
: currentGroups.length - 1;
if (timelineState.scrollToIndex !== targetIndex) {
timelineState.scrollToIndex = targetIndex;
shouldLayout = true;
}
}
}
timelineState.lastCurrentTime = time;
return {
shouldLayout,
shouldResetScroll,
groupsToEnable,
groupsToDisable: [...groupsToDisable],
};
}

View File

@@ -0,0 +1,247 @@
/**
* @fileoverview
* 一个播放歌词的组件
* @author SteveXMH
*/
import type { LyricLine } from "#interfaces";
import "#styles/index.css";
import { LyricPlayerBase } from "#lyric/base/index.ts";
import type { LyricLineBase } from "#lyric/base/line.ts";
import styles from "#styles/lyric-player.module.css";
import { LyricLineGroup } from "./lyric-group.ts";
import { LyricLineEl } from "./lyric-line.ts";
/**
* 歌词行鼠标相关事件,可以获取到歌词行的索引、主歌词行以及背景歌词行(如果有)元素
*/
export class LyricLineMouseEvent extends MouseEvent {
/**
* 自定义标志位,用于记录外部是否调用了 `stopPropagation`
*/
public isPropagationStopped = false;
constructor(
/**
* 歌词行索引
*/
public readonly lineIndex: number,
/**
* 歌词行元素
*/
public readonly line: LyricLineBase,
/**
* 背景人声歌词行元素 (如果存在)
*/
public readonly bgLine: LyricLineBase | undefined,
event: MouseEvent,
) {
super(`line-${event.type}`, event);
}
override stopPropagation(): void {
this.isPropagationStopped = true;
super.stopPropagation();
}
override stopImmediatePropagation(): void {
this.isPropagationStopped = true;
super.stopImmediatePropagation();
}
}
export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void;
/**
* 歌词播放组件,本框架的核心组件
*
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
*/
export class DomLyricPlayer extends LyricPlayerBase {
private abortController = new AbortController();
override currentLyricGroups: LyricLineGroup[] = [];
override onResize(): void {
const computedStyles = getComputedStyle(this.element);
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
this.rebuildStyle();
}
readonly supportPlusLighter: boolean = CSS.supports(
"mix-blend-mode",
"plus-lighter",
);
readonly supportMaskImage: boolean = CSS.supports("mask-image", "none");
readonly innerSize: [number, number] = [0, 0];
private readonly onMouseEventHandler = (e: MouseEvent) => {
const target = e.target;
if (!(target instanceof Element)) return;
const groupEl = target.closest(`.${styles.lyricLineWrapper}`);
if (!groupEl) return;
const group = this.lyricGroupElementMap.get(groupEl);
if (!group) return;
const mainLine = group.mainLine;
const bgLine = group.bgLine;
const lineIndex = this.lyricLinesIndexes.get(mainLine) ?? -1;
if (e.type === "click") {
this.resetScroll();
}
const evt = new LyricLineMouseEvent(lineIndex, mainLine, bgLine, e);
const isDispatched = this.dispatchEvent(evt);
if (!isDispatched || evt.defaultPrevented) {
e.preventDefault();
}
if (evt.isPropagationStopped) {
e.stopPropagation();
e.stopImmediatePropagation();
}
};
/**
* 是否为非逐词歌词
* @internal
*/
_getIsNonDynamic(): boolean {
return this.isNonDynamic;
}
private _baseFontSize = Number.parseFloat(
getComputedStyle(this.element).fontSize,
);
public get baseFontSize(): number {
return this._baseFontSize;
}
constructor() {
super();
this.onResize();
this.element.classList.add("amll-lyric-player", "dom");
if (this.disableSpring) {
this.element.classList.add(styles.disableSpring);
}
this.element.addEventListener("click", this.onMouseEventHandler, {
signal: this.abortController.signal,
});
this.element.addEventListener("contextmenu", this.onMouseEventHandler, {
signal: this.abortController.signal,
});
}
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): void {
super.setWordFadeWidth(value);
for (const group of this.currentLyricGroups) {
group.mainLine.updateMaskImageSync();
group.bgLine?.updateMaskImageSync();
}
}
/**
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
* @param lines 歌词数组
* @param initialTime 初始时间,默认为 0
*/
override setLyricLines(lines: LyricLine[], initialTime = 0): void {
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 group of this.currentLyricGroups) {
group.dispose();
}
this.currentLyricGroups = [];
let currentGroup: LyricLineGroup | null = null;
for (let i = 0; i < this.processedLines.length; i++) {
const line = this.processedLines[i];
const lineEl = new LyricLineEl(this, line);
this.lyricLinesIndexes.set(lineEl, i);
if (!line.isBG || !currentGroup) {
currentGroup = new LyricLineGroup(this, lineEl);
this.currentLyricGroups.push(currentGroup);
this.lyricGroupElementMap.set(currentGroup.element, currentGroup);
} else {
currentGroup.addBgLine(lineEl);
}
}
this.setLinePosXSpringParams({});
this.setLinePosYSpringParams({});
this.setLineScaleSpringParams({});
this.setCurrentTime(initialTime, true);
this.calcLayout(true);
this.update(0);
}
override pause(): void {
super.pause();
this.element.classList.remove(styles.playing);
this.interludeDots.pause();
for (const group of this.currentLyricGroups) {
group.mainLine.pause();
group.bgLine?.pause();
}
}
override resume(): void {
super.resume();
this.element.classList.add(styles.playing);
this.interludeDots.resume();
for (const group of this.currentLyricGroups) {
group.mainLine.resume();
group.bgLine?.resume();
}
}
override update(delta = 0): void {
if (!this.timelineState.initialLayoutFinished) return;
super.update(delta);
if (!this.supportMaskImage) {
this.element.style.setProperty(
"--amll-player-time",
`${this.timelineState.currentTime}`,
);
}
if (!this.isPageVisible) return;
const deltaS = delta / 1000;
for (const group of this.currentLyricGroups) {
group.update(deltaS);
}
}
override dispose(): void {
super.dispose();
this.abortController.abort();
this.element.remove();
for (const group of this.currentLyricGroups) {
group.dispose();
}
this.bottomLine.dispose();
this.interludeDots.dispose();
}
}

View File

@@ -0,0 +1,153 @@
import type { Disposable, HasElement } from "#interfaces";
import styles from "#styles/lyric-player.module.css";
import { clamp, clamp01, clampPositive } from "#utils/clamp.ts";
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);
}
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(): HTMLElement {
return this.element;
}
setTransform(left: number = this.left, top: number = this.top): void {
this.left = left;
this.top = top;
this.update();
}
setInterlude(interlude?: [number, number]): void {
this.currentInterlude = interlude;
this.currentTime = interlude?.[0] ?? 0;
if (interlude) {
this.element.classList.add(styles.enabled);
} else {
this.element.classList.remove(styles.enabled);
}
}
pause(): void {
this.playing = false;
this.element.classList.remove(styles.playing);
}
resume(): void {
this.playing = true;
this.element.classList.add(styles.playing);
}
update(delta = 0): void {
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 *= clamp01((interludeDuration - currentDuration) / 375);
}
const dotsDuration = clampPositive(interludeDuration - 750);
scale = clampPositive(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 = `${clamp01(globalOpacity * dot0Opacity)}`;
this.dot1.style.opacity = `${clamp01(globalOpacity * dot1Opacity)}`;
this.dot2.style.opacity = `${clamp01(globalOpacity * dot2Opacity)}`;
} 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(): void {
this.element.remove();
}
}

View File

@@ -0,0 +1,173 @@
import { LyricLineGroupBase } from "#lyric/base/group.ts";
import styles from "#styles/lyric-player.module.css";
import { clamp01 } from "#utils/clamp.ts";
import type { DomLyricPlayer } from "./index.ts";
import type { LyricLineEl } from "./lyric-line.ts";
export class LyricLineGroup extends LyricLineGroupBase<LyricLineEl> {
public element: HTMLElement;
public bgWrapper?: HTMLElement;
private lastIsActive?: boolean;
constructor(
public lyricPlayer: DomLyricPlayer,
mainLine: LyricLineEl,
) {
super(mainLine);
this.element = document.createElement("div");
this.element.className = styles.lyricLineWrapper;
this.element.appendChild(mainLine.getElement());
this.posY.setPosition(window.innerHeight * 2);
lyricPlayer.resizeObserver.observe(this.element);
}
get isInSight(): boolean {
const t = this.posY.getCurrentPosition();
let h = this.lyricPlayer.lyricGroupSize?.get(this)?.[1];
if (h === undefined || h === 0) {
h = this.element.clientHeight || 0;
}
const pb = this.lyricPlayer.size[1];
const ov = this.lyricPlayer.getOverscanPx();
return !(t > pb + h + ov || t < -h - ov);
}
show(): void {
if (!this.element.parentElement) {
const playerEl = this.lyricPlayer.getElement();
const groups = this.lyricPlayer.currentLyricGroups;
const myIndex = groups.indexOf(this);
let referenceNode: HTMLElement | null = null;
if (myIndex !== -1) {
for (let i = myIndex + 1; i < groups.length; i++) {
if (groups[i].element.parentElement === playerEl) {
referenceNode = groups[i].element;
break;
}
}
}
playerEl.insertBefore(this.element, referenceNode);
this.lyricPlayer.resizeObserver.observe(this.element);
}
this.mainLine.show();
this.bgLine?.show();
}
hide(): void {
if (this.element.parentElement) {
this.lyricPlayer.resizeObserver.unobserve(this.element);
this.element.remove();
this.mainLine.teardownContent();
this.bgLine?.teardownContent();
}
}
override update(delta: number): void {
if (this.isInSight) {
this.show();
} else {
this.hide();
}
super.update(delta);
}
addBgLine(bgLine: LyricLineEl): void {
if (this.bgLine) {
this.bgLine.dispose();
}
if (this.bgWrapper) {
this.bgWrapper.remove();
}
this.bgLine = bgLine;
// 需要对比第一个词的开始时间而不是行起始时间,因为行的起始时间已经被
// `syncMainAndBackgroundLines` 同步过了
const bgStartTime =
bgLine.getLine().words[0]?.startTime ?? bgLine.getLine().startTime;
const mainStartTime =
this.mainLine.getLine().words[0]?.startTime ??
this.mainLine.getLine().startTime;
this.isBgFirst = bgStartTime < mainStartTime;
if (this.mainLine.getLine().isDuet) {
bgLine.getElement().classList.add(styles.lyricDuetLine);
}
this.bgWrapper = document.createElement("div");
this.bgWrapper.className = styles.bgWrapper;
this.bgWrapper.appendChild(bgLine.getElement());
const alwaysPostposition =
this.lyricPlayer.getAlwaysPostpositionBackground();
const shouldBgFirst = !alwaysPostposition && this.isBgFirst;
if (shouldBgFirst) {
this.bgWrapper.classList.add(styles.bgWrapperTop);
this.element.insertBefore(this.bgWrapper, this.mainLine.getElement());
this.bgSlideY.setPosition(80);
} else {
this.element.appendChild(this.bgWrapper);
}
}
protected renderStyles(): void {
const y = this.posY.getCurrentPosition().toFixed(1);
this.element.style.transform = `translateY(${y}px)`;
this.element.style.opacity = this.opacity.toString();
this.element.style.filter = `blur(${Math.min(5, this.blur)}px)`;
if (!this.lyricPlayer.getEnableSpring()) {
this.element.style.transitionDelay = `${this.delay}ms`;
}
if (this.bgWrapper) {
if (this.lastIsActive !== this.isActive) {
this.lastIsActive = this.isActive;
this.bgWrapper.classList.toggle(styles.bgWrapperActive, this.isActive);
}
const slideY = this.bgSlideY.getCurrentPosition();
const slideYStr = slideY.toFixed(1);
const activeProgress = clamp01(1 - Math.abs(slideY) / 80);
const scaleStr = (0.8 + activeProgress * 0.2).toFixed(3);
this.bgWrapper.style.transform = `translateY(${slideYStr}%) scale(${scaleStr})`;
const alwaysPostposition =
this.lyricPlayer.getAlwaysPostpositionBackground();
const shouldBgFirst = !alwaysPostposition && this.isBgFirst;
if (shouldBgFirst) {
const bgHeight = this.bgWrapper.clientHeight || 0;
const currentMarginTop = -bgHeight * (1 - activeProgress);
this.bgWrapper.style.marginTop = `${currentMarginTop.toFixed(1)}px`;
} else {
this.bgWrapper.style.marginTop = "";
}
const targetHiddenYStr = shouldBgFirst ? "80.0" : "-80.0";
const isHidden = slideYStr === targetHiddenYStr && !this.isActive;
this.bgWrapper.classList.toggle(styles.bgWrapperHidden, isHidden);
}
}
override dispose(): void {
super.dispose();
this.lyricPlayer.resizeObserver.unobserve(this.element);
this.element.remove();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
import { DomLyricPlayer } from "./dom/index.ts";
export * from "./base/consts.ts";
export * from "./base/index.ts";
export * from "./dom/index.ts";
export {
/**
* 默认导出的歌词播放组件
*/
DomLyricPlayer as LyricPlayer,
};

View File

@@ -0,0 +1,36 @@
/*
给不需要重命名的全局类名的样式
*/
/* 给歌词视图元素应用的样式 */
.amll-lyric-player {
width: 100%;
height: 100%;
overflow: hidden;
max-width: 100%;
color: var(--amll-lp-color, white);
contain: strict;
mix-blend-mode: plus-lighter;
font-size: var(--amll-lp-font-size, max(max(5vh, 2.5vw), 12px));
@media screen and (max-width: 768px) {
font-size: var(--amll-lp-font-size, max(8vw, 12px));
}
&.dom {
--amll-lp-line-width-aspect: 0.8;
--amll-lp-line-padding-x: 1em;
--amll-lp-bg-line-scale: 0.7;
user-select: none;
box-sizing: content-box;
z-index: 1;
line-height: 1.2;
}
}
@media screen and (max-width: 768px) {
.amll-lyric-player {
--amll-lp-line-width-aspect: 1;
--amll-lp-line-padding-x: 0;
}
}

View File

@@ -0,0 +1,298 @@
.lyricLineWrapper {
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
padding: 0.4em var(--lyric-line-padding-x);
border-radius: 0.25em;
gap: 0.3em;
will-change: transform, opacity, filter;
transition:
opacity 0.4s ease,
filter 0.4s ease,
background-color 0.25s ease;
&:hover {
background-color: var(--amll-lp-hover-bg-color, #fff1);
}
&:active {
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
}
}
.lyricLine {
position: relative;
box-sizing: border-box;
width: var(--amll-lp-width, 100%);
min-width: var(--amll-lp-width, 100%);
max-width: var(--amll-lp-width, 100%);
height: fit-content;
/* Keep font descenders inside the paint containment without changing layout. */
padding: 0.2em 0.2em 0.32em;
margin: -0.2em -0.2em -0.32em;
contain: layout style paint;
content-visibility: auto;
contain-intrinsic-size: auto 40px;
backface-visibility: hidden;
transform-origin: left;
will-change: transform, filter, opacity;
transition: box-shadow 0.25s;
}
.lyricDuetLine {
text-align: right;
transform-origin: right;
}
.lyricMainLine {
margin: -1em;
padding: 1em;
transition: opacity 0.3s 0.1s;
contain: layout style;
& span {
display: inline-block;
text-align: start;
vertical-align: bottom;
}
.romanWord {
display: flex;
padding-inline-end: 0.3em;
font-size: 0.5em;
line-height: 1em;
}
.rubyWord {
display: flex;
justify-content: center;
min-height: 1em;
font-size: 0.5em;
line-height: 1em;
}
.wordWithRuby {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: bottom;
}
.wordBody {
display: flex;
flex-direction: column;
align-items: center;
}
& > span,
span.emphasizeWrapper {
display: inline-block;
margin: -1em;
padding: 1em;
white-space: pre-wrap;
vertical-align: bottom;
will-change: transform, text-shadow;
contain: layout style;
&.emphasize,
span.emphasize {
margin: -1em;
padding: 1em;
backface-visibility: hidden;
& > span {
margin: -1em;
padding: 1em;
will-change: transform, text-shadow;
backface-visibility: hidden;
}
}
}
}
.lyricBgLine {
opacity: 0.4;
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
transition:
background-color 0.25s,
box-shadow 0.25s;
.lyricMainLine {
padding: 1.2em 1em;
}
&.active {
opacity: 0.4;
transition:
background-color 0.25s,
box-shadow 0.25s;
}
}
.lyricSubLine {
opacity: 0.3;
font-size: max(0.5em, 10px);
line-height: 1.5em;
transition: opacity 0.2s 0.25s;
@supports (mix-blend-mode: plus-lighter) {
opacity: 0.3;
}
}
.bottomLine {
padding-top: 0;
padding-bottom: 0;
line-height: 1.8em;
cursor: default;
&:empty {
display: none;
height: 0;
margin: 0;
padding: 0;
}
}
.bgWrapper {
position: absolute;
top: 100%;
left: var(--lyric-line-padding-x);
z-index: -1;
display: flex;
flex-direction: column;
align-items: inherit;
width: calc(100% - var(--lyric-line-padding-x) * 2);
visibility: visible;
pointer-events: auto;
opacity: 0;
transform-origin: left top;
transition: opacity 0.3s ease;
}
.bgWrapperTop {
position: relative;
left: 0;
width: 100%;
top: auto;
bottom: auto;
margin-top: -999px;
transform-origin: left bottom;
}
.bgWrapperActive {
position: relative;
left: 0;
width: 100%;
top: auto;
bottom: auto;
opacity: 1;
}
.bgWrapperHidden {
visibility: hidden;
pointer-events: none;
}
.interludeDots {
position: absolute;
left: 0;
display: flex;
gap: 0.25em;
width: fit-content;
height: clamp(0.5em, 1vh, 3em);
padding: 2.5% 0.75em;
opacity: 0;
transform-origin: center;
transition: opacity 0.25s;
&.enabled {
opacity: 1;
}
& > * {
display: inline-block;
width: clamp(0.5em, 1vh, 3em);
height: clamp(0.5em, 1vh, 3em);
margin-right: 4px;
border-radius: 50%;
background-color: var(--amll-lp-color, white);
aspect-ratio: 1 / 1;
}
&.duet {
right: 0;
transform-origin: center;
}
}
.disableSpring > *,
.disableSpring .lyricLine {
transition:
filter 0.25s,
transform 0.5s,
background-color 0.25s,
box-shadow 0.25s;
}
.tmpDisableTransition {
/* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */
transition: none !important;
}
:global(.amll-lyric-player) {
--lyric-line-padding-x: 1em;
@media screen and (max-width: 500px) {
--lyric-line-padding-x: 20px;
}
&:hover .lyricLine,
&:hover .lyricLineWrapper {
/* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */
filter: unset !important;
}
&:not(.playing) .bgWrapper {
position: relative;
top: auto;
bottom: auto;
opacity: 1;
left: 0;
width: 100%;
}
&.hasDuetLine {
.lyricLine:not(.lyricDuetLine) {
padding-right: 15%;
}
.lyricDuetLine {
padding-left: 15%;
}
.lyricLineWrapper:has(.lyricDuetLine) {
align-items: flex-end;
.bgWrapper {
transform-origin: right top;
}
.bgWrapperTop {
transform-origin: right bottom;
}
}
}
}

29
amll-local/packages/core/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/** biome-ignore-all lint/correctness/noUnusedVariables: used */
declare module "*.glsl" {
const content: string;
export default content;
}
declare module "*.glsl?raw" {
const content: string;
export default content;
}
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.css" {
const css: string;
export default css;
}
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,11 @@
export function clamp(x: number, min: number, max: number): number {
return Math.min(Math.max(x, min), max);
}
export function clamp01(x: number): number {
return clamp(x, 0, 1);
}
export function clampPositive(x: number): number {
return Math.max(0, x);
}

View File

@@ -0,0 +1,34 @@
// biome-ignore lint/suspicious/noExplicitAny: for debounce function
export function debounce<T extends (...args: any) => any>(
cb: T,
wait = 20,
): (...args: Parameters<T>) => void {
let h: ReturnType<typeof setTimeout> | undefined;
const callable = (...args: Parameters<T>) => {
clearTimeout(h);
h = setTimeout(() => cb(...args), wait);
};
return callable;
}
// biome-ignore lint/suspicious/noExplicitAny: function can be any
export function debounceFrame<T extends (...args: any) => any>(
cb: T,
frameTime = 1,
): (...args: Parameters<T>) => void {
let h = 0;
let ft = frameTime;
const callable = (...args: Parameters<T>) => {
ft = frameTime;
cancelAnimationFrame(h);
const onCB = () => {
if (--ft <= 0) {
cb(...args);
} else {
h = requestAnimationFrame(onCB);
}
};
h = requestAnimationFrame(onCB);
};
return callable;
}

View File

@@ -0,0 +1,8 @@
export function derivative(f: (x: number) => number) {
const h = 0.001;
return (x: number): number => (f(x + h) - f(x - h)) / (2 * h);
}
export function getVelocity(f: (t: number) => number): (t: number) => number {
return derivative(f);
}

View File

@@ -0,0 +1,4 @@
export const eqSet: <T>(xs: Set<T>, ys: Set<T>) => boolean = (
xs,
ys,
): boolean => xs.size === ys.size && [...xs].every((x) => ys.has(x));

View File

@@ -0,0 +1,59 @@
const SAFE_FLAG_CHUNK_SIZE = Number.MAX_SAFE_INTEGER.toString(2).length;
export class FlagSets {
private flags: number[] = [];
private _size = 0;
get size(): number {
return this._size;
}
[Symbol.iterator]() {
let flag = 0;
let chunkIndex = 0;
let chunkOffset = 0;
return {
next: (): IteratorResult<number> => {
while (chunkIndex < this.flags.length) {
while (chunkOffset < SAFE_FLAG_CHUNK_SIZE) {
const flagBit = 1 << chunkOffset;
if (this.flags[chunkIndex] & flagBit) {
return { value: flag, done: false };
}
flag++;
chunkOffset++;
}
chunkIndex++;
chunkOffset = 0;
}
return { value: undefined, done: true };
},
};
}
add(flag: number): void {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
const flagBit = 1 << chunkOffset;
if (!(this.flags[chunkIndex] & flagBit)) {
this._size++;
this.flags[chunkIndex] |= flagBit;
}
}
delete(flag: number): void {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
const flagBit = 1 << chunkOffset;
if (!(this.flags[chunkIndex] & flagBit)) {
this._size--;
this.flags[chunkIndex] &= ~(1 << chunkOffset);
}
}
has(flag: number): boolean {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
return (this.flags[chunkIndex] & (1 << chunkOffset)) !== 0;
}
}

View File

@@ -0,0 +1,3 @@
export const isCJK = (char: string): boolean => {
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
};

View File

@@ -0,0 +1,283 @@
import { clampPositive } from "./clamp.ts";
import { type ChildNodeInfo, calcBalancedBreaks } from "./lyric-line-break.ts";
let sharedCanvasCtx: CanvasRenderingContext2D | null = null;
export function getMeasurementContext(): CanvasRenderingContext2D | null {
if (!sharedCanvasCtx) {
const canvas = document.createElement("canvas");
sharedCanvasCtx = canvas.getContext("2d");
}
return sharedCanvasCtx;
}
interface LineBalanceAdapter {
resetDOM(): void;
buildChildInfos(): { childInfos: ChildNodeInfo[]; fullText: string };
applyBreaks(breaks: number[], childInfos: ChildNodeInfo[]): void;
needsCalibration: boolean;
}
/**
* 用于平衡歌词行在换行后的各行长度
*/
export class LineBalancer {
private isBalancing = false;
private lastBalancedContainerWidth = -1;
constructor(private mainElement: HTMLDivElement) {}
public balanceLineBreaks(
isNonDynamic: boolean,
hasSplittedWords: boolean,
wordSegmenter: Intl.Segmenter,
): void {
if (this.isBalancing || !this.mainElement) return;
const computedStyle = getComputedStyle(this.mainElement);
const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
const containerWidth =
this.mainElement.clientWidth - paddingLeft - paddingRight;
if (containerWidth <= 0) return;
if (isNonDynamic) {
this.balanceNonDynamicLineBreaks(
containerWidth,
computedStyle,
wordSegmenter,
);
return;
}
if (!hasSplittedWords) return;
this.balanceDynamicLineBreaks(containerWidth, wordSegmenter);
}
public reset(): void {
this.lastBalancedContainerWidth = -1;
}
private executeLineBalance(
containerWidth: number,
adapter: LineBalanceAdapter,
wordSegmenter: Intl.Segmenter,
): void {
const existingBrs = this.mainElement.querySelectorAll("br");
if (
containerWidth === this.lastBalancedContainerWidth &&
existingBrs.length > 0
) {
return;
}
adapter.resetDOM();
// 临时设置 white-space: nowrap 以便测量单个歌词行的宽度
const prevWhiteSpace = this.mainElement.style.whiteSpace;
this.mainElement.style.whiteSpace = "nowrap";
// 临时移除父级的 transform 以便让 getBoundingClientRect 返回纯粹的布局尺寸
// 基类在 enableScale 时设置的 0.97 缩放倍率会影响到计算的宽度
const parentElement = this.mainElement.parentElement;
let prevTransform = "";
let transformChanged = false;
if (parentElement) {
prevTransform = parentElement.style.transform;
if (prevTransform && prevTransform !== "none") {
parentElement.style.transform = "none";
transformChanged = true;
}
}
let lockAcquired = false;
try {
const { childInfos, fullText } = adapter.buildChildInfos();
let layoutWidth = childInfos.reduce((sum, c) => sum + c.width, 0);
// 非动态歌词(用 Canvas 测量)才用 range 来缩放校准;动态歌词的强调 wrapper 有 1em 的 padding 和
// margin用 range 测会把首尾溢出的 1em 也加进来,极大地增大了行长度,视觉上就是非常激进地换行
if (adapter.needsCalibration) {
const range = document.createRange();
range.selectNodeContents(this.mainElement);
const visualWidth = range.getBoundingClientRect().width;
if (layoutWidth > 0 && visualWidth > 0) {
const scale = visualWidth / layoutWidth;
for (const info of childInfos) {
info.width *= scale;
}
}
layoutWidth = visualWidth;
}
const safeContainerWidth = Math.max(1, containerWidth);
if (layoutWidth <= safeContainerWidth) {
this.lastBalancedContainerWidth = containerWidth;
return;
}
const breaks = calcBalancedBreaks(
childInfos,
safeContainerWidth,
fullText,
wordSegmenter,
);
if (breaks.length === 0) {
this.lastBalancedContainerWidth = containerWidth;
return;
}
this.isBalancing = true;
lockAcquired = true;
adapter.applyBreaks(breaks, childInfos);
this.lastBalancedContainerWidth = containerWidth;
this.isBalancing = false;
} finally {
this.mainElement.style.whiteSpace = prevWhiteSpace;
if (transformChanged && parentElement) {
parentElement.style.transform = prevTransform;
}
if (lockAcquired) {
this.isBalancing = false;
}
}
}
private balanceDynamicLineBreaks(
containerWidth: number,
wordSegmenter: Intl.Segmenter,
): void {
const infoToNode: Node[] = [];
const dynamicAdapter: LineBalanceAdapter = {
resetDOM: () => {
this.mainElement.querySelectorAll("br").forEach((br) => {
br.remove();
});
},
buildChildInfos: () => {
infoToNode.length = 0;
const childNodes = Array.from(this.mainElement.childNodes);
const childInfos: ChildNodeInfo[] = [];
const range = document.createRange();
for (const node of childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? "";
if (text.length === 0) continue;
range.selectNodeContents(node);
childInfos.push({
width: range.getBoundingClientRect().width,
text,
isSpace: text.trim().length === 0,
});
infoToNode.push(node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const rect = el.getBoundingClientRect();
const elStyle = getComputedStyle(el);
const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0;
const marginRight = Number.parseFloat(elStyle.marginRight) || 0;
childInfos.push({
width: clampPositive(rect.width + marginLeft + marginRight),
text: el.textContent ?? "",
isSpace: false,
});
infoToNode.push(node);
}
}
return { childInfos, fullText: childInfos.map((c) => c.text).join("") };
},
applyBreaks: (breaks) => {
for (let i = breaks.length - 1; i >= 0; i--) {
const breakIndex = breaks[i];
if (breakIndex >= 0 && breakIndex < infoToNode.length) {
this.mainElement.insertBefore(
document.createElement("br"),
infoToNode[breakIndex],
);
}
}
},
needsCalibration: false,
};
this.executeLineBalance(containerWidth, dynamicAdapter, wordSegmenter);
}
private balanceNonDynamicLineBreaks(
containerWidth: number,
computedStyle: CSSStyleDeclaration,
wordSegmenter: Intl.Segmenter,
): void {
const fullText = this.mainElement.textContent ?? "";
if (fullText.trim().length === 0) return;
const nonDynamicAdapter: LineBalanceAdapter = {
resetDOM: () => {
this.mainElement.innerHTML = "";
this.mainElement.textContent = fullText;
},
buildChildInfos: () => {
const ctx = getMeasurementContext();
if (!ctx) {
console.debug(
"Canvas 2D context is not supported, skipping line balancing",
);
return { childInfos: [], fullText };
}
ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
if ("letterSpacing" in ctx) {
ctx.letterSpacing =
computedStyle.letterSpacing !== "normal"
? computedStyle.letterSpacing
: "0px";
}
if ("wordSpacing" in ctx) {
ctx.wordSpacing =
computedStyle.wordSpacing !== "normal"
? computedStyle.wordSpacing
: "0px";
}
const childInfos: ChildNodeInfo[] = [];
for (const { segment } of wordSegmenter.segment(fullText)) {
childInfos.push({
width: ctx.measureText(segment).width,
text: segment,
isSpace: segment.trim().length === 0,
});
}
return { childInfos, fullText };
},
applyBreaks: (breaks, childInfos) => {
this.mainElement.innerHTML = "";
const breakSet = new Set(breaks);
const fragment = document.createDocumentFragment();
for (let i = 0; i < childInfos.length; i++) {
if (breakSet.has(i)) {
fragment.appendChild(document.createElement("br"));
}
fragment.appendChild(document.createTextNode(childInfos[i].text));
}
this.mainElement.appendChild(fragment);
},
needsCalibration: true,
};
this.executeLineBalance(containerWidth, nonDynamicAdapter, wordSegmenter);
}
}

View File

@@ -0,0 +1,120 @@
type seconds = number;
export interface LinearParams {
duration: number; // Duration of the transition in seconds
}
export class Linear {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<LinearParams> = {};
private currentSolver: (t: seconds) => number;
private startTime = 0;
private queueParams:
| (Partial<LinearParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
}
private resetSolver() {
this.currentTime = 0;
this.startTime = 0;
this.currentSolver = solveLinear(
this.currentPosition,
this.targetPosition,
this.params,
);
}
arrived(): boolean {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);
}
setPosition(targetPosition: number): void {
this.targetPosition = targetPosition;
this.currentPosition = targetPosition;
this.currentSolver = () => this.targetPosition;
}
update(delta = 0): void {
this.currentTime += delta;
this.currentPosition = this.currentSolver(
this.currentTime - this.startTime,
);
if (this.queueParams) {
this.queueParams.time -= delta;
if (this.queueParams.time <= 0) {
this.updateParams({
...this.queueParams,
});
}
}
if (this.queuePosition) {
this.queuePosition.time -= delta;
if (this.queuePosition.time <= 0) {
this.setTargetPosition(this.queuePosition.position);
}
}
if (this.arrived()) {
this.setPosition(this.targetPosition);
}
}
updateParams(params: Partial<LinearParams>, delay = 0): void {
if (delay > 0) {
this.queueParams = {
...(this.queuePosition ?? {}),
...params,
time: delay,
};
} else {
this.queuePosition = undefined;
this.params = {
...this.params,
...params,
};
this.resetSolver();
}
}
setTargetPosition(targetPosition: number, delay = 0): void {
if (delay > 0) {
this.queuePosition = {
...(this.queuePosition ?? {}),
position: targetPosition,
time: delay,
};
} else {
this.queuePosition = undefined;
this.targetPosition = targetPosition;
this.resetSolver();
}
}
getCurrentPosition(): number {
return this.currentPosition;
}
}
function solveLinear(
from: number,
to: number,
params?: Partial<LinearParams>,
): (t: seconds) => number {
const duration = params?.duration ?? 1;
const delta = to - from;
return (t: seconds) => {
if (t < 0) return from;
if (t > duration) return to;
return from + (delta * t) / duration;
};
}

View File

@@ -0,0 +1,145 @@
import { isCJK } from "./is-cjk.ts";
export interface ChildNodeInfo {
width: number;
text: string;
isSpace: boolean;
}
/**
* 单个词超过容器宽度时的大惩罚倍数
*/
const OVERFLOW_PENALTY_MULTIPLIER = 1000;
/**
* 截断 CJK 词组边界的惩罚比例
*
* 相对于容器宽度
*/
const CJK_BREAK_PENALTY_RATIO = 0.15;
/**
* 截断普通文本(非空格、非 CJK 词界)的惩罚比例
*/
const NORMAL_BREAK_PENALTY_RATIO = 0.5;
/**
* 在空格处断开的奖励比例
*/
const SPACE_BREAK_REWARD_RATIO = 0.4;
/**
* 在标点符号处断开的奖励比例
*
* 比空格更高以便优先一点在标点处换行
*/
const PUNCTUATION_BREAK_REWARD_RATIO = 0.6;
const PUNCTUATION_REGEX = /[,.;:!?,。;:!?、)】》」』’”)[\]}>~…]$/;
/**
* 计算平均行长度的断点位置
* @param children 子节点信息
* @param containerWidth 容器可用内容宽度
* @param fullText 完整的行文本
* @param segmenter 预创建的 Intl.Segmenter 分词器
* @returns 需要在其前面插入 `<br>` 的子节点索引数组,升序
*/
export function calcBalancedBreaks(
children: ChildNodeInfo[],
containerWidth: number,
fullText: string,
segmenter: Intl.Segmenter,
): number[] {
const n = children.length;
if (n === 0 || containerWidth <= 0) {
return [];
}
// 计算哪里是 CJK 词组的边界
const cjkBoundaries = new Set<number>();
let offset = 0;
for (const { segment, isWordLike } of segmenter.segment(fullText)) {
if (offset > 0 && isWordLike) {
if ([...segment].some((ch) => isCJK(ch))) {
cjkBoundaries.add(offset);
}
}
offset += segment.length;
}
// 计算前缀宽和字符偏移量用于快速查询
const charOffsets = new Int32Array(n + 1);
const prefixWidth = new Float64Array(n + 1);
for (let i = 0; i < n; i++) {
charOffsets[i + 1] = charOffsets[i] + children[i].text.length;
prefixWidth[i + 1] = prefixWidth[i] + children[i].width;
}
if (prefixWidth[n] <= containerWidth) {
return [];
}
/**
* dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价
*/
const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY);
const nextBreak = new Int32Array(n + 1).fill(-1);
dp[n] = 0;
const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2;
const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2;
for (let i = n - 1; i >= 0; i--) {
for (let j = i + 1; j <= n; j++) {
const w = prefixWidth[j] - prefixWidth[i];
let lineCost = 0;
if (w > containerWidth) {
if (j === i + 1) {
// 单个无法分割的词自身就比容器宽,被迫独立成行,给大惩罚
lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER;
} else {
// 行内包含多个超过物理宽度的词就跳过
continue;
}
} else {
// 迫使所有行的长度方差最小化
lineCost = (containerWidth - w) ** 2;
}
let breakPenalty = 0;
if (j < n) {
const prevChild = children[j - 1];
// 优先级:标点 > 空格 > CJK 词界 > 普通文本
if (PUNCTUATION_REGEX.test(prevChild.text)) {
breakPenalty = -(
(containerWidth * PUNCTUATION_BREAK_REWARD_RATIO) **
2
);
} else if (prevChild.isSpace) {
breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2);
} else if (cjkBoundaries.has(charOffsets[j])) {
breakPenalty = PENALTY_CJK;
} else {
breakPenalty = PENALTY_NORMAL;
}
}
const totalCost = lineCost + breakPenalty + dp[j];
if (totalCost < dp[i]) {
dp[i] = totalCost;
nextBreak[i] = j;
}
}
}
const breaks: number[] = [];
let curr = 0;
while (curr < n) {
curr = nextBreak[curr];
if (curr > 0 && curr < n) {
breaks.push(curr);
}
}
return breaks;
}

View File

@@ -0,0 +1,112 @@
import type { LyricWord } from "../interfaces.ts";
import { isCJK } from "./is-cjk.ts";
const SPLIT_WHITESPACE_RE = /(\s+)/;
const WHITESPACE_RE = /\s/g;
/**
* 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
*
* 例如输入:`["Life", " ", "is", " a", " su", "gar so", "sweet"]`
*
* 应该返回:`["Life", " ", "is", " a", [" su", "gar"], "so", "sweet"]`
* @param words 输入的单词数组
* @returns 重新分组后的单词数组
*/
export function chunkAndSplitLyricWords(
words: LyricWord[],
): (LyricWord | LyricWord[])[] {
const result: (LyricWord | LyricWord[])[] = [];
let currentGroup: LyricWord[] = [];
const flushGroup = () => {
if (currentGroup.length > 0) {
result.push(
currentGroup.length === 1 ? currentGroup[0] : [...currentGroup],
);
currentGroup = [];
}
};
const processAtom = (atom: LyricWord) => {
const isSpace = atom.word.trim().length === 0;
const hasRuby = (atom.ruby?.length ?? 0) > 0;
const isCJKChar = isCJK(atom.word);
const isMergeable = !isSpace && !hasRuby && !isCJKChar;
if (isMergeable) {
currentGroup.push(atom);
} else {
flushGroup();
result.push(atom);
}
};
for (const w of words) {
const content = w.word.trim();
const isSpace = content.length === 0;
const romanWord = w.romanWord ?? "";
const obscene = w.obscene ?? false;
const hasRuby = (w.ruby?.length ?? 0) > 0;
if (isSpace || hasRuby) {
processAtom({ ...w });
continue;
}
const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0);
const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1;
const timeSpan = w.endTime - w.startTime;
const timePerUnit = timeSpan / totalLength;
let currentOffset = 0;
for (const part of parts) {
if (!part.trim()) {
const startTime = w.startTime + currentOffset * timePerUnit;
processAtom({
word: part,
romanWord: "",
startTime: startTime,
endTime: startTime,
obscene: obscene,
});
continue;
}
if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) {
const chars = part.split("");
for (const char of chars) {
const startTime = w.startTime + currentOffset * timePerUnit;
processAtom({
word: char,
romanWord: "",
startTime: startTime,
endTime: startTime + timePerUnit,
obscene: obscene,
});
currentOffset += 1;
}
} else {
const partRealLen = part.length;
const startTime = w.startTime + currentOffset * timePerUnit;
const duration = partRealLen * timePerUnit;
processAtom({
word: part,
romanWord: romanWord,
startTime: startTime,
endTime: startTime + duration,
obscene: obscene,
});
currentOffset += partRealLen;
}
}
}
flushGroup();
return result;
}

View File

@@ -0,0 +1,47 @@
// biome-ignore format: matrix
export type Matrix4 = [
number, number, number, number,
number, number, number, number,
number, number, number, number,
number, number, number, number,
];
export function createMatrix4(): Matrix4 {
// biome-ignore format: matrix
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
export function scaleMatrix4(
m: Matrix4,
scale = 1,
origin = { x: 0, y: 0 },
): Matrix4 {
const [ox, oy] = [origin.x, origin.y];
// biome-ignore format: matrix
return [
m[0] * scale , m[1] * scale , m[2] * scale , m[3],
m[4] * scale , m[5] * scale , m[6] * scale , m[7],
m[8] * scale , m[9] * scale , m[10] * scale, m[11],
m[12] - ox * scale + ox, m[13] - oy * scale + oy, m[14] , m[15]
];
}
export function translateMatrix4(m: Matrix4, x = 0, y = 0, z = 0): Matrix4 {
// biome-ignore format: matrix
return [
m[0] , m[1] , m[2] , m[3] ,
m[4] , m[5] , m[6] , m[7] ,
m[8] , m[9] , m[10] , m[11],
m[12] + x, m[13] + y, m[14] + z, m[15]
];
}
export function matrix4ToCSS(m: Matrix4, fractionDigits = 4): string {
const format = (n: number, _: number) => n.toFixed(fractionDigits);
return `matrix3d(${m.map(format).join(", ")})`;
}

View File

@@ -0,0 +1,251 @@
import type { LyricLine, OptimizeLyricOptions } from "../interfaces.ts";
const DEFAULT_OPTIMIZE_OPTIONS: OptimizeLyricOptions = {
normalizeSpaces: true,
resetLineTimestamps: true,
convertExcessiveBackgroundLines: true,
syncMainAndBackgroundLines: true,
cleanUnintentionalOverlaps: true,
tryAdvanceStartTime: true,
};
/**
* 规范化歌词中的空格,将多个连续空格替换为一个空格
*/
function normalizeSpaces(lines: LyricLine[]) {
for (const line of lines) {
for (const word of line.words) {
word.word = word.word.replace(/\s+/g, " ");
}
}
}
/**
* 将行级时间戳强行设为字级时间戳
*/
function resetLineTimestamps(lines: LyricLine[]) {
for (const line of lines) {
// 主要是给 TTML 解析器打补丁其解析逐行歌词时获得的词时间戳均为0
// 如果只有一个词且该词的起止时间均为0且行时间戳不全为0则将行时间戳同步给词时间戳
if (
line.words.length === 1 &&
line.words[0].startTime === 0 &&
line.words[0].endTime === 0 &&
(line.startTime !== 0 || line.endTime !== 0)
) {
line.words[0].startTime = line.startTime;
line.words[0].endTime = line.endTime;
} else if (line.words.length > 0) {
const firstWord = line.words[0];
const lastWord = line.words[line.words.length - 1];
line.startTime = firstWord.startTime;
line.endTime = lastWord.endTime;
}
}
}
/**
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
*/
function convertExcessiveBackgroundLines(lines: LyricLine[]) {
let consecutiveBgCount = 0;
for (const line of lines) {
if (line.isBG) {
consecutiveBgCount++;
if (consecutiveBgCount > 1) {
line.isBG = false;
}
} else {
consecutiveBgCount = 0;
}
}
}
/**
* 同步主歌词与背景人声的时间
*
* 取两者中最早的开始时间和最晚的结束时间,应用给双方
*/
function syncMainAndBackgroundLines(lines: LyricLine[]) {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.isBG) continue;
const nextLine = lines[i + 1];
if (nextLine?.isBG) {
const allWords = [...line.words, ...nextLine.words].filter(
(w) => w.word.trim().length > 0,
);
if (allWords.length > 0) {
const minStart = Math.min(...allWords.map((w) => w.startTime));
const maxEnd = Math.max(...allWords.map((w) => w.endTime));
const finalStart = Math.min(
minStart,
line.startTime,
nextLine.startTime,
);
const finalEnd = Math.max(maxEnd, line.endTime, nextLine.endTime);
line.startTime = finalStart;
line.endTime = finalEnd;
nextLine.startTime = finalStart;
nextLine.endTime = finalEnd;
}
}
}
}
/**
* 清洗非刻意的重叠
*
* 如果重叠大于100ms 且 重叠超过下一行时长的10%,则视为刻意重叠,否则将结束时间设为下一行的开始时间
*/
function cleanUnintentionalOverlaps(lines: LyricLine[]) {
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.isBG) continue;
let nextMainIndex = i + 1;
while (nextMainIndex < lines.length && lines[nextMainIndex].isBG) {
nextMainIndex++;
}
if (nextMainIndex < lines.length) {
const nextLine = lines[nextMainIndex];
const overlap = line.endTime - nextLine.startTime;
if (overlap > 0) {
const nextDuration = nextLine.endTime - nextLine.startTime;
const percentageThreshold = nextDuration * 0.1;
// 重叠大于100ms 且 重叠超过下一行时长的10%
const isIntentionalOverlap =
overlap > 100 && overlap > percentageThreshold;
if (!isIntentionalOverlap) {
line.endTime = nextLine.startTime;
const attachedBgLine = lines[i + 1];
if (attachedBgLine?.isBG) {
attachedBgLine.endTime = nextLine.startTime;
}
}
}
}
}
}
/**
* 尝试让歌词提前最多 600ms 开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30%
*/
function tryAdvanceStartTime(lines: LyricLine[]) {
const defaultAdvanceAmount = 600;
const fallbackAdvanceAmount = 400;
const fallbackAdvanceRatio = 0.3;
let prevLineStartTime = 0;
let prevLineEndTime = 0;
let prevMainGroupStartTime = 0;
let prevMainGroupEndTime = 0;
let hasPrevLine = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.isBG) continue;
const originalStartTime = line.startTime;
const originalEndTime = line.endTime;
let targetAdvanceAmount = 0;
let safeBoundary = 0;
if (hasPrevLine) {
const originallyHadGap = originalStartTime >= prevLineEndTime;
if (originallyHadGap) {
targetAdvanceAmount = defaultAdvanceAmount;
safeBoundary = prevMainGroupEndTime;
} else {
targetAdvanceAmount = fallbackAdvanceAmount;
const prevDuration = prevLineEndTime - prevLineStartTime;
safeBoundary = prevLineStartTime + prevDuration * fallbackAdvanceRatio;
}
} else {
targetAdvanceAmount = defaultAdvanceAmount;
safeBoundary = 0;
}
const targetTime = line.startTime - targetAdvanceAmount;
const newStartTime = Math.max(safeBoundary, targetTime);
if (newStartTime < line.startTime) {
line.startTime = newStartTime;
}
const nextLine = lines[i + 1];
if (nextLine?.isBG) {
nextLine.startTime = line.startTime;
}
if (hasPrevLine) {
const overlapsPrevGroup =
originalStartTime < prevMainGroupEndTime &&
originalEndTime > prevMainGroupStartTime;
if (overlapsPrevGroup) {
prevMainGroupStartTime = Math.min(
prevMainGroupStartTime,
originalStartTime,
);
prevMainGroupEndTime = Math.max(prevMainGroupEndTime, originalEndTime);
} else {
prevMainGroupStartTime = originalStartTime;
prevMainGroupEndTime = originalEndTime;
}
} else {
prevMainGroupStartTime = originalStartTime;
prevMainGroupEndTime = originalEndTime;
}
prevLineStartTime = originalStartTime;
prevLineEndTime = originalEndTime;
hasPrevLine = true;
}
}
/**
* 优化歌词行的展示效果
*
* 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组
* @param lines 歌词行数组
* @param options 优化的可选配置,默认全部开启
*/
export function optimizeLyricLines(
lines: LyricLine[],
options?: OptimizeLyricOptions,
): void {
const config = { ...DEFAULT_OPTIMIZE_OPTIONS, ...options };
if (config.normalizeSpaces) {
normalizeSpaces(lines);
}
if (config.resetLineTimestamps) {
resetLineTimestamps(lines);
}
if (config.convertExcessiveBackgroundLines) {
convertExcessiveBackgroundLines(lines);
}
if (config.syncMainAndBackgroundLines) {
syncMainAndBackgroundLines(lines);
}
if (config.cleanUnintentionalOverlaps) {
cleanUnintentionalOverlaps(lines);
}
if (config.tryAdvanceStartTime) {
tryAdvanceStartTime(lines);
}
}

View File

@@ -0,0 +1,79 @@
export function loadImage(imageUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => resolve(img);
img.onerror = reject;
img.src = imageUrl;
img.crossOrigin = "anonymous";
img.loading = "eager";
});
}
export function loadVideo(videoUrl: string): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
let playing = false;
let timeupdate = false;
let rejected = false;
video.addEventListener(
"playing",
() => {
playing = true;
checkReady();
},
true,
);
video.addEventListener(
"timeupdate",
() => {
timeupdate = true;
checkReady();
},
true,
);
video.addEventListener(
"error",
(err) => {
rejected = true;
reject(err);
},
true,
);
function checkReady() {
if (playing && timeupdate && !rejected) {
resolve(video);
}
}
video.src = videoUrl;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.autoplay = true;
video.loop = true;
video.muted = true;
video.play();
});
}
export function loadResourceFromUrl(
url: string,
isVideo = false,
): Promise<HTMLImageElement | HTMLVideoElement> {
return isVideo ? loadVideo(url) : loadImage(url);
}
export function loadResourceFromElement(
element: HTMLImageElement | HTMLVideoElement,
): Promise<HTMLImageElement | HTMLVideoElement> {
return new Promise((resolve, reject) => {
if (
element instanceof HTMLImageElement
? element.complete
: element.readyState >= 3
) {
resolve(element);
} else {
element.onload = () => resolve(element);
element.onerror = reject;
}
});
}

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview
* @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js
*/
interface Task<T> {
task: () => T;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
// biome-ignore lint/suspicious/noExplicitAny: util functions
const measureTasks: Task<any>[] = [];
// biome-ignore lint/suspicious/noExplicitAny: util functions
const mutateTasks: Task<any>[] = [];
let scheduled = false;
function onFlush() {
let tmp = mutateTasks.shift();
while (tmp) {
try {
tmp.resolve(tmp.task());
} catch (error) {
tmp.reject(error);
}
tmp = mutateTasks.shift();
}
tmp = measureTasks.shift();
while (tmp) {
try {
tmp.resolve(tmp.task());
} catch (error) {
tmp.reject(error);
}
tmp = measureTasks.shift();
}
scheduled = false;
}
function scheduleFlush() {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(onFlush);
}
}
export function measure<T>(callback: () => T): Promise<T> {
const task: Task<T> = {
task: callback,
resolve: () => {},
reject: () => {},
};
const promise = new Promise<T>((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
measureTasks.push(task);
scheduleFlush();
return promise;
}
export function mutate(callback: () => void): Promise<unknown> {
const task: Task<void> = {
task: callback,
resolve: () => {},
reject: () => {},
};
const promise = new Promise((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
mutateTasks.push(task);
scheduleFlush();
return promise;
}

View File

@@ -0,0 +1,158 @@
import { getVelocity } from "./derivative.ts";
/** MIT License github.com/pushkine/ */
export interface SpringParams {
mass: number; // = 1.0
damping: number; // = 10.0
stiffness: number; // = 100.0
soft: boolean; // = false
}
type seconds = number;
export class Spring {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number;
private getV: (t: seconds) => number;
private getV2: (t: seconds) => number;
private queueParams:
| (Partial<SpringParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
private resetSolver() {
const curV = this.getV(this.currentTime);
this.currentTime = 0;
this.currentSolver = solveSpring(
this.currentPosition,
curV,
this.targetPosition,
0,
this.params,
);
this.getV = getVelocity(this.currentSolver);
this.getV2 = getVelocity(this.getV);
}
arrived(): boolean {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.getV(this.currentTime) < 0.01 &&
this.getV2(this.currentTime) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);
}
setPosition(targetPosition: number): void {
this.targetPosition = targetPosition;
this.currentPosition = targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
update(delta = 0): void {
this.currentTime += delta;
this.currentPosition = this.currentSolver(this.currentTime);
if (this.queueParams) {
this.queueParams.time -= delta;
if (this.queueParams.time <= 0) {
this.updateParams({
...this.queueParams,
});
}
}
if (this.queuePosition) {
this.queuePosition.time -= delta;
if (this.queuePosition.time <= 0) {
this.setTargetPosition(this.queuePosition.position);
}
}
if (this.arrived()) {
this.setPosition(this.targetPosition);
}
}
updateParams(params: Partial<SpringParams>, delay = 0): void {
if (delay > 0) {
this.queueParams = {
...(this.queuePosition ?? {}),
...params,
time: delay,
};
} else {
this.queuePosition = undefined;
this.params = {
...this.params,
...params,
};
this.resetSolver();
}
}
setTargetPosition(targetPosition: number, delay = 0): void {
if (delay > 0) {
this.queuePosition = {
...(this.queuePosition ?? {}),
position: targetPosition,
time: delay,
};
} else {
this.queuePosition = undefined;
this.targetPosition = targetPosition;
this.resetSolver();
}
}
getCurrentPosition(): number {
return this.currentPosition;
}
}
function solveSpring(
from: number,
velocity: number,
to: number,
delay: seconds = 0,
params?: Partial<SpringParams>,
): (t: seconds) => number {
const soft = params?.soft ?? false;
const stiffness = params?.stiffness ?? 100;
const damping = params?.damping ?? 10;
const mass = params?.mass ?? 1;
const delta = to - from;
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
const angular_frequency = -Math.sqrt(stiffness / mass);
const leftover = -angular_frequency * delta - velocity;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
};
}
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
const leftover =
(damping * delta - 2.0 * mass * velocity) / damping_frequency;
const dfm = (0.5 * damping_frequency) / mass;
const dm = -(0.5 * damping) / mass;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return (
to -
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
Math.E ** (t * dm)
);
};
}

View File

@@ -0,0 +1,139 @@
import bezier from "bezier-easing";
import type { Disposable } from "../interfaces.ts";
import { getVelocity } from "./derivative";
// export interface SpringParams {
// mass: number; // = 1.0
// damping: number; // = 10.0
// stiffness: number; // = 100.0
// }
type seconds = number;
type CSSStyleKeys = {
[Style in keyof CSSStyleDeclaration]: Style extends string
? CSSStyleDeclaration[Style] extends string
? Style
: never
: never;
}[keyof CSSStyleDeclaration];
/**
* 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本
*/
export class WebAnimationSpring extends EventTarget implements Disposable {
private currentAnimation: Animation;
private targetPosition = 0;
private isStatic = true;
// private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number = () => this.targetPosition;
private getV: (t: seconds) => number = () => 0;
constructor(
private element: HTMLElement,
private styleName: CSSStyleKeys,
private valueGenerator: (value: number) => string,
private currentPosition = 0,
) {
super();
this.targetPosition = currentPosition;
this.currentAnimation = element.animate(
[
{
[styleName]: valueGenerator(currentPosition),
},
],
{
duration: 1000,
fill: "both",
composite: "add",
},
);
}
makeStatic(): void {
this.getV = () => 0;
this.currentSolver = () => this.targetPosition;
this.currentAnimation.cancel();
this.currentAnimation = this.element.animate(
[
{
[this.styleName]: this.valueGenerator(this.targetPosition),
},
{
[this.styleName]: this.valueGenerator(this.targetPosition),
},
],
{
duration: Number.POSITIVE_INFINITY,
id: `wa-spring-static-${this.styleName}`,
fill: "both",
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
composite: "add",
},
);
this.currentAnimation.pause();
}
setTargetPosition(targetPosition: number): void {
this.targetPosition = targetPosition;
this.onStepFinished();
}
getCurrentPosition(): number {
if (this.isStatic || !this.currentAnimation.effect)
return this.currentPosition;
const timing = this.currentAnimation.effect?.getComputedTiming();
return this.currentSolver(timing.progress ?? 1);
}
getCurrentVelocity(): number {
if (this.isStatic || !this.currentAnimation.effect) return 0;
const timing = this.currentAnimation.effect?.getComputedTiming();
return this.getV(timing.progress ?? 1);
}
private onStepFinished() {
const currentPosition = this.getCurrentPosition();
if (Math.abs(this.targetPosition - currentPosition) < 0.0001) {
this.makeStatic();
this.dispatchEvent(new Event("finished"));
return;
}
this.currentSolver = bezier(0.5, 0, 0.5, 1);
this.getV = getVelocity(this.currentSolver);
this.currentAnimation.cancel();
const delta = (this.targetPosition - currentPosition) * 1.05;
this.currentPosition += delta;
this.currentAnimation = this.element.animate(
[
{
[this.styleName]: this.valueGenerator(currentPosition),
},
{
[this.styleName]: this.valueGenerator(this.currentPosition),
},
],
{
duration: 250,
id: `wa-spring-dynamic-${this.styleName}`,
fill: "forwards",
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
composite: "add",
},
);
this.currentAnimation.onfinish = () => this.onStepFinished();
}
stop(): void {
if (this.currentAnimation) {
this.currentAnimation.cancel();
}
}
dispose(): void {
this.stop();
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "tsdown";
import { baseConfig } from "../../tsdown.base.ts";
const rawQueryPlugin = {
name: "raw-query",
resolveId(id: string, importer: string | undefined) {
if (id.endsWith("?raw")) {
const rawPath = id.slice(0, -4);
const base = importer ? `file://${importer}` : `file://${process.cwd()}/`;
const resolved = new URL(rawPath, base).pathname.replace(
/^\/([A-Za-z]:)/,
"$1",
);
return `\0raw:${resolved}`;
}
},
load(id: string) {
if (id.startsWith("\0raw:")) {
const file = id.slice(5);
const content = readFileSync(file, "utf-8");
return `export default ${JSON.stringify(content)}`;
}
},
};
export default defineConfig({
...baseConfig,
entry: { "amll-core": "./src/index.ts" },
plugins: [rawQueryPlugin],
define: {
"import.meta.env.DEV": "process.env.NODE_ENV !== 'production'",
},
});