初次提交
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
dist/
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
# AMLL Core
|
||||
|
||||
[English](./README.md) / 简体中文
|
||||
|
||||
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
|
||||
AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件。
|
||||
|
||||
此处的东西都是 UI 框架无关的,所以可以间接在各种动态页面框架下引用。
|
||||
|
||||
或者如果你需要使用组件绑定的话,这里有 [React 绑定版本](../react/README.md) 和 [Vue 绑定版本](../vue/README.md)
|
||||
|
||||
## 安装
|
||||
|
||||
安装使用的依赖(如果以下列出的依赖包没有安装的话需要自行安装):
|
||||
```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)
|
||||
|
||||
一个测试用途的程序可以在 [./src/test.ts](./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) 中的代码。
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
# 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!
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
[](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)
|
||||
|
||||
## 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 [./src/test.ts](./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).
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="module" src="./src/canvas-test.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AMLL Core Test</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="src/test.ts" type="module" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
font-family:
|
||||
"SF Pro Display", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #222;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#player {
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player"></div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AMLL Core Test</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="src/mg-test.ts" type="module" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
font-family:
|
||||
"SF Pro Display", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #222;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bg {
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#result {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 50em;
|
||||
height: 20em;
|
||||
border: solid 1px #3337;
|
||||
background-color: #1117;
|
||||
color: white;
|
||||
font-family:
|
||||
"Fira Code", "SF Pro Display", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
#result::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dragger {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: solid 3px #8888;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: #f44;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<canvas id="bg"></canvas>
|
||||
<textarea
|
||||
id="result"
|
||||
placeholder="控制点代码将会在这里显示"
|
||||
readonly
|
||||
></textarea>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@applemusic-like-lyrics/core",
|
||||
"version": "0.2.1",
|
||||
"description": "AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件",
|
||||
"repository": {
|
||||
"url": "https://github.com/Steve-xmh/applemusic-like-lyrics.git",
|
||||
"directory": "packages/core",
|
||||
"type": "git"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"library"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.ts",
|
||||
"dist/**/*.cjs",
|
||||
"dist/**/*.css",
|
||||
"dist/**/*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"build:docs": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build",
|
||||
"fmt": "biome format --write ./src",
|
||||
"dev": "vite dev",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/amll-core.cjs",
|
||||
"module": "./dist/amll-core.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/amll-core.js",
|
||||
"require": "./dist/amll-core.cjs"
|
||||
},
|
||||
"./style.css": {
|
||||
"import": "./dist/amll-core.css",
|
||||
"require": "./dist/amll-core.css"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applemusic-like-lyrics/lyric": "^0.3.0",
|
||||
"@biomejs/biome": "catalog:",
|
||||
"@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": "catalog:",
|
||||
"typedoc-plugin-markdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:",
|
||||
"vite-plugin-glsl": "^1.5.5",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pixi/app": "*",
|
||||
"@pixi/core": "*",
|
||||
"@pixi/display": "*",
|
||||
"@pixi/filter-blur": "*",
|
||||
"@pixi/filter-bulge-pinch": "*",
|
||||
"@pixi/filter-color-matrix": "*",
|
||||
"@pixi/sprite": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ungap/structured-clone": "catalog:",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"deep-freeze": "^0.0.1",
|
||||
"gl-matrix": "4.0.0-beta.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
*.lrc
|
||||
*.yrc
|
||||
*.qrc
|
||||
*.lys
|
||||
*.ttml
|
||||
*.wav
|
||||
*.mp3
|
||||
*.flac
|
||||
*.jpg
|
||||
*.png
|
||||
*.webp
|
||||
!empty.png
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export abstract class BaseRenderer extends AbstractBaseRenderer {
|
||||
private observer: ResizeObserver;
|
||||
protected flowSpeed = 4;
|
||||
protected currerntRenderScale = 0.75;
|
||||
constructor(protected canvas: HTMLCanvasElement) {
|
||||
super();
|
||||
this.observer = new ResizeObserver(() => {
|
||||
const width = Math.max(
|
||||
1,
|
||||
canvas.clientWidth * window.devicePixelRatio * this.currerntRenderScale,
|
||||
);
|
||||
const height = Math.max(
|
||||
1,
|
||||
canvas.clientHeight *
|
||||
window.devicePixelRatio *
|
||||
this.currerntRenderScale,
|
||||
);
|
||||
this.onResize(width, height);
|
||||
});
|
||||
this.observer.observe(canvas);
|
||||
}
|
||||
setRenderScale(scale: number) {
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 修改背景的流动速度,数字越大越快,默认为 4
|
||||
* @param speed 背景的流动速度,默认为 4
|
||||
*/
|
||||
setFlowSpeed(speed: number) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
export function blurImage(
|
||||
imageData: ImageData,
|
||||
radius: number,
|
||||
quality: number,
|
||||
) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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() {
|
||||
return this.element;
|
||||
}
|
||||
dispose() {
|
||||
this.renderer.dispose();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 实验性的随机控制点生成函数算法
|
||||
* 目的是取代原先大量的预设控制点代码
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlPointConf,
|
||||
type ControlPointPreset,
|
||||
p,
|
||||
preset,
|
||||
} from "./cp-presets.ts";
|
||||
|
||||
const randomRange = (min: number, max: number): number =>
|
||||
Math.random() * (max - min) + min;
|
||||
|
||||
function clamp(x: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(x, min), max);
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
|
||||
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 = Math.min(1, Math.max(f + factorIterationModifier, 0));
|
||||
}
|
||||
|
||||
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 = randomRange(0.4, 0.6), // = 0.2,
|
||||
normalOffset = randomRange(0.3, 0.6), // = 0.3,
|
||||
blendFactor = 0.8,
|
||||
smoothIters = Math.floor(randomRange(3, 5)), // = 3,
|
||||
smoothFactor = randomRange(0.2, 0.3), // = 0.3,
|
||||
smoothModifier = 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);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @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 = [
|
||||
// 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),
|
||||
]),
|
||||
] as const;
|
||||
+1161
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
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";
|
||||
|
||||
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 = Math.max(0, 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) {
|
||||
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) {
|
||||
this.staticMode = enable;
|
||||
this.app.ticker.start();
|
||||
}
|
||||
|
||||
override setFPS(fps: number) {
|
||||
this.app.ticker.maxFPS = fps;
|
||||
}
|
||||
|
||||
override pause() {
|
||||
this.app.ticker.stop();
|
||||
this.app.render();
|
||||
}
|
||||
|
||||
override resume() {
|
||||
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() {
|
||||
super.dispose();
|
||||
this.app.ticker.remove(this.onTick);
|
||||
this.app.destroy(true);
|
||||
}
|
||||
|
||||
override getElement(): HTMLElement {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type TextLayoutConfig,
|
||||
layoutWord,
|
||||
} from "./lyric-player/canvas/text-layout";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 600 * devicePixelRatio;
|
||||
canvas.height = 400 * devicePixelRatio;
|
||||
canvas.style.border = "1px solid black";
|
||||
canvas.style.width = "600px";
|
||||
canvas.style.height = "400px";
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
const textInput = document.createElement("input");
|
||||
const fontSizeInput = document.createElement("input");
|
||||
const config: TextLayoutConfig = Object.seal({
|
||||
fontSize: 60,
|
||||
maxWidth: 400,
|
||||
lineHeight: 1.2,
|
||||
uniformSpace: true,
|
||||
});
|
||||
function redraw() {
|
||||
ctx.resetTransform();
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
config.fontSize = fontSizeInput.valueAsNumber;
|
||||
|
||||
ctx.font = `${config.fontSize}px sans-serif`;
|
||||
ctx.fillStyle = "black";
|
||||
ctx.strokeStyle = "red";
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
ctx.translate(100, 100);
|
||||
|
||||
ctx.strokeRect(0, -config.fontSize - 5, config.maxWidth, 1);
|
||||
const start = performance.now();
|
||||
for (const wordLayout of layoutWord(ctx, textInput.value, config)) {
|
||||
ctx.filter = "none";
|
||||
ctx.strokeRect(
|
||||
wordLayout.x,
|
||||
wordLayout.lineIndex * (config.fontSize * config.lineHeight) -
|
||||
config.fontSize,
|
||||
wordLayout.width,
|
||||
config.fontSize,
|
||||
);
|
||||
if (wordLayout.lineIndex > 0)
|
||||
ctx.filter = `blur(${wordLayout.lineIndex}px)`;
|
||||
ctx.fillText(
|
||||
wordLayout.text,
|
||||
wordLayout.x,
|
||||
wordLayout.lineIndex * (config.fontSize * config.lineHeight),
|
||||
);
|
||||
}
|
||||
console.log(performance.now() - start);
|
||||
}
|
||||
|
||||
textInput.type = "text";
|
||||
textInput.placeholder = "输入文字";
|
||||
textInput.style.width = "100%";
|
||||
textInput.style.padding = "5px";
|
||||
textInput.style.boxSizing = "border-box";
|
||||
textInput.value =
|
||||
"Hello, World! AWAY! 你好世界!Lyric Test Test 你好世界!你好世界!タンタン \\(°▽ ° )";
|
||||
textInput.addEventListener("input", () => {
|
||||
redraw();
|
||||
});
|
||||
document.body.appendChild(textInput);
|
||||
|
||||
fontSizeInput.type = "number";
|
||||
fontSizeInput.placeholder = "字体大小";
|
||||
fontSizeInput.style.width = "100%";
|
||||
fontSizeInput.style.padding = "5px";
|
||||
fontSizeInput.style.boxSizing = "border-box";
|
||||
fontSizeInput.valueAsNumber = 40;
|
||||
fontSizeInput.addEventListener("input", () => {
|
||||
redraw();
|
||||
});
|
||||
document.body.appendChild(fontSizeInput);
|
||||
|
||||
addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
redraw();
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
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";
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 拥有一个 HTML 元素的接口
|
||||
*
|
||||
* 可以通过 `getElement` 获取这个类所对应的 HTML 元素实例
|
||||
*/
|
||||
export interface HasElement {
|
||||
/** 获取这个类所对应的 HTML 元素实例 */
|
||||
getElement(): HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现了这个接口的东西需要在使用完毕后
|
||||
*
|
||||
* 手动调用 `dispose` 函数来销毁清除占用资源
|
||||
*
|
||||
* 以免产生泄露
|
||||
*/
|
||||
export interface Disposable {
|
||||
/**
|
||||
* 销毁实现了该接口的对象实例,释放占用的资源
|
||||
*
|
||||
* 一般情况下,调用本函数后就不可以再调用对象的任何函数了
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词行的渲染模式
|
||||
* @internal
|
||||
*/
|
||||
export enum LyricLineRenderMode {
|
||||
SOLID = 0,
|
||||
GRADIENT = 1,
|
||||
}
|
||||
|
||||
/** 一个歌词单词 */
|
||||
export interface LyricWord {
|
||||
/** 单词的起始时间,单位为毫秒 */
|
||||
startTime: number;
|
||||
/** 单词的结束时间,单位为毫秒 */
|
||||
endTime: number;
|
||||
/** 单词内容 */
|
||||
word: string;
|
||||
/** 单词的音译内容 */
|
||||
romanWord: string;
|
||||
/** 单词内容是否包含冒犯性的不雅用语 */
|
||||
obscene: boolean;
|
||||
}
|
||||
|
||||
/** 一行歌词,存储多个单词 */
|
||||
export interface LyricLine {
|
||||
/**
|
||||
* 该行的所有单词
|
||||
* 如果是 LyRiC 等只能表达一行歌词的格式,这里就只会有一个单词且通常其始末时间和本结构的 `startTime` 和 `endTime` 相同
|
||||
*/
|
||||
words: LyricWord[];
|
||||
/** 该行的翻译歌词,将会显示在主歌词行的下方 */
|
||||
translatedLyric: string;
|
||||
/** 该行的音译歌词,将会显示在翻译歌词行的下方 */
|
||||
romanLyric: string;
|
||||
/** 句子的起始时间,单位为毫秒 */
|
||||
startTime: number;
|
||||
/** 句子的结束时间,单位为毫秒 */
|
||||
endTime: number;
|
||||
/** 该行是否为背景歌词行,当该行歌词的上一句非背景歌词被激活时,这行歌词将会显示出来,注意每个非背景歌词下方只能拥有一个背景歌词 */
|
||||
isBG: boolean;
|
||||
/** 该行是否为对唱歌词行(即歌词行靠右对齐) */
|
||||
isDuet: boolean;
|
||||
}
|
||||
+1095
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
import type { Disposable, HasElement } from "../interfaces.ts";
|
||||
import styles from "../styles/lyric-player.module.css";
|
||||
import { measure } from "../utils/schedule.ts";
|
||||
import { Spring } from "../utils/spring.ts";
|
||||
import type { LyricPlayerBase } from "./base.ts";
|
||||
|
||||
export class BottomLineEl implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private delay = 0;
|
||||
// 由 LyricPlayer 来设置
|
||||
lineSize: [number, number] = [0, 0];
|
||||
readonly lineTransforms = {
|
||||
posX: new Spring(0),
|
||||
posY: new Spring(0),
|
||||
};
|
||||
constructor(private lyricPlayer: LyricPlayerBase) {
|
||||
this.element.setAttribute("class", styles.lyricLine);
|
||||
this.rebuildStyle();
|
||||
}
|
||||
async measureSize(): Promise<[number, number]> {
|
||||
const size: [number, number] = await measure(() => [
|
||||
this.element.clientWidth,
|
||||
this.element.clientHeight,
|
||||
]);
|
||||
return size;
|
||||
}
|
||||
private lastStyle = "";
|
||||
show() {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
hide() {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
private rebuildStyle() {
|
||||
let style = `transform:translate(${this.lineTransforms.posX
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px,${this.lineTransforms.posY
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px);`;
|
||||
if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
|
||||
style += `transition-delay:${this.delay}ms;`;
|
||||
}
|
||||
if (style !== this.lastStyle) {
|
||||
this.lastStyle = style;
|
||||
this.element.setAttribute("style", style);
|
||||
}
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(
|
||||
left: number = this.left,
|
||||
top: number = this.top,
|
||||
force = false,
|
||||
delay = 0,
|
||||
) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.delay = (delay * 1000) | 0;
|
||||
if (force || !this.lyricPlayer.getEnableSpring()) {
|
||||
if (force) this.element.classList.add(styles.tmpDisableTransition);
|
||||
this.lineTransforms.posX.setPosition(left);
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
if (!this.lyricPlayer.getEnableSpring()) this.show();
|
||||
else this.rebuildStyle();
|
||||
if (force)
|
||||
requestAnimationFrame(() => {
|
||||
this.element.classList.remove(styles.tmpDisableTransition);
|
||||
});
|
||||
} else {
|
||||
this.lineTransforms.posX.setTargetPosition(left, delay);
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
}
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.lyricPlayer.getEnableSpring()) return;
|
||||
this.lineTransforms.posX.update(delta);
|
||||
this.lineTransforms.posY.update(delta);
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
get isInSight() {
|
||||
const l = this.lineTransforms.posX.getCurrentPosition();
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const r = l + this.lineSize[0];
|
||||
const b = t + this.lineSize[1];
|
||||
const pr = this.lyricPlayer.size[0];
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
return !(l > pr || t > pb || r < 0 || b < 0);
|
||||
}
|
||||
dispose(): void {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import { LyricPlayerBase } from "../base.ts";
|
||||
import { CanvasLyricLine } from "./lyric-line.ts";
|
||||
|
||||
export class CanvasLyricPlayer extends LyricPlayerBase {
|
||||
private canvasElement = document.createElement("canvas");
|
||||
override currentLyricLineObjects: CanvasLyricLine[] = [];
|
||||
/** @internal */
|
||||
readonly ctx = this.canvasElement.getContext("2d")!;
|
||||
/** @internal */
|
||||
baseLineHeight = 1;
|
||||
/** @internal */
|
||||
baseFontSize = 30;
|
||||
/** @internal */
|
||||
baseFontFamily = "sans-serif";
|
||||
constructor() {
|
||||
super();
|
||||
this.element.classList.add("amll-lyric-player", "dom");
|
||||
this.canvasElement.style.width = "100%";
|
||||
this.canvasElement.style.height = "100%";
|
||||
this.canvasElement.style.display = "block";
|
||||
this.canvasElement.style.position = "absolute";
|
||||
this.onResize();
|
||||
this.update();
|
||||
// TODO: 添加鼠标事件监听
|
||||
this.element.addEventListener("mousemove", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.addEventListener("contextmenu", (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.element.appendChild(this.canvasElement);
|
||||
this.element.appendChild(this.interludeDots.getElement());
|
||||
this.element.appendChild(this.bottomLine.getElement());
|
||||
}
|
||||
override setLyricLines(lines: LyricLine[], initialTime?: number): void {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
|
||||
this.currentLyricLineObjects = this.processedLines.map(
|
||||
(line) => new CanvasLyricLine(this, line),
|
||||
);
|
||||
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true);
|
||||
}
|
||||
override onResize(): void {
|
||||
const computedStyle = getComputedStyle(this.element);
|
||||
this.baseFontSize = Number.parseFloat(computedStyle.fontSize) || 30;
|
||||
this.baseFontFamily = computedStyle.fontFamily;
|
||||
const realWidth = this.canvasElement.clientWidth;
|
||||
const realHeight = this.canvasElement.clientHeight;
|
||||
this.size[0] = realWidth - this.baseFontSize * 2;
|
||||
this.size[1] = realHeight;
|
||||
this.canvasElement.width = realWidth * devicePixelRatio;
|
||||
this.canvasElement.height = realHeight * devicePixelRatio;
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.relayout();
|
||||
}
|
||||
console.log("CanvasLyricPlayer.onResize", this.size);
|
||||
this.calcLayout(true);
|
||||
}
|
||||
/**
|
||||
* @internal
|
||||
* @param size
|
||||
*/
|
||||
setFontSize(emSize: number): void {
|
||||
this.ctx.font = `${this.baseFontSize * emSize}px ${this.baseFontFamily}`;
|
||||
}
|
||||
override update(delta = 0): void {
|
||||
super.update(delta);
|
||||
|
||||
const ctx = this.ctx;
|
||||
const width = this.size[0];
|
||||
const height = this.size[1];
|
||||
ctx.resetTransform();
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);
|
||||
ctx.fillStyle = "currentColor";
|
||||
ctx.font = `${this.baseFontSize}px ${this.baseFontFamily}`;
|
||||
ctx.textRendering = "optimizeSpeed";
|
||||
ctx.textAlign = "left";
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.baseFontSize, 0);
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(delta / 1000);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
ctx.font = `15px ${this.baseFontFamily}`;
|
||||
ctx.fillStyle = "#FFFFFF55";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("CanvasLyricPlayer 播放器", width - 16, height - 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
|
||||
import { LyricLineBase } from "../base.ts";
|
||||
import type { CanvasLyricPlayer } from "./index.ts";
|
||||
import {
|
||||
layoutLine,
|
||||
type TextLayoutConfig,
|
||||
type TextLayoutResult,
|
||||
} from "./text-layout";
|
||||
|
||||
export class CanvasLyricLine extends LyricLineBase {
|
||||
constructor(
|
||||
private player: CanvasLyricPlayer,
|
||||
private line: LyricLine = {
|
||||
words: [],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
override getLine(): LyricLine {
|
||||
return this.line;
|
||||
}
|
||||
private lineSize: [number, number] = [0, 0];
|
||||
measureSize(): [number, number] {
|
||||
const maxMainLineIndex = Math.max(
|
||||
0,
|
||||
...this.layoutWords.flat().map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const maxTranslatedLineIndex = Math.max(
|
||||
0,
|
||||
...this.translatedLayoutWords.map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const maxRomanLineIndex = Math.max(
|
||||
0,
|
||||
...this.romanLayoutWords.map((w) => w.lineIndex + 1),
|
||||
);
|
||||
const lineHeight = this.player.baseFontSize;
|
||||
this.lineSize = [
|
||||
this.player.size[0],
|
||||
(maxMainLineIndex + maxTranslatedLineIndex + maxRomanLineIndex) *
|
||||
lineHeight +
|
||||
this.player.size[1] * 0.04,
|
||||
];
|
||||
return [...this.lineSize];
|
||||
}
|
||||
|
||||
private layoutWords: TextLayoutResult[][] = [];
|
||||
private translatedLayoutWords: TextLayoutResult[] = [];
|
||||
private romanLayoutWords: TextLayoutResult[] = [];
|
||||
private lineCanvas: HTMLCanvasElement = document.createElement("canvas");
|
||||
/** @internal */
|
||||
relayout(): void {
|
||||
const config: TextLayoutConfig = {
|
||||
fontSize: this.player.baseFontSize,
|
||||
maxWidth: this.player.size[0] - 50,
|
||||
lineHeight: this.player.baseFontSize,
|
||||
uniformSpace: true,
|
||||
};
|
||||
const ctx = this.player.ctx;
|
||||
this.player.setFontSize(1);
|
||||
for (const chunk of chunkAndSplitLyricWords(this.line.words)) {
|
||||
if (Array.isArray(chunk)) {
|
||||
if (chunk.length === 0) continue;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
this.layoutWords = [
|
||||
[...layoutLine(ctx, this.line.words.map((w) => w.word).join(""), config)],
|
||||
];
|
||||
this.player.setFontSize(0.5);
|
||||
this.translatedLayoutWords = [
|
||||
...layoutLine(ctx, this.line.translatedLyric, config),
|
||||
];
|
||||
this.romanLayoutWords = [...layoutLine(ctx, this.line.romanLyric, config)];
|
||||
|
||||
// 在自己的 Canvas 上绘制
|
||||
this.measureSize();
|
||||
|
||||
this.lineCanvas.width = this.player.ctx.canvas.width;
|
||||
this.lineCanvas.height = this.lineSize[1] * devicePixelRatio;
|
||||
|
||||
const lctx = this.lineCanvas.getContext("2d")!;
|
||||
lctx.globalAlpha = 1;
|
||||
this.player.setFontSize(1);
|
||||
lctx.font = ctx.font;
|
||||
lctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
lctx.fillStyle = "white";
|
||||
lctx.textBaseline = "top";
|
||||
lctx.textAlign = "left";
|
||||
lctx.font = `${this.player.baseFontSize}px ${this.player.baseFontFamily}`;
|
||||
let lineIndex = 0;
|
||||
for (const word of this.layoutWords) {
|
||||
for (const layout of word) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
lineIndex = layout.lineIndex;
|
||||
}
|
||||
}
|
||||
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
|
||||
this.player.setFontSize(0.5);
|
||||
lctx.font = ctx.font;
|
||||
lctx.globalAlpha = 0.5;
|
||||
lineIndex = 0;
|
||||
for (const layout of this.translatedLayoutWords) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
lineIndex = layout.lineIndex;
|
||||
}
|
||||
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
|
||||
for (const layout of this.romanLayoutWords) {
|
||||
lctx.fillText(
|
||||
layout.text,
|
||||
layout.x,
|
||||
layout.lineIndex *
|
||||
this.player.baseFontSize *
|
||||
this.player.baseLineHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
override enable(): void {}
|
||||
override disable(): void {}
|
||||
override resume(): void {}
|
||||
override pause(): void {}
|
||||
override setTransform(
|
||||
top = this.top,
|
||||
scale = this.scale,
|
||||
opacity = this.opacity,
|
||||
blur = this.blur,
|
||||
force = false,
|
||||
delay = this.delay,
|
||||
): void {
|
||||
const targetBlur = Math.min(32, blur);
|
||||
// TODO: 实现模糊效果和不透明度效果的动画化
|
||||
this.blur = targetBlur;
|
||||
this.opacity = opacity;
|
||||
if (force) {
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
this.lineTransforms.scale.setPosition(scale);
|
||||
} else {
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
this.lineTransforms.scale.setTargetPosition(scale);
|
||||
}
|
||||
}
|
||||
get isInSight() {
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const r = this.lineSize[0];
|
||||
const b = t + this.lineSize[1];
|
||||
const pb = this.player.size[1];
|
||||
return !(t > pb || r < 0 || b < 0);
|
||||
}
|
||||
override update(delta?: number): void {
|
||||
this.lineTransforms.posY.update(delta);
|
||||
this.lineTransforms.scale.update(delta);
|
||||
if (!this.isInSight) return;
|
||||
const ctx = this.player.ctx;
|
||||
ctx.save();
|
||||
ctx.fillStyle = "white";
|
||||
ctx.filter = `blur(${this.blur}px)`;
|
||||
ctx.textRendering = "geometricPrecision";
|
||||
ctx.globalAlpha = this.opacity;
|
||||
ctx.translate(0, this.lineTransforms.posY.getCurrentPosition());
|
||||
ctx.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
|
||||
if (this.lineCanvas.width * this.lineCanvas.height > 0)
|
||||
ctx.drawImage(this.lineCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
const WHITE_SPACE = /^\s+/;
|
||||
const LATIN = /^[\p{L}0-9!"#$%&’()*+,-./:;<=>?@\[\]^_`\{|\}~]+/iu;
|
||||
|
||||
export interface TextLayoutResult {
|
||||
text: string;
|
||||
index: number;
|
||||
lineIndex: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
}
|
||||
|
||||
export interface TextLayoutConfig {
|
||||
fontSize: number;
|
||||
maxWidth: number;
|
||||
lineHeight: number;
|
||||
/**
|
||||
* 是否统一空格宽度,即不论空白字符有多少个,都只占用一个字符的宽度
|
||||
*/
|
||||
uniformSpace: boolean;
|
||||
}
|
||||
|
||||
export interface TextLayoutFinalState {
|
||||
x: number;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定文本进行布局,返回每个字符的位置信息
|
||||
* 目前仅可支持普通拉丁字符和 CJK 字符
|
||||
* @param ctx 2D 画板上下文
|
||||
* @param text 文本
|
||||
* @param config 字体大小
|
||||
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
|
||||
*/
|
||||
export function* layoutWord(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
config: TextLayoutConfig,
|
||||
initialX = 0,
|
||||
): Generator<TextLayoutResult, TextLayoutFinalState, void> {
|
||||
if (import.meta.env.DEV) {
|
||||
if (!ctx) throw new Error("ctx is null");
|
||||
if (!(config.fontSize > 0)) throw new Error("fontSize is invalid");
|
||||
}
|
||||
|
||||
let x = initialX;
|
||||
let lineIndex = 0;
|
||||
let lastWhitespaceIndex = 0;
|
||||
let curLayoutIndex = 0;
|
||||
let shouldWhitespace = false;
|
||||
let match: RegExpMatchArray | null = null;
|
||||
const spaceMetrics = ctx.measureText(" ");
|
||||
|
||||
while (curLayoutIndex < text.length) {
|
||||
const rest = text.substring(curLayoutIndex);
|
||||
match = rest.match(WHITE_SPACE);
|
||||
if (match) {
|
||||
shouldWhitespace = true;
|
||||
lastWhitespaceIndex = curLayoutIndex;
|
||||
curLayoutIndex += match[0].length;
|
||||
continue;
|
||||
}
|
||||
// 拉丁文字应尽量保持连续而不在中间换行
|
||||
match = rest.match(LATIN);
|
||||
if (match) {
|
||||
curLayoutIndex += match[0].length;
|
||||
const text = match[0];
|
||||
const wholeMetrics = ctx.measureText(text);
|
||||
if (x + wholeMetrics.width > config.maxWidth) {
|
||||
// 在当前行放不下,换行
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
shouldWhitespace = false;
|
||||
}
|
||||
if (shouldWhitespace) {
|
||||
shouldWhitespace = false;
|
||||
yield {
|
||||
text: " ",
|
||||
index: lastWhitespaceIndex,
|
||||
lineIndex,
|
||||
width: 0,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += spaceMetrics.width;
|
||||
}
|
||||
let prevChar = "";
|
||||
let lastMetrics: TextMetrics | null = null;
|
||||
for (const c of text) {
|
||||
const lastMergedMetrics = ctx.measureText(`${prevChar}${c}`);
|
||||
const metrics = ctx.measureText(c);
|
||||
if (prevChar !== "") {
|
||||
x = x + lastMergedMetrics.width - metrics.width;
|
||||
}
|
||||
if (x + metrics.width > config.maxWidth) {
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
}
|
||||
yield {
|
||||
text: c,
|
||||
index: curLayoutIndex,
|
||||
lineIndex,
|
||||
width: metrics.width,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
prevChar = c;
|
||||
lastMetrics = metrics;
|
||||
}
|
||||
if (lastMetrics) x += lastMetrics.width;
|
||||
continue;
|
||||
}
|
||||
// 无需保持连续的字符,直接布局,推进游标
|
||||
{
|
||||
if (shouldWhitespace) {
|
||||
shouldWhitespace = false;
|
||||
yield {
|
||||
text: " ",
|
||||
index: lastWhitespaceIndex,
|
||||
lineIndex,
|
||||
width: 0,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += spaceMetrics.width;
|
||||
}
|
||||
const metrics = ctx.measureText(text[curLayoutIndex]);
|
||||
if (x + metrics.width > config.maxWidth) {
|
||||
x = 0;
|
||||
lineIndex++;
|
||||
}
|
||||
yield {
|
||||
text: text[curLayoutIndex],
|
||||
index: curLayoutIndex,
|
||||
lineIndex,
|
||||
width: metrics.width,
|
||||
height: config.fontSize,
|
||||
x,
|
||||
};
|
||||
x += metrics.width;
|
||||
}
|
||||
|
||||
curLayoutIndex++;
|
||||
}
|
||||
|
||||
return { x, lineIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定文本进行布局,返回每段文字的位置信息
|
||||
* 目前仅可支持普通拉丁字符和 CJK 字符
|
||||
* @param ctx 2D 画板上下文
|
||||
* @param text 文本
|
||||
* @param config 字体大小
|
||||
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
|
||||
*/
|
||||
export function* layoutLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
config: TextLayoutConfig,
|
||||
initialX = 0,
|
||||
): Generator<TextLayoutResult, void, void> {
|
||||
let currentLine: TextLayoutResult = {
|
||||
text: "",
|
||||
index: 0,
|
||||
lineIndex: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
};
|
||||
|
||||
for (const word of layoutWord(ctx, text, config, initialX)) {
|
||||
if (word.lineIndex !== currentLine.lineIndex) {
|
||||
if (currentLine.text.length) yield currentLine;
|
||||
currentLine = {
|
||||
...word,
|
||||
};
|
||||
} else {
|
||||
currentLine.text += word.text;
|
||||
currentLine.width = word.x + word.width;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.text.length) yield currentLine;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
给各个内部元素使用的样式
|
||||
*/
|
||||
|
||||
/* 给每行歌词应用的样式 */
|
||||
.lyricLine {
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 2vh 1em;
|
||||
contain: content;
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.25em;
|
||||
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
|
||||
&:has(> *):hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
|
||||
&:has(> *):active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
scale 0.5s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
|
||||
padding: 1vh
|
||||
calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
|
||||
|
||||
&.active {
|
||||
transition:
|
||||
opacity 0.5s 0.25s,
|
||||
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
&:hover .lyricLine {
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
transition: opacity 0.3s 0.1s;
|
||||
contain: content paint;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
transition: opacity 0.25s;
|
||||
transform-origin: center;
|
||||
width: fit-content;
|
||||
padding: 2.5% 0.75em;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
transition: none !important;
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件,但是进行了部分效果的阉割精简,以尝试改善在低性能设备上的性能问题
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import "../../styles/index.css";
|
||||
import { debounce } from "../../utils/debounce.ts";
|
||||
import { LyricPlayerBase } from "../base.ts";
|
||||
import { LyricLineMouseEvent } from "../dom/index.ts";
|
||||
import styles from "./index.module.css";
|
||||
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
|
||||
|
||||
/**
|
||||
* 歌词播放组件,本框架的核心组件
|
||||
*
|
||||
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
|
||||
*/
|
||||
export class DomSlimLyricPlayer extends LyricPlayerBase {
|
||||
override currentLyricLineObjects: LyricLineEl[] = [];
|
||||
|
||||
private debounceCalcLayout = debounce(
|
||||
() =>
|
||||
this.calcLayout(true).then(() =>
|
||||
this.currentLyricLineObjects.map(async (el, i) => {
|
||||
el.markMaskImageDirty("DomLyricPlayer onResize");
|
||||
await el.waitMaskImageUpdated();
|
||||
if (this.hotLines.has(i)) {
|
||||
el.enable(this.currentTime);
|
||||
el.resume();
|
||||
}
|
||||
}),
|
||||
),
|
||||
1000,
|
||||
);
|
||||
|
||||
override onResize(): void {
|
||||
const computedStyles = getComputedStyle(this.element);
|
||||
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
|
||||
const innerWidth =
|
||||
this.element.clientWidth -
|
||||
Number.parseFloat(computedStyles.paddingLeft) -
|
||||
Number.parseFloat(computedStyles.paddingRight);
|
||||
const innerHeight =
|
||||
this.element.clientHeight -
|
||||
Number.parseFloat(computedStyles.paddingTop) -
|
||||
Number.parseFloat(computedStyles.paddingBottom);
|
||||
this.innerSize[0] = innerWidth;
|
||||
this.innerSize[1] = innerHeight;
|
||||
this.rebuildStyle();
|
||||
// for (const obj of this.currentLyricLineObjects) {
|
||||
// if (!obj.getElement().classList.contains(styles.dirty))
|
||||
// obj.getElement().classList.add(styles.dirty);
|
||||
// }
|
||||
this.debounceCalcLayout();
|
||||
}
|
||||
|
||||
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
|
||||
readonly supportMaskImage = CSS.supports("mask-image", "none");
|
||||
readonly innerSize: [number, number] = [0, 0];
|
||||
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
|
||||
const evt = new LyricLineMouseEvent(
|
||||
this.lyricLinesIndexes.get(e.line) ?? -1,
|
||||
e.line,
|
||||
e,
|
||||
);
|
||||
if (!this.dispatchEvent(evt)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 是否为非逐词歌词
|
||||
* @internal
|
||||
*/
|
||||
_getIsNonDynamic() {
|
||||
return this.isNonDynamic;
|
||||
}
|
||||
private _baseFontSize = Number.parseFloat(
|
||||
getComputedStyle(this.element).fontSize,
|
||||
);
|
||||
public get baseFontSize() {
|
||||
return this._baseFontSize;
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.onResize();
|
||||
this.element.classList.add("amll-lyric-player", "dom-slim");
|
||||
if (this.disableSpring) {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStyle() {
|
||||
const width = this.innerSize[0];
|
||||
const height = this.innerSize[1];
|
||||
this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
|
||||
this.element.style.setProperty(
|
||||
"--amll-lp-height",
|
||||
`${height.toFixed(4)}px`,
|
||||
);
|
||||
}
|
||||
|
||||
override setWordFadeWidth(value = 0.5) {
|
||||
super.setWordFadeWidth(value);
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.markMaskImageDirty("DomLyricPlayer setWordFadeWidth");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
override setLyricLines(lines: LyricLine[], initialTime = 0) {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
if (this.hasDuetLine) {
|
||||
this.element.classList.add(styles.hasDuetLine);
|
||||
} else {
|
||||
this.element.classList.remove(styles.hasDuetLine);
|
||||
}
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.removeMouseEventListener("click", this.onLineClickedHandler);
|
||||
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
line.dispose();
|
||||
}
|
||||
|
||||
// 创建新的歌词行元素
|
||||
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
|
||||
const lineEl = new LyricLineEl(this, line);
|
||||
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
|
||||
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
this.element.appendChild(lineEl.getElement());
|
||||
this.lyricLinesIndexes.set(lineEl, i);
|
||||
lineEl.markMaskImageDirty("DomLyricPlayer setLyricLines");
|
||||
return lineEl;
|
||||
});
|
||||
|
||||
this.setLinePosXSpringParams({});
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true).then(() => {
|
||||
this.initialLayoutFinished = true;
|
||||
});
|
||||
}
|
||||
|
||||
override pause() {
|
||||
super.pause();
|
||||
this.interludeDots.pause();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.pause();
|
||||
}
|
||||
}
|
||||
|
||||
override resume() {
|
||||
super.resume();
|
||||
this.interludeDots.resume();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.resume();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta = 0) {
|
||||
if (!this.initialLayoutFinished) return;
|
||||
super.update(delta);
|
||||
if (!this.isPageVisible) return;
|
||||
const deltaS = delta / 1000;
|
||||
this.interludeDots.update(delta);
|
||||
this.bottomLine.update(deltaS);
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(deltaS);
|
||||
}
|
||||
}
|
||||
|
||||
override async calcLayout(sync?: boolean): Promise<void> {
|
||||
await super.calcLayout(sync);
|
||||
const curLine = this.currentLyricLineObjects[this.targetAlignIndex];
|
||||
const curLineEl = curLine.getElement();
|
||||
const curLineVisibility = curLineEl.checkVisibility({
|
||||
contentVisibilityAuto: true,
|
||||
});
|
||||
const playerTop = this.element.getBoundingClientRect().top;
|
||||
if (!curLineVisibility) {
|
||||
curLineEl.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
const curLineHeight = curLineEl.clientHeight;
|
||||
const curLineRect = curLineEl.getBoundingClientRect();
|
||||
let scrollToPos =
|
||||
curLineRect.top - playerTop - this.size[1] * this.alignPosition;
|
||||
if (curLine) {
|
||||
switch (this.alignAnchor) {
|
||||
case "bottom":
|
||||
scrollToPos += curLineHeight;
|
||||
break;
|
||||
case "center":
|
||||
scrollToPos += curLineHeight / 2;
|
||||
break;
|
||||
case "top":
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.element.scrollBy({
|
||||
top: scrollToPos,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.element.remove();
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.dispose();
|
||||
}
|
||||
this.bottomLine.dispose();
|
||||
this.interludeDots.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Disposable, HasElement } from "../../interfaces.ts";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
|
||||
function easeInOutBack(x: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c2 = c1 * 1.525;
|
||||
|
||||
return x < 0.5
|
||||
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
|
||||
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutExpo(x: number): number {
|
||||
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
|
||||
}
|
||||
|
||||
const clamp = (min: number, cur: number, max: number) =>
|
||||
Math.max(min, Math.min(cur, max));
|
||||
|
||||
export class InterludeDots implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private dot0: HTMLElement = document.createElement("span");
|
||||
private dot1: HTMLElement = document.createElement("span");
|
||||
private dot2: HTMLElement = document.createElement("span");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private playing = true;
|
||||
private lastStyle = "";
|
||||
private currentInterlude?: [number, number];
|
||||
private currentTime = 0;
|
||||
private targetBreatheDuration = 1500;
|
||||
constructor() {
|
||||
this.element.className = styles.interludeDots;
|
||||
this.element.appendChild(this.dot0);
|
||||
this.element.appendChild(this.dot1);
|
||||
this.element.appendChild(this.dot2);
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(left: number = this.left, top: number = this.top) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.update();
|
||||
}
|
||||
setInterlude(interlude?: [number, number]) {
|
||||
this.currentInterlude = interlude;
|
||||
this.currentTime = interlude?.[0] ?? 0;
|
||||
if (interlude) {
|
||||
this.element.classList.add(styles.enabled);
|
||||
} else {
|
||||
this.element.classList.remove(styles.enabled);
|
||||
}
|
||||
}
|
||||
pause() {
|
||||
this.playing = false;
|
||||
this.element.classList.remove(styles.playing);
|
||||
}
|
||||
resume() {
|
||||
this.playing = true;
|
||||
this.element.classList.add(styles.playing);
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.playing) return;
|
||||
this.currentTime += delta;
|
||||
let curStyle = "";
|
||||
|
||||
// 计算缩放大小
|
||||
|
||||
if (this.currentInterlude) {
|
||||
const interludeDuration =
|
||||
this.currentInterlude[1] - this.currentInterlude[0];
|
||||
const currentDuration = this.currentTime - this.currentInterlude[0];
|
||||
if (currentDuration <= interludeDuration) {
|
||||
const breatheDuration =
|
||||
interludeDuration /
|
||||
Math.ceil(interludeDuration / this.targetBreatheDuration);
|
||||
let scale = 1;
|
||||
let globalOpacity = 1;
|
||||
|
||||
scale *=
|
||||
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
|
||||
20 +
|
||||
1;
|
||||
|
||||
if (currentDuration < 2000) {
|
||||
scale *= easeOutExpo(currentDuration / 2000);
|
||||
}
|
||||
|
||||
if (currentDuration < 500) {
|
||||
globalOpacity = 0;
|
||||
} else if (currentDuration < 1000) {
|
||||
globalOpacity *= (currentDuration - 500) / 500;
|
||||
}
|
||||
|
||||
if (interludeDuration - currentDuration < 750) {
|
||||
scale *=
|
||||
1 -
|
||||
easeInOutBack(
|
||||
(750 - (interludeDuration - currentDuration)) / 750 / 2,
|
||||
);
|
||||
}
|
||||
if (interludeDuration - currentDuration < 375) {
|
||||
globalOpacity *= clamp(
|
||||
0,
|
||||
(interludeDuration - currentDuration) / 375,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const dotsDuration = Math.max(0, interludeDuration - 750);
|
||||
|
||||
scale = Math.max(0, scale) * 0.7;
|
||||
|
||||
curStyle += ` scale(${scale})`;
|
||||
|
||||
const dot0Opacity = clamp(
|
||||
0.25,
|
||||
((currentDuration * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot1Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot2Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
this.dot0.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot0Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot1.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot1Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot2.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot2Opacity),
|
||||
1,
|
||||
)}`;
|
||||
} else {
|
||||
curStyle += " scale(0)";
|
||||
this.dot0.style.opacity = "0";
|
||||
this.dot1.style.opacity = "0";
|
||||
this.dot2.style.opacity = "0";
|
||||
}
|
||||
|
||||
curStyle += ";";
|
||||
|
||||
if (this.lastStyle !== curStyle) {
|
||||
this.element.setAttribute("style", curStyle);
|
||||
this.lastStyle = curStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
import bezier from "bezier-easing";
|
||||
import type { LyricLine, LyricWord } from "../../interfaces.ts";
|
||||
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
|
||||
import {
|
||||
createMatrix4,
|
||||
matrix4ToCSS,
|
||||
scaleMatrix4,
|
||||
} from "../../utils/matrix.ts";
|
||||
import { mutexifyFunction } from "../../utils/mutex.ts";
|
||||
import { measure, mutate } from "../../utils/schedule.ts";
|
||||
import { LyricLineBase } from "../base.ts";
|
||||
import styles from "./index.module.css";
|
||||
import type { DomSlimLyricPlayer } from "./index.ts";
|
||||
|
||||
interface RealWord extends LyricWord {
|
||||
mainElement: HTMLSpanElement;
|
||||
subElements: HTMLSpanElement[];
|
||||
elementAnimations: Animation[];
|
||||
maskAnimations: Animation[];
|
||||
width: number;
|
||||
height: number;
|
||||
padding: number;
|
||||
shouldEmphasize: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_FRAME_QUANTITY = 32;
|
||||
|
||||
const norNum = (min: number, max: number) => (x: number) =>
|
||||
Math.min(1, Math.max(0, (x - min) / (max - min)));
|
||||
const EMP_EASING_MID = 0.5;
|
||||
const beginNum = norNum(0, EMP_EASING_MID);
|
||||
const endNum = norNum(EMP_EASING_MID, 1);
|
||||
|
||||
const bezIn = bezier(0.2, 0.4, 0.58, 1.0);
|
||||
const bezOut = bezier(0.3, 0.0, 0.58, 1.0);
|
||||
|
||||
const makeEmpEasing = (mid: number) => {
|
||||
return (x: number) => (x < mid ? bezIn(beginNum(x)) : 1 - bezOut(endNum(x)));
|
||||
};
|
||||
|
||||
function generateFadeGradient(
|
||||
width: number,
|
||||
padding = 0,
|
||||
bright = "rgba(0,0,0,var(--bright-mask-alpha, 1.0))",
|
||||
dark = "rgba(0,0,0,var(--dark-mask-alpha, 1.0))",
|
||||
): [string, number] {
|
||||
const totalAspect = 2 + width + padding;
|
||||
const widthInTotal = width / totalAspect;
|
||||
const leftPos = (1 - widthInTotal) / 2;
|
||||
return [
|
||||
`linear-gradient(to right,${bright} ${leftPos * 100}%,${dark} ${
|
||||
(leftPos + widthInTotal) * 100
|
||||
}%)`,
|
||||
totalAspect,
|
||||
];
|
||||
}
|
||||
|
||||
export class RawLyricLineMouseEvent extends MouseEvent {
|
||||
constructor(
|
||||
public readonly line: LyricLineEl,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
super(event.type, event);
|
||||
}
|
||||
}
|
||||
|
||||
function getScaleFromTransform(transform: string): number {
|
||||
const match = transform.match(/matrix\(([^)]+)\)/);
|
||||
if (match) {
|
||||
const values = match[1].split(", ");
|
||||
const scaleX = Number.parseFloat(values[0]);
|
||||
const scaleY = Number.parseFloat(values[3]);
|
||||
return (scaleX + scaleY) / 2; // Average of scaleX and scaleY
|
||||
}
|
||||
return 1; // Default scale value if not found
|
||||
}
|
||||
|
||||
type MouseEventMap = {
|
||||
[evt in keyof HTMLElementEventMap]: HTMLElementEventMap[evt] extends MouseEvent
|
||||
? evt
|
||||
: never;
|
||||
};
|
||||
type MouseEventTypes = MouseEventMap[keyof MouseEventMap];
|
||||
type MouseEventListener = (
|
||||
this: LyricLineEl,
|
||||
ev: RawLyricLineMouseEvent,
|
||||
) => void;
|
||||
|
||||
export class LyricLineEl extends LyricLineBase {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private splittedWords: RealWord[] = [];
|
||||
// 由 LyricPlayer 来设置
|
||||
lineSize: number[] = [0, 0];
|
||||
|
||||
constructor(
|
||||
private lyricPlayer: DomSlimLyricPlayer,
|
||||
private lyricLine: LyricLine = {
|
||||
words: [],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.element.setAttribute("class", styles.lyricLine);
|
||||
if (this.lyricLine.isBG) {
|
||||
this.element.classList.add(styles.lyricBgLine);
|
||||
}
|
||||
if (this.lyricLine.isDuet) {
|
||||
this.element.classList.add(styles.lyricDuetLine);
|
||||
}
|
||||
this.element.appendChild(document.createElement("div")); // 歌词行
|
||||
this.element.appendChild(document.createElement("div")); // 翻译行
|
||||
this.element.appendChild(document.createElement("div")); // 音译行
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
const trans = this.element.children[1] as HTMLDivElement;
|
||||
const roman = this.element.children[2] as HTMLDivElement;
|
||||
main.setAttribute("class", styles.lyricMainLine);
|
||||
trans.setAttribute("class", styles.lyricSubLine);
|
||||
roman.setAttribute("class", styles.lyricSubLine);
|
||||
this.rebuildElement();
|
||||
this.rebuildStyle();
|
||||
this.markMaskImageDirty("Initial construction");
|
||||
}
|
||||
private listenersMap = new Map<string, Set<MouseEventListener>>();
|
||||
private readonly onMouseEvent = (e: MouseEvent) => {
|
||||
const wrapped = new RawLyricLineMouseEvent(this, e);
|
||||
for (const listener of this.listenersMap.get(e.type) ?? []) {
|
||||
listener.call(this, wrapped);
|
||||
}
|
||||
if (!this.dispatchEvent(wrapped) || wrapped.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
addMouseEventListener(
|
||||
type: MouseEventTypes,
|
||||
callback: MouseEventListener | null,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void {
|
||||
if (callback) {
|
||||
const listeners = this.listenersMap.get(type) ?? new Set();
|
||||
if (listeners.size === 0)
|
||||
this.element.addEventListener(type, this.onMouseEvent, options);
|
||||
listeners.add(callback);
|
||||
this.listenersMap.set(type, listeners);
|
||||
}
|
||||
}
|
||||
|
||||
removeMouseEventListener(
|
||||
type: MouseEventTypes,
|
||||
callback: MouseEventListener | null,
|
||||
options?: boolean | EventListenerOptions | undefined,
|
||||
): void {
|
||||
if (callback) {
|
||||
const listeners = this.listenersMap.get(type);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0)
|
||||
this.element.removeEventListener(type, this.onMouseEvent, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areWordsOnSameLine(word1: RealWord, word2: RealWord) {
|
||||
if (word1?.mainElement && word2?.mainElement) {
|
||||
const word1el = word1.mainElement;
|
||||
const word2el = word2.mainElement;
|
||||
|
||||
const rect1 = word1el.getBoundingClientRect();
|
||||
const rect2 = word2el.getBoundingClientRect();
|
||||
|
||||
// 检查两个单词的顶部距离是否相等(或者差值很小)
|
||||
const topDifference = Math.abs(rect1.top - rect2.top);
|
||||
|
||||
// 如果顶部距离相差很小,可以认为它们在同一行上
|
||||
return topDifference < 10;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isEnabled = false;
|
||||
async enable(maskAnimationTime = this.lyricLine.startTime) {
|
||||
this.isEnabled = true;
|
||||
this.element.classList.add(styles.active);
|
||||
await this.waitMaskImageUpdated();
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
a.currentTime = 0;
|
||||
a.playbackRate = 1;
|
||||
a.play();
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
a.currentTime = Math.min(
|
||||
this.totalDuration,
|
||||
Math.max(0, maskAnimationTime - this.lyricLine.startTime),
|
||||
);
|
||||
a.playbackRate = 1;
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
main.classList.add(styles.active);
|
||||
}
|
||||
disable() {
|
||||
this.isEnabled = false;
|
||||
this.element.classList.remove(styles.active);
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
if (
|
||||
a.id === "float-word" ||
|
||||
a.id.includes("emphasize-word-float-only")
|
||||
) {
|
||||
a.playbackRate = -1;
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
main.classList.remove(styles.active);
|
||||
}
|
||||
private lastWord?: RealWord;
|
||||
async resume() {
|
||||
await this.waitMaskImageUpdated();
|
||||
if (!this.isEnabled) return;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
if (
|
||||
!this.lastWord ||
|
||||
this.splittedWords.indexOf(this.lastWord) <
|
||||
this.splittedWords.indexOf(word)
|
||||
) {
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
if (
|
||||
!this.lastWord ||
|
||||
this.splittedWords.indexOf(this.lastWord) <
|
||||
this.splittedWords.indexOf(word)
|
||||
) {
|
||||
a.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async pause() {
|
||||
await this.waitMaskImageUpdated();
|
||||
if (!this.isEnabled) return;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.elementAnimations) {
|
||||
a.pause();
|
||||
}
|
||||
for (const a of word.maskAnimations) {
|
||||
a.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
setMaskAnimationState(maskAnimationTime = 0) {
|
||||
const t = maskAnimationTime - this.lyricLine.startTime;
|
||||
for (const word of this.splittedWords) {
|
||||
for (const a of word.maskAnimations) {
|
||||
a.currentTime = Math.min(this.totalDuration, Math.max(0, t));
|
||||
a.playbackRate = 1;
|
||||
if (t >= 0 && t < this.totalDuration) a.play();
|
||||
else a.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
private measureLockMark = false;
|
||||
private measureLock = mutexifyFunction(
|
||||
async (callback: () => Promise<void>): Promise<void> => {
|
||||
if (this.measureLockMark) return;
|
||||
this.measureLockMark = true;
|
||||
// if (this._hide) {
|
||||
// await mutate(() => {
|
||||
// this._prevParentEl.appendChild(this.element);
|
||||
// this.element.style.display = "";
|
||||
// this.element.style.visibility = "hidden";
|
||||
// });
|
||||
// }
|
||||
await callback();
|
||||
// if (this._hide) {
|
||||
// await mutate(() => {
|
||||
// this._prevParentEl.removeChild(this.element);
|
||||
// this.element.style.display = "none";
|
||||
// this.element.style.visibility = "";
|
||||
// });
|
||||
// }
|
||||
this.measureLockMark = false;
|
||||
},
|
||||
);
|
||||
|
||||
getLine() {
|
||||
return this.lyricLine;
|
||||
}
|
||||
show() {
|
||||
// this._hide = false;
|
||||
// if (!this.measureLockMark && !this.element.parentElement) {
|
||||
// this._prevParentEl.appendChild(this.element);
|
||||
// }
|
||||
this.rebuildStyle();
|
||||
}
|
||||
hide() {
|
||||
// this._hide = true;
|
||||
// if (!this.measureLockMark && this.element.parentElement) {
|
||||
// this._prevParentEl.removeChild(this.element);
|
||||
// }
|
||||
}
|
||||
private rebuildStyle() {
|
||||
// let style = "";
|
||||
// if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
|
||||
// style += `transition-delay:${this.delay}ms;`;
|
||||
// }
|
||||
// style += `filter:blur(${Math.min(32, this.blur)}px);`;
|
||||
// if (style !== this.lastStyle) {
|
||||
// this.lastStyle = style;
|
||||
// this.element.setAttribute("style", style);
|
||||
// }
|
||||
}
|
||||
rebuildElement() {
|
||||
this.disposeElements();
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
const trans = this.element.children[1] as HTMLDivElement;
|
||||
const roman = this.element.children[2] as HTMLDivElement;
|
||||
// 如果是非动态歌词,那么就不需要分词了
|
||||
if (this.lyricPlayer._getIsNonDynamic()) {
|
||||
main.innerText = this.lyricLine.words.map((w) => w.word).join("");
|
||||
trans.innerText = this.lyricLine.translatedLyric;
|
||||
roman.innerText = this.lyricLine.romanLyric;
|
||||
return;
|
||||
}
|
||||
const chunkedWords = chunkAndSplitLyricWords(this.lyricLine.words);
|
||||
main.innerHTML = "";
|
||||
for (const chunk of chunkedWords) {
|
||||
if (Array.isArray(chunk)) {
|
||||
// 多个没有空格的单词组合成的一个单词数组
|
||||
if (chunk.length === 0) continue;
|
||||
const merged = chunk.reduce(
|
||||
(a, b) => {
|
||||
a.endTime = Math.max(a.endTime, b.endTime);
|
||||
a.startTime = Math.min(a.startTime, b.startTime);
|
||||
a.word += b.word;
|
||||
return a;
|
||||
},
|
||||
{
|
||||
word: "",
|
||||
romanWord: "",
|
||||
startTime: Number.POSITIVE_INFINITY,
|
||||
endTime: Number.NEGATIVE_INFINITY,
|
||||
wordType: "normal",
|
||||
obscene: false,
|
||||
} as LyricWord,
|
||||
);
|
||||
const emp = chunk
|
||||
.map((word) => LyricLineBase.shouldEmphasize(word))
|
||||
.reduce((a, b) => a || b, LyricLineBase.shouldEmphasize(merged));
|
||||
const wrapperWordEl = document.createElement("span");
|
||||
wrapperWordEl.classList.add(styles.emphasizeWrapper);
|
||||
const characterElements: HTMLElement[] = [];
|
||||
for (const word of chunk) {
|
||||
const mainWordEl = document.createElement("span");
|
||||
// const mainWordFloatAnimation = this.initFloatAnimation(
|
||||
// merged,
|
||||
// mainWordEl,
|
||||
// );
|
||||
if (emp) {
|
||||
mainWordEl.classList.add(styles.emphasize);
|
||||
const charEls: HTMLSpanElement[] = [];
|
||||
for (const char of word.word.trim()) {
|
||||
const charEl = document.createElement("span");
|
||||
charEl.innerText = char;
|
||||
charEls.push(charEl);
|
||||
characterElements.push(charEl);
|
||||
mainWordEl.appendChild(charEl);
|
||||
}
|
||||
const realWord: RealWord = {
|
||||
...word,
|
||||
mainElement: mainWordEl,
|
||||
subElements: charEls,
|
||||
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
};
|
||||
this.splittedWords.push(realWord);
|
||||
} else {
|
||||
mainWordEl.innerText = word.word;
|
||||
this.splittedWords.push({
|
||||
...word,
|
||||
mainElement: mainWordEl,
|
||||
subElements: [],
|
||||
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
});
|
||||
}
|
||||
wrapperWordEl.appendChild(mainWordEl);
|
||||
}
|
||||
if (emp) {
|
||||
this.splittedWords[
|
||||
this.splittedWords.length - 1
|
||||
].elementAnimations.push(
|
||||
...this.initEmphasizeAnimation(
|
||||
merged,
|
||||
characterElements,
|
||||
merged.endTime - merged.startTime,
|
||||
merged.startTime - this.lyricLine.startTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (merged.word.trimStart() !== merged.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
main.appendChild(wrapperWordEl);
|
||||
if (
|
||||
merged.word.trimEnd() !== merged.word &&
|
||||
LyricLineBase.shouldEmphasize(merged)
|
||||
) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
} else if (chunk.word.trim().length === 0) {
|
||||
// 纯空格
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
} else {
|
||||
// 单个单词
|
||||
const emp = LyricLineBase.shouldEmphasize(chunk);
|
||||
const mainWordEl = document.createElement("span");
|
||||
const realWord: RealWord = {
|
||||
...chunk,
|
||||
mainElement: mainWordEl,
|
||||
subElements: [],
|
||||
// elementAnimations: [this.initFloatAnimation(chunk, mainWordEl)],
|
||||
elementAnimations: [], // this.initFloatAnimation(chunk, mainWordEl)
|
||||
maskAnimations: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
padding: 0,
|
||||
shouldEmphasize: emp,
|
||||
};
|
||||
if (LyricLineBase.shouldEmphasize(chunk)) {
|
||||
mainWordEl.classList.add(styles.emphasize);
|
||||
const charEls: HTMLSpanElement[] = [];
|
||||
for (const char of chunk.word.trim()) {
|
||||
const charEl = document.createElement("span");
|
||||
charEl.innerText = char;
|
||||
charEls.push(charEl);
|
||||
mainWordEl.appendChild(charEl);
|
||||
}
|
||||
realWord.subElements = charEls;
|
||||
const duration = Math.abs(realWord.endTime - realWord.startTime);
|
||||
realWord.elementAnimations.push(
|
||||
...this.initEmphasizeAnimation(
|
||||
chunk,
|
||||
charEls,
|
||||
duration,
|
||||
realWord.startTime - this.lyricLine.startTime,
|
||||
),
|
||||
);
|
||||
// realWord.elementAnimations = this.initEmphasizeAnimation(realWord);
|
||||
} else {
|
||||
mainWordEl.innerText = chunk.word.trim();
|
||||
}
|
||||
if (chunk.word.trimStart() !== chunk.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
main.appendChild(mainWordEl);
|
||||
if (chunk.word.trimEnd() !== chunk.word) {
|
||||
main.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
this.splittedWords.push(realWord);
|
||||
}
|
||||
}
|
||||
trans.innerText = this.lyricLine.translatedLyric;
|
||||
roman.innerText = this.lyricLine.romanLyric;
|
||||
}
|
||||
// 按照原 Apple Music 参考,强调效果只应用缩放、轻微左右位移和辉光效果,原主要的悬浮位移效果不变
|
||||
// 为了避免产生锯齿抖动感,使用 matrix3d 来实现缩放和位移
|
||||
private initEmphasizeAnimation(
|
||||
word: LyricWord,
|
||||
characterElements: HTMLElement[],
|
||||
duration: number,
|
||||
delay: number,
|
||||
): Animation[] {
|
||||
const de = Math.max(0, delay);
|
||||
let du = Math.max(1000, duration);
|
||||
|
||||
let result: Animation[] = [];
|
||||
|
||||
let amount = du / 2000;
|
||||
amount = amount > 1 ? Math.sqrt(amount) : amount ** 3;
|
||||
let blur = du / 3000;
|
||||
blur = blur > 1 ? Math.sqrt(blur) : blur ** 3;
|
||||
amount *= 0.6;
|
||||
blur *= 0.5;
|
||||
if (
|
||||
this.lyricLine.words.length > 0 &&
|
||||
word.word.includes(
|
||||
this.lyricLine.words[this.lyricLine.words.length - 1].word,
|
||||
)
|
||||
) {
|
||||
amount *= 1.6;
|
||||
blur *= 1.5;
|
||||
du *= 1.2;
|
||||
}
|
||||
amount = Math.min(1.2, amount);
|
||||
blur = Math.min(0.8, blur);
|
||||
|
||||
const animateDu = Number.isFinite(du) ? du : 0;
|
||||
const empEasing = makeEmpEasing(EMP_EASING_MID);
|
||||
|
||||
result = characterElements.flatMap((el, i, arr) => {
|
||||
const wordDe = de + (du / 2.5 / arr.length) * i;
|
||||
const result: Animation[] = [];
|
||||
|
||||
const frames: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
|
||||
.fill(0)
|
||||
.map((_, j) => {
|
||||
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
|
||||
const transX = empEasing(x);
|
||||
const glowLevel = empEasing(x) * blur;
|
||||
|
||||
const mat = scaleMatrix4(createMatrix4(), 1 + transX * 0.1 * amount);
|
||||
const offsetX = -transX * 0.03 * amount * (arr.length / 2 - i);
|
||||
const offsetY = -transX * 0.025 * amount;
|
||||
|
||||
return {
|
||||
offset: x,
|
||||
transform: `${matrix4ToCSS(
|
||||
mat,
|
||||
4,
|
||||
)} translate(${offsetX}em, ${offsetY}em)`,
|
||||
textShadow: `0 0 ${Math.min(
|
||||
0.3,
|
||||
blur * 0.3,
|
||||
)}em rgba(255, 255, 255, ${glowLevel})`,
|
||||
};
|
||||
});
|
||||
|
||||
const glow = el.animate(frames, {
|
||||
duration: animateDu,
|
||||
delay: Number.isFinite(wordDe) ? wordDe : 0,
|
||||
id: `emphasize-word-${el.innerText}-${i}`,
|
||||
iterations: 1,
|
||||
composite: "replace",
|
||||
fill: "both",
|
||||
});
|
||||
glow.onfinish = () => {
|
||||
glow.pause();
|
||||
};
|
||||
glow.pause();
|
||||
result.push(glow);
|
||||
|
||||
const floatFrame: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
|
||||
.fill(0)
|
||||
.map((_, j) => {
|
||||
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
|
||||
let y = Math.sin(x * Math.PI);
|
||||
// y = x < 0.5 ? y : Math.max(y, 1.0);
|
||||
if (this.lyricLine.isBG) {
|
||||
y *= 2;
|
||||
}
|
||||
|
||||
return {
|
||||
offset: x,
|
||||
transform: `translateY(${-y * 0.05}em)`,
|
||||
};
|
||||
});
|
||||
const float = el.animate(floatFrame, {
|
||||
duration: animateDu * 1.4,
|
||||
delay: Number.isFinite(wordDe) ? wordDe - 400 : 0,
|
||||
id: "emphasize-word-float",
|
||||
iterations: 1,
|
||||
composite: "add",
|
||||
fill: "both",
|
||||
});
|
||||
float.onfinish = () => {
|
||||
float.pause();
|
||||
};
|
||||
float.pause();
|
||||
result.push(float);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private get totalDuration() {
|
||||
return this.lyricLine.endTime - this.lyricLine.startTime;
|
||||
}
|
||||
private maskImageDirty = false;
|
||||
private markImageDirtyPromiseResolve: Set<() => void> = new Set();
|
||||
private markImageDirtyPromise: Promise<void> = new Promise((resolve) => {
|
||||
this.markImageDirtyPromiseResolve.add(resolve);
|
||||
});
|
||||
markMaskImageDirty(_debugReason = ""): Promise<void> {
|
||||
this.maskImageDirty = true;
|
||||
if (!this.element.classList.contains(styles.dirty))
|
||||
this.element.classList.add(styles.dirty);
|
||||
// if (import.meta.env.DEV) {
|
||||
// console.log("Mark mask image dirty: ", _debugReason);
|
||||
// }
|
||||
const newPromise = Promise.all([
|
||||
this.markImageDirtyPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
this.markImageDirtyPromiseResolve.add(resolve);
|
||||
}),
|
||||
]).then(() => {});
|
||||
this.markImageDirtyPromise = newPromise;
|
||||
return newPromise;
|
||||
}
|
||||
waitMaskImageUpdated(): Promise<void> {
|
||||
return this.markImageDirtyPromise;
|
||||
}
|
||||
async updateMaskImage() {
|
||||
if (
|
||||
!this.element.checkVisibility({
|
||||
contentVisibilityAuto: true,
|
||||
})
|
||||
)
|
||||
return;
|
||||
this.maskImageDirty = false;
|
||||
await this.measureLock(async () => {
|
||||
await Promise.all(
|
||||
this.splittedWords.map(async (word) => {
|
||||
const el = word.mainElement;
|
||||
if (el) {
|
||||
await measure(() => {
|
||||
word.padding = Number.parseFloat(
|
||||
getComputedStyle(el).paddingLeft,
|
||||
);
|
||||
word.width = el.clientWidth - word.padding * 2;
|
||||
word.height = el.clientHeight - word.padding * 2;
|
||||
});
|
||||
} else {
|
||||
word.width = 0;
|
||||
word.height = 0;
|
||||
word.padding = 0;
|
||||
}
|
||||
if (word.width * word.height === 0) {
|
||||
console.warn("Word size is zero");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await mutate(() => {
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
this.generateWebAnimationBasedMaskImage();
|
||||
} else {
|
||||
this.generateCalcBasedMaskImage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const resolve of this.markImageDirtyPromiseResolve) {
|
||||
resolve();
|
||||
this.markImageDirtyPromiseResolve.delete(resolve);
|
||||
}
|
||||
await mutate(() => {
|
||||
this.element.classList.remove(styles.dirty);
|
||||
});
|
||||
}
|
||||
private generateCalcBasedMaskImage() {
|
||||
for (const word of this.splittedWords) {
|
||||
const wordEl = word.mainElement;
|
||||
if (wordEl) {
|
||||
word.width = wordEl.clientWidth;
|
||||
word.height = wordEl.clientHeight;
|
||||
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
|
||||
const [maskImage, totalAspect] = generateFadeGradient(
|
||||
fadeWidth / word.width,
|
||||
);
|
||||
const totalAspectStr = `${totalAspect * 100}% 100%`;
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
wordEl.style.maskImage = maskImage;
|
||||
wordEl.style.maskRepeat = "no-repeat";
|
||||
wordEl.style.maskOrigin = "left";
|
||||
wordEl.style.maskSize = totalAspectStr;
|
||||
} else {
|
||||
wordEl.style.webkitMaskImage = maskImage;
|
||||
wordEl.style.webkitMaskRepeat = "no-repeat";
|
||||
wordEl.style.webkitMaskOrigin = "left";
|
||||
wordEl.style.webkitMaskSize = totalAspectStr;
|
||||
}
|
||||
const w = word.width + fadeWidth;
|
||||
const maskPos = `clamp(${-w}px,calc(${-w}px + (var(--amll-player-time) - ${
|
||||
word.startTime
|
||||
})*${
|
||||
w / Math.abs(word.endTime - word.startTime)
|
||||
}px),0px) 0px, left top`;
|
||||
wordEl.style.maskPosition = maskPos;
|
||||
wordEl.style.webkitMaskPosition = maskPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
private generateWebAnimationBasedMaskImage() {
|
||||
// 因为歌词行有可能比行内单词的结束时间早,有可能导致过渡动画提早停止出现瑕疵
|
||||
// 所以要以单词的结束时间为准
|
||||
const totalFadeDuration =
|
||||
Math.max(
|
||||
this.splittedWords.reduce((pv, w) => Math.max(w.endTime, pv), 0),
|
||||
this.lyricLine.endTime,
|
||||
) - this.lyricLine.startTime;
|
||||
this.splittedWords.forEach((word, i) => {
|
||||
const wordEl = word.mainElement;
|
||||
if (wordEl) {
|
||||
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
|
||||
const [maskImage, totalAspect] = generateFadeGradient(
|
||||
fadeWidth / (word.width + word.padding * 2),
|
||||
);
|
||||
const totalAspectStr = `${totalAspect * 100}% 100%`;
|
||||
if (this.lyricPlayer.supportMaskImage) {
|
||||
wordEl.style.maskImage = maskImage;
|
||||
wordEl.style.maskRepeat = "no-repeat";
|
||||
wordEl.style.maskOrigin = "left";
|
||||
wordEl.style.maskSize = totalAspectStr;
|
||||
} else {
|
||||
wordEl.style.webkitMaskImage = maskImage;
|
||||
wordEl.style.webkitMaskRepeat = "no-repeat";
|
||||
wordEl.style.webkitMaskOrigin = "left";
|
||||
wordEl.style.webkitMaskSize = totalAspectStr;
|
||||
}
|
||||
// 为了尽可能将渐变动画在相连的每个单词间近似衔接起来
|
||||
// 要综合每个单词的效果时间和间隙生成动画帧数组
|
||||
const widthBeforeSelf =
|
||||
this.splittedWords.slice(0, i).reduce((a, b) => a + b.width, 0) +
|
||||
(this.splittedWords[0] ? fadeWidth : 0);
|
||||
const minOffset = -(word.width + word.padding * 2 + fadeWidth);
|
||||
const clampOffset = (x: number) => Math.max(minOffset, Math.min(0, x));
|
||||
let curPos = -widthBeforeSelf - word.width - word.padding - fadeWidth;
|
||||
let timeOffset = 0;
|
||||
const frames: Keyframe[] = [];
|
||||
let lastPos = curPos;
|
||||
let lastTime = 0;
|
||||
const pushFrame = () => {
|
||||
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
|
||||
// const easing = "cubic-bezier(.33,.12,.83,.9)";
|
||||
const moveOffset = curPos - lastPos;
|
||||
const time = Math.max(0, Math.min(1, timeOffset));
|
||||
const duration = time - lastTime;
|
||||
const d = Math.abs(duration / moveOffset);
|
||||
// 因为有可能会和之前的动画有边界
|
||||
if (curPos > minOffset && lastPos < minOffset) {
|
||||
const staticTime = Math.abs(lastPos - minOffset) * d;
|
||||
const value = `${clampOffset(lastPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: lastTime + staticTime,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
}
|
||||
if (curPos > 0 && lastPos < 0) {
|
||||
const staticTime = Math.abs(lastPos) * d;
|
||||
const value = `${clampOffset(curPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: lastTime + staticTime,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
}
|
||||
const value = `${clampOffset(curPos)}px 0`;
|
||||
const frame: Keyframe = {
|
||||
offset: time,
|
||||
maskPosition: value,
|
||||
};
|
||||
frames.push(frame);
|
||||
lastPos = curPos;
|
||||
lastTime = time;
|
||||
};
|
||||
pushFrame();
|
||||
let lastTimeStamp = 0;
|
||||
this.splittedWords.forEach((otherWord, j) => {
|
||||
// 停顿
|
||||
{
|
||||
const curTimeStamp = otherWord.startTime - this.lyricLine.startTime;
|
||||
const staticDuration = curTimeStamp - lastTimeStamp;
|
||||
timeOffset += staticDuration / totalFadeDuration;
|
||||
if (staticDuration > 0) pushFrame();
|
||||
lastTimeStamp = curTimeStamp;
|
||||
}
|
||||
// 移动
|
||||
{
|
||||
const fadeDuration = otherWord.endTime - otherWord.startTime;
|
||||
timeOffset += fadeDuration / totalFadeDuration;
|
||||
curPos += otherWord.width;
|
||||
if (j === 0) {
|
||||
curPos += fadeWidth * 1.5;
|
||||
}
|
||||
if (j === this.splittedWords.length - 1) {
|
||||
curPos += fadeWidth * 0.5;
|
||||
}
|
||||
if (fadeDuration > 0) pushFrame();
|
||||
lastTimeStamp += fadeDuration;
|
||||
}
|
||||
});
|
||||
for (const a of word.maskAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
try {
|
||||
// TODO: 如果此处动画帧计算出错,需要一个后备方案
|
||||
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
|
||||
const ani = wordEl.animate(frames, {
|
||||
duration: totalFadeDuration || 1,
|
||||
id: `fade-word-${word.word}-${i}`,
|
||||
fill: "both",
|
||||
});
|
||||
ani.pause();
|
||||
word.maskAnimations = [ani];
|
||||
} catch (err) {
|
||||
console.warn("应用渐变动画发生错误", frames, totalFadeDuration, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
override setTransform(
|
||||
top: number = this.top,
|
||||
scale: number = this.scale,
|
||||
opacity = 1,
|
||||
blur = 0,
|
||||
force = false,
|
||||
delay = 0,
|
||||
) {
|
||||
super.setTransform(top, scale, opacity, blur, force, delay);
|
||||
const beforeInSight = this.isInSight;
|
||||
const enableSpring = this.lyricPlayer.getEnableSpring();
|
||||
this.top = top;
|
||||
this.scale = scale;
|
||||
this.delay = (delay * 1000) | 0;
|
||||
const main = this.element.children[0] as HTMLDivElement;
|
||||
// main.style.opacity = `${opacity *
|
||||
// (!this.hasFaded ? 1 : this.lyricPlayer._getIsNonDynamic() ? 1 : 0.3)
|
||||
// }`;
|
||||
main.style.opacity = `${opacity}`;
|
||||
// trans.style.opacity = `${subopacity}`;
|
||||
// roman.style.opacity = `${subopacity}`;
|
||||
if (force || !enableSpring) {
|
||||
if (force) this.element.classList.add(styles.tmpDisableTransition);
|
||||
// this.lineWebAnimationTransforms.posX.setTargetPosition(left);
|
||||
// this.lineWebAnimationTransforms.posY.setTargetPosition(top);
|
||||
// this.lineWebAnimationTransforms.scale.setTargetPosition(scale);
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
this.lineTransforms.scale.setPosition(scale);
|
||||
if (!enableSpring) {
|
||||
const afterInSight = this.isInSight;
|
||||
if (beforeInSight || afterInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
} else this.rebuildStyle();
|
||||
if (force)
|
||||
requestAnimationFrame(() => {
|
||||
this.element.classList.remove(styles.tmpDisableTransition);
|
||||
});
|
||||
} else {
|
||||
// this.lineWebAnimationTransforms.posX.stop();
|
||||
// this.lineWebAnimationTransforms.posY.stop();
|
||||
// this.lineWebAnimationTransforms.scale.stop();
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
this.lineTransforms.scale.setTargetPosition(scale);
|
||||
}
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.lyricPlayer.getEnableSpring()) return;
|
||||
this.lineTransforms.posY.update(delta);
|
||||
this.lineTransforms.scale.update(delta);
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
if (this.maskImageDirty) {
|
||||
this.updateMaskImage();
|
||||
}
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
if (this.lyricPlayer.getEnableSpring()) {
|
||||
this.element.style.setProperty(
|
||||
"--bright-mask-alpha",
|
||||
`${
|
||||
Math.max(
|
||||
0.0,
|
||||
Math.min(
|
||||
1.0,
|
||||
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
|
||||
) / 0.03,
|
||||
) *
|
||||
0.8 +
|
||||
0.2
|
||||
}`,
|
||||
);
|
||||
this.element.style.setProperty(
|
||||
"--dark-mask-alpha",
|
||||
`${
|
||||
Math.max(
|
||||
0.0,
|
||||
Math.min(
|
||||
1.0,
|
||||
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
|
||||
) / 0.03,
|
||||
) *
|
||||
0.2 +
|
||||
0.2
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
const computedStyle = window.getComputedStyle(this.element);
|
||||
const transform = computedStyle.transform;
|
||||
|
||||
// Extract the scale value from the transform property
|
||||
const scale = getScaleFromTransform(transform);
|
||||
|
||||
this.element.style.setProperty(
|
||||
"--bright-mask-alpha",
|
||||
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.8 + 0.2}`,
|
||||
);
|
||||
this.element.style.setProperty(
|
||||
"--dark-mask-alpha",
|
||||
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.2 + 0.2}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getDebugTargetPos(): string {
|
||||
return `[位移: ${this.top}; 缩放: ${this.scale}; 延时: ${this.delay}]`;
|
||||
}
|
||||
|
||||
get isInSight() {
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const h = this.lineSize[1];
|
||||
const b = t + h;
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
return !(t > pb + h || b < -h);
|
||||
}
|
||||
private disposeElements() {
|
||||
for (const realWord of this.splittedWords) {
|
||||
for (const a of realWord.elementAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
for (const a of realWord.maskAnimations) {
|
||||
a.cancel();
|
||||
}
|
||||
for (const sub of realWord.subElements) {
|
||||
sub.remove();
|
||||
sub.parentNode?.removeChild(sub);
|
||||
}
|
||||
realWord.elementAnimations = [];
|
||||
realWord.maskAnimations = [];
|
||||
realWord.subElements = [];
|
||||
realWord.mainElement.remove();
|
||||
realWord.mainElement.parentNode?.removeChild(realWord.mainElement);
|
||||
}
|
||||
this.splittedWords = [];
|
||||
}
|
||||
override dispose(): void {
|
||||
this.disposeElements();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import type { LyricLine } from "../../interfaces.ts";
|
||||
import "../../styles/index.css";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
import { type LyricLineBase, LyricPlayerBase } from "../base.ts";
|
||||
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
|
||||
|
||||
/**
|
||||
* 歌词行鼠标相关事件,可以获取到歌词行的索引和歌词行元素
|
||||
*/
|
||||
export class LyricLineMouseEvent extends MouseEvent {
|
||||
constructor(
|
||||
/**
|
||||
* 歌词行索引
|
||||
*/
|
||||
public readonly lineIndex: number,
|
||||
/**
|
||||
* 歌词行元素
|
||||
*/
|
||||
public readonly line: LyricLineBase,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
super(`line-${event.type}`, event);
|
||||
}
|
||||
}
|
||||
|
||||
export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 歌词播放组件,本框架的核心组件
|
||||
*
|
||||
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
|
||||
*/
|
||||
export class DomLyricPlayer extends LyricPlayerBase {
|
||||
override currentLyricLineObjects: LyricLineEl[] = [];
|
||||
|
||||
override onResize(): void {
|
||||
const computedStyles = getComputedStyle(this.element);
|
||||
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
|
||||
this.rebuildStyle();
|
||||
}
|
||||
|
||||
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
|
||||
readonly supportMaskImage = CSS.supports("mask-image", "none");
|
||||
readonly innerSize: [number, number] = [0, 0];
|
||||
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
|
||||
const evt = new LyricLineMouseEvent(
|
||||
this.lyricLinesIndexes.get(e.line) ?? -1,
|
||||
e.line,
|
||||
e,
|
||||
);
|
||||
if (!this.dispatchEvent(evt)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 是否为非逐词歌词
|
||||
* @internal
|
||||
*/
|
||||
_getIsNonDynamic() {
|
||||
return this.isNonDynamic;
|
||||
}
|
||||
private _baseFontSize = Number.parseFloat(
|
||||
getComputedStyle(this.element).fontSize,
|
||||
);
|
||||
public get baseFontSize() {
|
||||
return this._baseFontSize;
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.onResize();
|
||||
this.element.classList.add("amll-lyric-player", "dom");
|
||||
if (this.disableSpring) {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStyle() {
|
||||
// const width = this.innerSize[0];
|
||||
// const height = this.innerSize[1];
|
||||
// this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
|
||||
// this.element.style.setProperty(
|
||||
// "--amll-lp-height",
|
||||
// `${height.toFixed(4)}px`,
|
||||
// );
|
||||
}
|
||||
|
||||
override setWordFadeWidth(value = 0.5) {
|
||||
super.setWordFadeWidth(value);
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.updateMaskImageSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
override setLyricLines(lines: LyricLine[], initialTime = 0) {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
if (this.hasDuetLine) {
|
||||
this.element.classList.add(styles.hasDuetLine);
|
||||
} else {
|
||||
this.element.classList.remove(styles.hasDuetLine);
|
||||
}
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty("--amll-player-time", `${initialTime}`);
|
||||
}
|
||||
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.removeMouseEventListener("click", this.onLineClickedHandler);
|
||||
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
line.dispose();
|
||||
}
|
||||
|
||||
// 创建新的歌词行元素
|
||||
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
|
||||
const lineEl = new LyricLineEl(this, line);
|
||||
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
|
||||
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
|
||||
// 不立即挂载到 DOM,进入视图(含 overscan)后在 LyricLineEl 内部挂载
|
||||
this.lyricLinesIndexes.set(lineEl, i);
|
||||
// 仍需建立元素到行对象的映射,供 ResizeObserver 使用
|
||||
this.lyricLineElementMap.set(lineEl.getElement(), lineEl);
|
||||
return lineEl;
|
||||
});
|
||||
|
||||
this.setLinePosXSpringParams({});
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.calcLayout(true);
|
||||
// 触发一次更新以便立即挂载在视区/overscan 内的行元素
|
||||
this.update(0);
|
||||
}
|
||||
|
||||
override pause() {
|
||||
super.pause();
|
||||
this.element.classList.remove("playing");
|
||||
this.interludeDots.pause();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.pause();
|
||||
}
|
||||
}
|
||||
|
||||
override resume() {
|
||||
super.resume();
|
||||
this.element.classList.add("playing");
|
||||
this.interludeDots.resume();
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.resume();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta = 0) {
|
||||
if (!this.initialLayoutFinished) return;
|
||||
super.update(delta);
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty(
|
||||
"--amll-player-time",
|
||||
`${this.currentTime}`,
|
||||
);
|
||||
}
|
||||
if (!this.isPageVisible) return;
|
||||
const deltaS = delta / 1000;
|
||||
this.interludeDots.update(delta);
|
||||
this.bottomLine.update(deltaS);
|
||||
for (const line of this.currentLyricLineObjects) {
|
||||
line.update(deltaS);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.element.remove();
|
||||
for (const el of this.currentLyricLineObjects) {
|
||||
el.dispose();
|
||||
}
|
||||
this.bottomLine.dispose();
|
||||
this.interludeDots.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Disposable, HasElement } from "../../interfaces.ts";
|
||||
import styles from "../../styles/lyric-player.module.css";
|
||||
|
||||
function easeInOutBack(x: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c2 = c1 * 1.525;
|
||||
|
||||
return x < 0.5
|
||||
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
|
||||
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutExpo(x: number): number {
|
||||
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
|
||||
}
|
||||
|
||||
const clamp = (min: number, cur: number, max: number) =>
|
||||
Math.max(min, Math.min(cur, max));
|
||||
|
||||
export class InterludeDots implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private dot0: HTMLElement = document.createElement("span");
|
||||
private dot1: HTMLElement = document.createElement("span");
|
||||
private dot2: HTMLElement = document.createElement("span");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private playing = true;
|
||||
private lastStyle = "";
|
||||
private currentInterlude?: [number, number];
|
||||
private currentTime = 0;
|
||||
private targetBreatheDuration = 1500;
|
||||
constructor() {
|
||||
this.element.className = styles.interludeDots;
|
||||
this.element.appendChild(this.dot0);
|
||||
this.element.appendChild(this.dot1);
|
||||
this.element.appendChild(this.dot2);
|
||||
}
|
||||
getElement() {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(left: number = this.left, top: number = this.top) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.update();
|
||||
}
|
||||
setInterlude(interlude?: [number, number]) {
|
||||
this.currentInterlude = interlude;
|
||||
this.currentTime = interlude?.[0] ?? 0;
|
||||
if (interlude) {
|
||||
this.element.classList.add(styles.enabled);
|
||||
} else {
|
||||
this.element.classList.remove(styles.enabled);
|
||||
}
|
||||
}
|
||||
pause() {
|
||||
this.playing = false;
|
||||
this.element.classList.remove(styles.playing);
|
||||
}
|
||||
resume() {
|
||||
this.playing = true;
|
||||
this.element.classList.add(styles.playing);
|
||||
}
|
||||
update(delta = 0) {
|
||||
if (!this.playing) return;
|
||||
this.currentTime += delta;
|
||||
let curStyle = "";
|
||||
|
||||
curStyle += `transform:translate(${this.left.toFixed(
|
||||
2,
|
||||
)}px, ${this.top.toFixed(2)}px)`;
|
||||
|
||||
// 计算缩放大小
|
||||
|
||||
if (this.currentInterlude) {
|
||||
const interludeDuration =
|
||||
this.currentInterlude[1] - this.currentInterlude[0];
|
||||
const currentDuration = this.currentTime - this.currentInterlude[0];
|
||||
if (currentDuration <= interludeDuration) {
|
||||
const breatheDuration =
|
||||
interludeDuration /
|
||||
Math.ceil(interludeDuration / this.targetBreatheDuration);
|
||||
let scale = 1;
|
||||
let globalOpacity = 1;
|
||||
|
||||
scale *=
|
||||
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
|
||||
20 +
|
||||
1;
|
||||
|
||||
if (currentDuration < 2000) {
|
||||
scale *= easeOutExpo(currentDuration / 2000);
|
||||
}
|
||||
|
||||
if (currentDuration < 500) {
|
||||
globalOpacity = 0;
|
||||
} else if (currentDuration < 1000) {
|
||||
globalOpacity *= (currentDuration - 500) / 500;
|
||||
}
|
||||
|
||||
if (interludeDuration - currentDuration < 750) {
|
||||
scale *=
|
||||
1 -
|
||||
easeInOutBack(
|
||||
(750 - (interludeDuration - currentDuration)) / 750 / 2,
|
||||
);
|
||||
}
|
||||
if (interludeDuration - currentDuration < 375) {
|
||||
globalOpacity *= clamp(
|
||||
0,
|
||||
(interludeDuration - currentDuration) / 375,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const dotsDuration = Math.max(0, interludeDuration - 750);
|
||||
|
||||
scale = Math.max(0, scale) * 0.7;
|
||||
|
||||
curStyle += ` scale(${scale})`;
|
||||
|
||||
const dot0Opacity = clamp(
|
||||
0.25,
|
||||
((currentDuration * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot1Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot2Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
this.dot0.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot0Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot1.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot1Opacity),
|
||||
1,
|
||||
)}`;
|
||||
this.dot2.style.opacity = `${clamp(
|
||||
0,
|
||||
Math.max(0, globalOpacity * dot2Opacity),
|
||||
1,
|
||||
)}`;
|
||||
} else {
|
||||
curStyle += " scale(0)";
|
||||
this.dot0.style.opacity = "0";
|
||||
this.dot1.style.opacity = "0";
|
||||
this.dot2.style.opacity = "0";
|
||||
}
|
||||
|
||||
curStyle += ";";
|
||||
|
||||
if (this.lastStyle !== curStyle) {
|
||||
this.element.setAttribute("style", curStyle);
|
||||
this.lastStyle = curStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
+1050
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
import { DomLyricPlayer } from "./dom/index.ts";
|
||||
|
||||
export { LyricPlayerBase } from "./base.ts";
|
||||
export * from "./canvas/index.ts";
|
||||
export * from "./dom-slim/index.ts";
|
||||
export * from "./dom/index.ts";
|
||||
|
||||
/**
|
||||
* 歌词中不雅用语的掩码模式
|
||||
*/
|
||||
enum MaskObsceneWordsMode {
|
||||
/** 禁用任何不雅用语掩码 */
|
||||
Disabled = "",
|
||||
/** 完全掩码所有不雅用语 */
|
||||
FullMask = "full-mask",
|
||||
// TODO: 更多模式
|
||||
}
|
||||
|
||||
export {
|
||||
MaskObsceneWordsMode,
|
||||
/**
|
||||
* 默认导出的歌词播放组件
|
||||
*/
|
||||
DomLyricPlayer as LyricPlayer,
|
||||
};
|
||||
Vendored
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 调试 MG 渲染器的测试脚本
|
||||
*
|
||||
* @author SteveXMH
|
||||
*/
|
||||
import GUI from "lil-gui";
|
||||
import Stats from "stats.js";
|
||||
import { MeshGradientRenderer } from "./bg-render";
|
||||
|
||||
const debugValues = {
|
||||
controlPointSize: 4,
|
||||
subdivideDepth: 15,
|
||||
wireFrame: false,
|
||||
};
|
||||
|
||||
const canvas = document.getElementById("bg")! as HTMLCanvasElement;
|
||||
const mgRenderer = new MeshGradientRenderer(canvas);
|
||||
// mgRenderer.setAlbum("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAADUExURf///6fEG8gAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAKSURBVBjTY2AAAAACAAGYY2zXAAAAAElFTkSuQmCC");
|
||||
mgRenderer.setManualControl(true);
|
||||
mgRenderer.setFPS(Number.POSITIVE_INFINITY);
|
||||
|
||||
function updateControlPointDraggers() {
|
||||
for (const el of document.querySelectorAll(".dragger")) {
|
||||
const x = Number.parseInt(el.getAttribute("x") ?? "");
|
||||
const y = Number.parseInt(el.getAttribute("y") ?? "");
|
||||
const point = mgRenderer.getControlPoint(x, y);
|
||||
if (point === undefined) return;
|
||||
(el as HTMLElement).style.left = `${(point.location.x + 1) * 50}%`;
|
||||
(el as HTMLElement).style.top = `${(1 - point.location.y) * 50}%`;
|
||||
}
|
||||
}
|
||||
|
||||
let draggerGui: GUI | undefined;
|
||||
function setActiveDragger(x: number, y: number) {
|
||||
if (draggerGui) {
|
||||
draggerGui.destroy();
|
||||
draggerGui = undefined;
|
||||
}
|
||||
const point = mgRenderer.getControlPoint(x, y);
|
||||
if (point) {
|
||||
draggerGui = gui.addFolder(`控制点 (${x}, ${y})`);
|
||||
const obj = {
|
||||
uAngle: point.uRot,
|
||||
vAngle: point.vRot,
|
||||
uScale: point.uScale,
|
||||
vScale: point.vScale,
|
||||
};
|
||||
draggerGui
|
||||
.add(obj, "uAngle", -180, 180)
|
||||
.name("横向扭曲角度")
|
||||
.onChange((v: number) => {
|
||||
point.uRot = (v * Math.PI) / 180;
|
||||
updateResult();
|
||||
});
|
||||
draggerGui
|
||||
.add(obj, "vAngle", -180, 180)
|
||||
.name("纵向扭曲角度")
|
||||
.onChange((v: number) => {
|
||||
point.vRot = (v * Math.PI) / 180;
|
||||
updateResult();
|
||||
});
|
||||
draggerGui
|
||||
.add(obj, "uScale", 0.1, 10)
|
||||
.name("横向缩放")
|
||||
.onChange((v: number) => {
|
||||
point.uScale = v;
|
||||
updateResult();
|
||||
});
|
||||
draggerGui
|
||||
.add(obj, "vScale", 0.1, 10)
|
||||
.name("纵向缩放")
|
||||
.onChange((v: number) => {
|
||||
point.vScale = v;
|
||||
updateResult();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateControlPointDraggers);
|
||||
|
||||
const resultTextArea = document.getElementById(
|
||||
"result",
|
||||
)! as HTMLTextAreaElement;
|
||||
resultTextArea.value = "// 控制点的设置代码将会在这里显示";
|
||||
function updateResult() {
|
||||
const result = [
|
||||
`preset(${debugValues.controlPointSize}, ${debugValues.controlPointSize}, [`,
|
||||
];
|
||||
for (let y = 0; y < debugValues.controlPointSize; y++) {
|
||||
for (let x = 0; x < debugValues.controlPointSize; x++) {
|
||||
const point = mgRenderer.getControlPoint(x, y);
|
||||
if (point === undefined) continue;
|
||||
result.push(
|
||||
` p(${x}, ${y}, ${point.location.x}, ${point.location.y}, ${point.uRot}, ${point.vRot}, ${point.uScale}, ${point.vScale}),`,
|
||||
);
|
||||
}
|
||||
}
|
||||
result.push("]),");
|
||||
resultTextArea.value = result.join("\n");
|
||||
}
|
||||
|
||||
function resizeControlPoint() {
|
||||
document
|
||||
.querySelectorAll(".dragger")
|
||||
.forEach((el) => el.parentElement?.removeChild(el));
|
||||
mgRenderer.resizeControlPoints(
|
||||
debugValues.controlPointSize,
|
||||
debugValues.controlPointSize,
|
||||
);
|
||||
mgRenderer.resetSubdivition(debugValues.subdivideDepth);
|
||||
|
||||
for (let y = 0; y < debugValues.controlPointSize; y++) {
|
||||
for (let x = 0; x < debugValues.controlPointSize; x++) {
|
||||
const point = mgRenderer.getControlPoint(x, y);
|
||||
if (point === undefined) continue;
|
||||
const dragger = document.createElement("div");
|
||||
const draggerInput = document.createElement("input");
|
||||
draggerInput.type = "color";
|
||||
draggerInput.style.position = "absolute";
|
||||
draggerInput.style.visibility = "hidden";
|
||||
dragger.appendChild(draggerInput);
|
||||
dragger.setAttribute("x", `${x}`);
|
||||
dragger.setAttribute("y", `${y}`);
|
||||
dragger.className = "dragger";
|
||||
dragger.style.left = `${(x * 100) / (debugValues.controlPointSize - 1)}%`;
|
||||
dragger.style.top = `${
|
||||
((debugValues.controlPointSize - y - 1) * 100) /
|
||||
(debugValues.controlPointSize - 1)
|
||||
}%`;
|
||||
draggerInput.addEventListener("input", () => {
|
||||
// mgRenderer.getControlPoint(x, y).color = dragger.value;
|
||||
const c = draggerInput.value;
|
||||
console.log(c);
|
||||
dragger.style.backgroundColor = c;
|
||||
const color = c.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
|
||||
if (color) {
|
||||
point.color.r = Number.parseInt(color[1], 16) / 255;
|
||||
point.color.g = Number.parseInt(color[2], 16) / 255;
|
||||
point.color.b = Number.parseInt(color[3], 16) / 255;
|
||||
dragger.setAttribute("r", `${point.color.r}`);
|
||||
dragger.setAttribute("g", `${point.color.g}`);
|
||||
dragger.setAttribute("b", `${point.color.b}`);
|
||||
updateResult();
|
||||
}
|
||||
});
|
||||
let dragging = false;
|
||||
dragger.addEventListener("mousedown", (evt) => {
|
||||
evt.stopPropagation();
|
||||
function onMouseMove(evt: MouseEvent) {
|
||||
dragger.style.left = `${Math.min(
|
||||
window.innerWidth,
|
||||
Math.max(0, evt.clientX),
|
||||
)}px`;
|
||||
dragger.style.top = `${Math.min(
|
||||
window.innerHeight,
|
||||
Math.max(0, evt.clientY),
|
||||
)}px`;
|
||||
if (point) {
|
||||
point.location.x = Math.max(
|
||||
-1,
|
||||
Math.min(1, (evt.clientX / window.innerWidth) * 2 - 1),
|
||||
);
|
||||
point.location.y = Math.max(
|
||||
-1,
|
||||
Math.min(1, -((evt.clientY / window.innerHeight) * 2 - 1)),
|
||||
);
|
||||
}
|
||||
dragging = true;
|
||||
updateResult();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
function onMouseUp(evt: MouseEvent) {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
} else if (dragger.classList.contains("active")) {
|
||||
draggerInput.click();
|
||||
} else {
|
||||
for (const el of document.querySelectorAll(".dragger.active")) {
|
||||
el.classList.remove("active");
|
||||
}
|
||||
dragger.classList.add("active");
|
||||
setActiveDragger(x, y);
|
||||
}
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
evt.stopPropagation();
|
||||
}
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
document.body.appendChild(dragger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subdivide() {
|
||||
mgRenderer.resetSubdivition(debugValues.subdivideDepth);
|
||||
}
|
||||
|
||||
mgRenderer.setAlbum("bigsur.png").then(() => {
|
||||
resizeControlPoint();
|
||||
subdivide();
|
||||
});
|
||||
|
||||
const gui = new GUI();
|
||||
gui.close();
|
||||
gui.title("MG Renderer 调试页面");
|
||||
gui
|
||||
.add(debugValues, "controlPointSize", 3, 10, 1)
|
||||
.name("控制点矩阵大小")
|
||||
.onFinishChange(resizeControlPoint);
|
||||
gui
|
||||
.add(debugValues, "subdivideDepth", 2, 50, 1)
|
||||
.name("细分深度")
|
||||
.onChange(subdivide);
|
||||
gui
|
||||
.add(debugValues, "wireFrame")
|
||||
.name("线框模式")
|
||||
.onChange((v: boolean) => mgRenderer.setWireFrame(v));
|
||||
|
||||
const stats = new Stats();
|
||||
stats.showPanel(0);
|
||||
stats.dom.style.left = "50px";
|
||||
document.body.appendChild(stats.dom);
|
||||
const frame = () => {
|
||||
stats.end();
|
||||
stats.begin();
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
给不需要重命名的全局类名的样式
|
||||
*/
|
||||
|
||||
/* 给歌词视图元素应用的样式 */
|
||||
.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.2em;
|
||||
}
|
||||
|
||||
&.dom-slim {
|
||||
--amll-lp-line-width-aspect: 0.8;
|
||||
--amll-lp-line-padding-x: 1em;
|
||||
--amll-lp-bg-line-scale: 0.7;
|
||||
overflow: hidden scroll;
|
||||
user-select: none;
|
||||
box-sizing: content-box;
|
||||
z-index: 1;
|
||||
line-height: 1.2em;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> * {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 100% 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.amll-lyric-player {
|
||||
--amll-lp-line-width-aspect: 1;
|
||||
--amll-lp-line-padding-x: 0;
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
给各个内部元素使用的样式
|
||||
*/
|
||||
|
||||
/* 给每行歌词应用的样式 */
|
||||
.lyricLine {
|
||||
position: absolute;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: left;
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 0.5em 1em;
|
||||
contain: content;
|
||||
will-change: transform;
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.25em;
|
||||
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&.dirty {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:has(> *):hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
|
||||
&:has(> *):active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0.0001;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
scale 0.5s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
|
||||
padding: 1vh
|
||||
calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
|
||||
|
||||
&.active {
|
||||
transition:
|
||||
opacity 0.5s 0.25s,
|
||||
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
&:hover .lyricLine {
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:global(.playing)) > .lyricBgLine {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
transition: opacity 0.3s 0.1s;
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
|
||||
text-wrap: balance;
|
||||
|
||||
word-break: keep-all;
|
||||
|
||||
overflow-wrap: break-word;
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.romanWord {
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > span,
|
||||
span.emphasizeWrapper {
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
|
||||
&.emphasize,
|
||||
span.emphasize {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
& > span {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.disableSpring > * {
|
||||
transition:
|
||||
filter 0.25s,
|
||||
transform 0.5s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
transition: opacity 0.25s;
|
||||
transform-origin: center;
|
||||
width: fit-content;
|
||||
padding: 2.5% 0.75em;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
transition: none !important;
|
||||
}
|
||||
Vendored
+390
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 此处是一个简易的组件加载测试脚本,用来调试歌词
|
||||
*
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import * as lyrics from "@applemusic-like-lyrics/lyric";
|
||||
import {
|
||||
parseLrc,
|
||||
parseLys,
|
||||
parseQrc,
|
||||
parseTTML,
|
||||
parseYrc,
|
||||
type LyricLine as RawLyricLine,
|
||||
} from "@applemusic-like-lyrics/lyric";
|
||||
import GUI from "lil-gui";
|
||||
import Stats from "stats.js";
|
||||
import type { LyricLine } from ".";
|
||||
import {
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer,
|
||||
PixiRenderer,
|
||||
} from "./bg-render";
|
||||
import { DomLyricPlayer, type LyricLineMouseEvent } from "./lyric-player";
|
||||
import type { SpringParams } from "./utils/spring";
|
||||
|
||||
(window as any).lyrics = lyrics;
|
||||
|
||||
const audio = document.createElement("audio");
|
||||
audio.volume = 0.5;
|
||||
audio.preload = "auto";
|
||||
|
||||
const debugValues = {
|
||||
lyric: new URL(location.href).searchParams.get("lyric") || "",
|
||||
music: new URL(location.href).searchParams.get("music") || "",
|
||||
album: new URL(location.href).searchParams.get("album") || "",
|
||||
enableSpring: true,
|
||||
bgFPS: 60,
|
||||
bgMode: new URL(location.href).searchParams.get("bg") || "mg",
|
||||
bgScale: 1,
|
||||
bgFlowSpeed: 2,
|
||||
bgPlaying: true,
|
||||
bgStaticMode: true,
|
||||
currentTime: 0,
|
||||
enableBlur: true,
|
||||
playing: false,
|
||||
async mockPlay() {
|
||||
this.playing = true;
|
||||
const startTime = Date.now();
|
||||
const baseTime = this.currentTime * 1000;
|
||||
while (this.playing && this.currentTime < 300) {
|
||||
const time = Date.now() - startTime;
|
||||
this.currentTime = (baseTime + time) / 1000;
|
||||
progress.updateDisplay();
|
||||
lyricPlayer.setCurrentTime(baseTime + time);
|
||||
await waitFrame();
|
||||
}
|
||||
},
|
||||
play() {
|
||||
this.playing = true;
|
||||
audio.load();
|
||||
audio.play();
|
||||
},
|
||||
pause() {
|
||||
this.playing = false;
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
lyricPlayer.resume();
|
||||
} else {
|
||||
audio.pause();
|
||||
lyricPlayer.pause();
|
||||
}
|
||||
},
|
||||
fadeWidth: 0.5,
|
||||
lineSprings: {
|
||||
posX: {
|
||||
mass: 1,
|
||||
damping: 10,
|
||||
stiffness: 100,
|
||||
soft: false,
|
||||
} as SpringParams,
|
||||
posY: {
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
stiffness: 100,
|
||||
soft: false,
|
||||
} as SpringParams,
|
||||
scale: {
|
||||
mass: 1,
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
soft: false,
|
||||
} as SpringParams,
|
||||
},
|
||||
};
|
||||
|
||||
function recreateBGRenderer(mode: string) {
|
||||
window.globalBackground?.dispose();
|
||||
if (mode === "pixi") {
|
||||
window.globalBackground = BackgroundRender.new(PixiRenderer);
|
||||
} else if (mode === "mg") {
|
||||
window.globalBackground = BackgroundRender.new(MeshGradientRenderer);
|
||||
} else {
|
||||
throw new Error("Unknown renderer mode");
|
||||
}
|
||||
const bg = window.globalBackground;
|
||||
bg.setFPS(debugValues.bgFPS);
|
||||
bg.setRenderScale(debugValues.bgScale);
|
||||
bg.setStaticMode(debugValues.bgStaticMode);
|
||||
bg.getElement().style.position = "absolute";
|
||||
bg.getElement().style.top = "0";
|
||||
bg.getElement().style.left = "0";
|
||||
bg.getElement().style.width = "100%";
|
||||
bg.getElement().style.height = "100%";
|
||||
bg.setAlbum(debugValues.album);
|
||||
}
|
||||
|
||||
audio.src = debugValues.music;
|
||||
audio.load();
|
||||
|
||||
const gui = new GUI();
|
||||
gui.close();
|
||||
|
||||
gui.title("AMLL 歌词测试页面");
|
||||
gui
|
||||
.add(debugValues, "lyric")
|
||||
.name("歌词文件")
|
||||
.onFinishChange(async (url: string) => {
|
||||
lyricPlayer.setLyricLines(
|
||||
parseTTML(await (await fetch(url)).text()).lines.map(mapTTMLLyric),
|
||||
);
|
||||
});
|
||||
gui
|
||||
.add(debugValues, "music")
|
||||
.name("歌曲")
|
||||
.onFinishChange((v: string) => {
|
||||
audio.src = v;
|
||||
});
|
||||
gui
|
||||
.add(debugValues, "album")
|
||||
.name("专辑图片")
|
||||
.onFinishChange((v: string) => {
|
||||
window.globalBackground.setAlbum(v);
|
||||
});
|
||||
|
||||
const bgGui = gui.addFolder("背景");
|
||||
bgGui
|
||||
.add(debugValues, "bgPlaying")
|
||||
.name("播放")
|
||||
.onFinishChange((v: boolean) => {
|
||||
if (v) {
|
||||
window.globalBackground.resume();
|
||||
} else {
|
||||
window.globalBackground.pause();
|
||||
}
|
||||
});
|
||||
bgGui
|
||||
.add(debugValues, "bgMode", ["pixi", "mg"])
|
||||
.name("背景渲染器")
|
||||
.onFinishChange((v: string) => {
|
||||
recreateBGRenderer(v);
|
||||
});
|
||||
bgGui
|
||||
.add(debugValues, "bgScale", 0.01, 1, 0.01)
|
||||
.name("分辨率比率")
|
||||
.onChange((v: number) => {
|
||||
window.globalBackground.setRenderScale(v);
|
||||
});
|
||||
bgGui
|
||||
.add(debugValues, "bgFPS", 1, 60, 1)
|
||||
.name("帧率")
|
||||
.onFinishChange((v: number) => {
|
||||
window.globalBackground.setFPS(v);
|
||||
});
|
||||
bgGui
|
||||
.add(debugValues, "bgFlowSpeed", 0, 10, 0.1)
|
||||
.name("流动速度")
|
||||
.onFinishChange((v: number) => {
|
||||
window.globalBackground.setFlowSpeed(v);
|
||||
});
|
||||
bgGui
|
||||
.add(debugValues, "bgStaticMode")
|
||||
.name("静态模式")
|
||||
.onFinishChange((v: boolean) => {
|
||||
window.globalBackground.setStaticMode(v);
|
||||
});
|
||||
|
||||
{
|
||||
const animation = gui.addFolder("歌词行动画/效果");
|
||||
animation
|
||||
.add(debugValues, "fadeWidth", 0, 10, 0.01)
|
||||
.name("歌词渐变宽度")
|
||||
.onChange((v: number) => {
|
||||
lyricPlayer.setWordFadeWidth(v);
|
||||
});
|
||||
animation
|
||||
.add(debugValues, "enableBlur")
|
||||
.name("启用歌词模糊")
|
||||
.onChange((v: boolean) => {
|
||||
lyricPlayer.setEnableBlur(v);
|
||||
});
|
||||
animation
|
||||
.add(debugValues, "enableSpring")
|
||||
.name("使用弹簧动画")
|
||||
.onChange((v: boolean) => {
|
||||
lyricPlayer.setEnableSpring(v);
|
||||
});
|
||||
function addSpringDbg(name: string, obj: SpringParams, onChange: () => void) {
|
||||
const x = animation.addFolder(name);
|
||||
x.close();
|
||||
x.add(obj, "mass").name("质量").onFinishChange(onChange);
|
||||
x.add(obj, "damping").name("阻力").onFinishChange(onChange);
|
||||
x.add(obj, "stiffness").name("弹性").onFinishChange(onChange);
|
||||
x.add(obj, "soft")
|
||||
.name("强制软弹簧(当阻力小于 1 时有用)")
|
||||
.onFinishChange(onChange);
|
||||
}
|
||||
addSpringDbg("水平位移弹簧", debugValues.lineSprings.posX, () => {
|
||||
lyricPlayer.setLinePosXSpringParams(debugValues.lineSprings.posX);
|
||||
});
|
||||
addSpringDbg("垂直位移弹簧", debugValues.lineSprings.posY, () => {
|
||||
lyricPlayer.setLinePosYSpringParams(debugValues.lineSprings.posY);
|
||||
});
|
||||
addSpringDbg("缩放弹簧", debugValues.lineSprings.scale, () => {
|
||||
lyricPlayer.setLineScaleSpringParams(debugValues.lineSprings.scale);
|
||||
});
|
||||
}
|
||||
|
||||
const playerGui = gui.addFolder("音乐播放器");
|
||||
const progress = playerGui
|
||||
.add(debugValues, "currentTime")
|
||||
.min(0)
|
||||
.step(1)
|
||||
.name("当前进度")
|
||||
.onChange((v: number) => {
|
||||
audio.currentTime = v;
|
||||
lyricPlayer.setCurrentTime(v * 1000, true);
|
||||
});
|
||||
playerGui.add(debugValues, "play").name("加载/播放");
|
||||
playerGui.add(debugValues, "pause").name("暂停/继续");
|
||||
|
||||
const lyricPlayer = new DomLyricPlayer();
|
||||
|
||||
lyricPlayer.addEventListener("line-click", (evt) => {
|
||||
const e = evt as LyricLineMouseEvent;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
console.log(e.line, e.lineIndex);
|
||||
audio.currentTime = e.line.getLine().startTime / 1000;
|
||||
});
|
||||
|
||||
const stats = new Stats();
|
||||
stats.showPanel(0);
|
||||
document.body.appendChild(stats.dom);
|
||||
let lastTime = -1;
|
||||
const frame = (time: number) => {
|
||||
stats.end();
|
||||
if (lastTime === -1) {
|
||||
lastTime = time;
|
||||
}
|
||||
if (!audio.paused) {
|
||||
const time = (audio.currentTime * 1000) | 0;
|
||||
debugValues.currentTime = (time / 1000) | 0;
|
||||
progress.max(audio.duration | 0);
|
||||
progress.updateDisplay();
|
||||
lyricPlayer.setCurrentTime(time);
|
||||
}
|
||||
lyricPlayer.update(time - lastTime);
|
||||
lastTime = time;
|
||||
stats.begin();
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
globalLyricPlayer: DomLyricPlayer;
|
||||
globalBackground:
|
||||
| BackgroundRender<PixiRenderer>
|
||||
| BackgroundRender<MeshGradientRenderer>;
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).globalLyricPlayer = lyricPlayer;
|
||||
|
||||
const waitFrame = (): Promise<number> =>
|
||||
new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const mapLyric = (
|
||||
line: RawLyricLine,
|
||||
_i: number,
|
||||
_lines: RawLyricLine[],
|
||||
): LyricLine => ({
|
||||
words: line.words.map((word) => ({ obscene: false, romanWord: "", ...word })),
|
||||
startTime: line.words[0]?.startTime ?? 0,
|
||||
endTime:
|
||||
line.words[line.words.length - 1]?.endTime ?? Number.POSITIVE_INFINITY,
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
});
|
||||
|
||||
const mapTTMLLyric = (line: RawLyricLine): LyricLine => ({
|
||||
...line,
|
||||
words: line.words.map((word) => ({ obscene: false, romanWord: "", ...word })),
|
||||
romanLyric: "",
|
||||
});
|
||||
|
||||
async function loadLyric() {
|
||||
const lyricFile = debugValues.lyric;
|
||||
const content = await (await fetch(lyricFile)).text();
|
||||
if (lyricFile.endsWith(".ttml")) {
|
||||
lyricPlayer.setLyricLines(parseTTML(content).lines.map(mapTTMLLyric));
|
||||
} else if (lyricFile.endsWith(".lrc")) {
|
||||
lyricPlayer.setLyricLines(parseLrc(content).map(mapLyric));
|
||||
} else if (lyricFile.endsWith(".yrc")) {
|
||||
lyricPlayer.setLyricLines(parseYrc(content).map(mapLyric));
|
||||
} else if (lyricFile.endsWith(".lys")) {
|
||||
lyricPlayer.setLyricLines(parseLys(content).map(mapLyric));
|
||||
} else if (lyricFile.endsWith(".qrc")) {
|
||||
lyricPlayer.setLyricLines(parseQrc(content).map(mapLyric));
|
||||
} else if (lyricFile === "bug") {
|
||||
const buildLyricLines = (
|
||||
lyric: string,
|
||||
startTime = 1000,
|
||||
otherParams: Partial<LyricLine> = {},
|
||||
): LyricLine => {
|
||||
let curTime = startTime;
|
||||
const words = [];
|
||||
for (const word of lyric.split("|")) {
|
||||
const [text, duration] = word.split(",");
|
||||
const endTime = curTime + Number.parseInt(duration);
|
||||
words.push({
|
||||
word: text,
|
||||
romanWord: "",
|
||||
startTime: curTime,
|
||||
endTime,
|
||||
obscene: false,
|
||||
});
|
||||
curTime = endTime;
|
||||
}
|
||||
return {
|
||||
startTime,
|
||||
endTime: curTime + 3000,
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
words,
|
||||
...otherParams,
|
||||
};
|
||||
};
|
||||
|
||||
const DEMO_LYRIC: LyricLine[] = [
|
||||
buildLyricLines(
|
||||
"Apple ,750|Music ,500|Like ,500|Ly,400|ri,500|cs ,250",
|
||||
1000,
|
||||
),
|
||||
buildLyricLines("BG ,750|Lyrics ,1000", 2000, {
|
||||
isBG: true,
|
||||
}),
|
||||
buildLyricLines("Next ,1000|Lyrics,1000", 2500, {
|
||||
// isDuet: true,
|
||||
}),
|
||||
];
|
||||
|
||||
lyricPlayer.setLyricLines(DEMO_LYRIC);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
recreateBGRenderer(debugValues.bgMode);
|
||||
audio.style.display = "none";
|
||||
// lyricPlayer.getBottomLineElement().innerHTML = "Test Bottom Line";
|
||||
const player = document.getElementById("player");
|
||||
if (player) {
|
||||
player.appendChild(audio);
|
||||
player.appendChild(window.globalBackground.getElement());
|
||||
player.appendChild(lyricPlayer.getElement());
|
||||
}
|
||||
if (!debugValues.enableSpring) {
|
||||
lyricPlayer.setEnableSpring(false);
|
||||
}
|
||||
await loadLyric();
|
||||
// debugValues.play();
|
||||
// debugValues.currentTime = 34;
|
||||
// debugValues.mockPlay();
|
||||
})();
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.glsl?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// biome-ignore lint/complexity/noBannedTypes: for debounce function
|
||||
export function debounce<T extends Function>(cb: T, wait = 20) {
|
||||
let h = 0;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: function can be any
|
||||
const callable = (...args: any) => {
|
||||
clearTimeout(h);
|
||||
h = setTimeout(() => cb(...args), wait);
|
||||
};
|
||||
return callable;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noBannedTypes: for debounce function
|
||||
export function debounceFrame<T extends Function>(cb: T, frameTime = 1) {
|
||||
let h = 0;
|
||||
let ft = frameTime;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: function can be any
|
||||
const callable = (...args: any) => {
|
||||
ft = frameTime;
|
||||
cancelAnimationFrame(h);
|
||||
const onCB = () => {
|
||||
if (--ft <= 0) {
|
||||
cb(...args);
|
||||
} else {
|
||||
h = requestAnimationFrame(onCB);
|
||||
}
|
||||
};
|
||||
h = requestAnimationFrame(onCB);
|
||||
};
|
||||
return callable;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export function derivative(f: (x: number) => number) {
|
||||
const h = 0.001;
|
||||
return (x: number) => (f(x + h) - f(x - h)) / (2 * h);
|
||||
}
|
||||
|
||||
export function getVelocity(f: (t: number) => number): (t: number) => number {
|
||||
return derivative(f);
|
||||
}
|
||||
Vendored
+4
@@ -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));
|
||||
+59
@@ -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() {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
let flag = 0;
|
||||
let chunkIndex = 0;
|
||||
let chunkOffset = 0;
|
||||
return {
|
||||
next: () => {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
|
||||
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
|
||||
return (this.flags[chunkIndex] & (1 << chunkOffset)) !== 0;
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export const isCJK = (char: string) => {
|
||||
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
|
||||
};
|
||||
Vendored
+120
@@ -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() {
|
||||
return (
|
||||
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
||||
this.queueParams === undefined &&
|
||||
this.queuePosition === undefined
|
||||
);
|
||||
}
|
||||
setPosition(targetPosition: number) {
|
||||
this.targetPosition = targetPosition;
|
||||
this.currentPosition = targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
}
|
||||
update(delta = 0) {
|
||||
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) {
|
||||
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) {
|
||||
if (delay > 0) {
|
||||
this.queuePosition = {
|
||||
...(this.queuePosition ?? {}),
|
||||
position: targetPosition,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.targetPosition = targetPosition;
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
getCurrentPosition() {
|
||||
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;
|
||||
};
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import type { LyricWord } from "../interfaces.ts";
|
||||
import { isCJK } from "./is-cjk.ts";
|
||||
|
||||
const hasSegmenter =
|
||||
typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined";
|
||||
|
||||
/**
|
||||
* 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
|
||||
*
|
||||
* 例如输入:`["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 atoms: LyricWord[] = [];
|
||||
|
||||
for (const w of words) {
|
||||
const content = w.word.trim();
|
||||
const isSpace = content.length === 0;
|
||||
|
||||
if (isSpace) {
|
||||
atoms.push({ ...w });
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = w.word.split(/(\s+)/).filter((p) => p.length > 0);
|
||||
|
||||
let currentOffset = 0;
|
||||
const totalLength = w.word.replace(/\s/g, "").length || 1;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) {
|
||||
const startTime =
|
||||
w.startTime +
|
||||
(currentOffset / totalLength) * (w.endTime - w.startTime);
|
||||
|
||||
atoms.push({
|
||||
word: part,
|
||||
romanWord: "",
|
||||
startTime: startTime,
|
||||
endTime: startTime,
|
||||
obscene: w.obscene,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCJK(part) && part.length > 1 && !w.romanWord) {
|
||||
const chars = part.split("");
|
||||
for (const char of chars) {
|
||||
const charDuration = (1 / totalLength) * (w.endTime - w.startTime);
|
||||
const startTime =
|
||||
w.startTime +
|
||||
(currentOffset / totalLength) * (w.endTime - w.startTime);
|
||||
atoms.push({
|
||||
word: char,
|
||||
romanWord: "",
|
||||
startTime: startTime,
|
||||
endTime: startTime + charDuration,
|
||||
obscene: w.obscene,
|
||||
});
|
||||
currentOffset += 1;
|
||||
}
|
||||
} else {
|
||||
const partRealLen = part.length;
|
||||
const duration =
|
||||
(partRealLen / totalLength) * (w.endTime - w.startTime);
|
||||
const startTime =
|
||||
w.startTime +
|
||||
(currentOffset / totalLength) * (w.endTime - w.startTime);
|
||||
|
||||
atoms.push({
|
||||
word: part,
|
||||
romanWord: w.romanWord,
|
||||
startTime: startTime,
|
||||
endTime: startTime + duration,
|
||||
obscene: w.obscene,
|
||||
});
|
||||
currentOffset += partRealLen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSegmenter) {
|
||||
return atoms;
|
||||
}
|
||||
|
||||
const fullText = atoms.map((a) => a.word).join("");
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
||||
const segments = Array.from(segmenter.segment(fullText));
|
||||
|
||||
const result: (LyricWord | LyricWord[])[] = [];
|
||||
let atomIndex = 0;
|
||||
let currentAtomOffset = 0;
|
||||
let currentSegmentOffset = 0;
|
||||
|
||||
for (const segment of segments) {
|
||||
const segmentStr = segment.segment;
|
||||
const segmentLen = segmentStr.length;
|
||||
|
||||
if (currentAtomOffset > currentSegmentOffset) {
|
||||
currentSegmentOffset += segmentLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
const segmentGroup: LyricWord[] = [];
|
||||
let neededLength = segmentLen;
|
||||
|
||||
while (neededLength > 0 && atomIndex < atoms.length) {
|
||||
const currentAtom = atoms[atomIndex];
|
||||
const atomLen = currentAtom.word.length;
|
||||
|
||||
segmentGroup.push(currentAtom);
|
||||
|
||||
currentAtomOffset += atomLen;
|
||||
atomIndex++;
|
||||
neededLength -= atomLen;
|
||||
}
|
||||
|
||||
currentSegmentOffset += segmentLen;
|
||||
|
||||
while (segmentGroup.length > 1 && !segmentGroup[0].word.trim()) {
|
||||
const spaceAtom = segmentGroup.shift();
|
||||
if (spaceAtom) {
|
||||
result.push(spaceAtom);
|
||||
}
|
||||
}
|
||||
|
||||
if (segmentGroup.length === 1) {
|
||||
result.push(segmentGroup[0]);
|
||||
} else if (segmentGroup.length > 1) {
|
||||
result.push(segmentGroup);
|
||||
}
|
||||
}
|
||||
|
||||
while (atomIndex < atoms.length) {
|
||||
result.push(atoms[atomIndex++]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Vendored
+47
@@ -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(", ")})`;
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
// 一个异步互斥锁,用于控制并发
|
||||
|
||||
export function mutexifyFunction<
|
||||
F extends (...args: Args) => Promise<Result>,
|
||||
Args extends any[],
|
||||
Result,
|
||||
>(func: F): F {
|
||||
const awaitingTasks: {
|
||||
resolve: (value: Result) => void;
|
||||
reject: (reason: any) => void;
|
||||
args: Args;
|
||||
}[] = [];
|
||||
function processNextTask() {
|
||||
const task = awaitingTasks[0];
|
||||
if (!task) return;
|
||||
func(...task.args)
|
||||
.then((value) => {
|
||||
task.resolve(value);
|
||||
})
|
||||
.catch((reason) => {
|
||||
task.reject(reason);
|
||||
})
|
||||
.finally(() => {
|
||||
awaitingTasks.shift();
|
||||
if (awaitingTasks.length > 0) {
|
||||
processNextTask();
|
||||
}
|
||||
});
|
||||
}
|
||||
return ((...args: Args) => {
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
awaitingTasks.push({ resolve, reject, args });
|
||||
if (awaitingTasks.length === 1) {
|
||||
processNextTask();
|
||||
}
|
||||
});
|
||||
}) as F;
|
||||
}
|
||||
|
||||
export default mutexifyFunction;
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import type { LyricLine } from "../interfaces.ts";
|
||||
|
||||
/**
|
||||
* 将行级时间戳强行设为字级时间戳
|
||||
*/
|
||||
function resetLineTimestamps(lines: LyricLine[]) {
|
||||
for (const line of lines) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试让歌词提前最多 1 秒开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30%
|
||||
*/
|
||||
function tryAdvanceStartTime(lines: LyricLine[]) {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line.isBG) continue;
|
||||
|
||||
let prevLine: LyricLine | null = null;
|
||||
if (i > 0) {
|
||||
let prevIdx = i - 1;
|
||||
if (lines[prevIdx].isBG) {
|
||||
prevIdx--;
|
||||
}
|
||||
if (prevIdx >= 0) {
|
||||
prevLine = lines[prevIdx];
|
||||
}
|
||||
}
|
||||
|
||||
let targetAdvanceAmount = 0;
|
||||
let safeBoundary = 0;
|
||||
|
||||
if (prevLine) {
|
||||
const originallyHadGap = line.startTime >= prevLine.endTime;
|
||||
|
||||
if (originallyHadGap) {
|
||||
targetAdvanceAmount = 1000;
|
||||
safeBoundary = prevLine.endTime;
|
||||
} else {
|
||||
targetAdvanceAmount = 400;
|
||||
const prevDuration = prevLine.endTime - prevLine.startTime;
|
||||
safeBoundary = prevLine.startTime + prevDuration * 0.3;
|
||||
}
|
||||
} else {
|
||||
targetAdvanceAmount = 1000;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化歌词行的展示效果
|
||||
*
|
||||
* 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组
|
||||
* @param lines 歌词行数组
|
||||
*/
|
||||
export function optimizeLyricLines(lines: LyricLine[]) {
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
word.word = word.word.replace(/\s+/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
resetLineTimestamps(lines);
|
||||
convertExcessiveBackgroundLines(lines);
|
||||
syncMainAndBackgroundLines(lines);
|
||||
cleanUnintentionalOverlaps(lines);
|
||||
tryAdvanceStartTime(lines);
|
||||
}
|
||||
+79
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js
|
||||
*/
|
||||
|
||||
interface Task<T> {
|
||||
task: () => T;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
const measureTasks: Task<any>[] = [];
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
Vendored
+158
@@ -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() {
|
||||
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) {
|
||||
this.targetPosition = targetPosition;
|
||||
this.currentPosition = targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.getV = () => 0;
|
||||
this.getV2 = () => 0;
|
||||
}
|
||||
update(delta = 0) {
|
||||
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) {
|
||||
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) {
|
||||
if (delay > 0) {
|
||||
this.queuePosition = {
|
||||
...(this.queuePosition ?? {}),
|
||||
position: targetPosition,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.targetPosition = targetPosition;
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
getCurrentPosition() {
|
||||
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)
|
||||
);
|
||||
};
|
||||
}
|
||||
+139
@@ -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() {
|
||||
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) {
|
||||
this.targetPosition = targetPosition;
|
||||
this.onStepFinished();
|
||||
}
|
||||
|
||||
getCurrentPosition() {
|
||||
if (this.isStatic || !this.currentAnimation.effect)
|
||||
return this.currentPosition;
|
||||
|
||||
const timing = this.currentAnimation.effect?.getComputedTiming();
|
||||
return this.currentSolver(timing.progress ?? 1);
|
||||
}
|
||||
|
||||
getCurrentVelocity() {
|
||||
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() {
|
||||
if (this.currentAnimation) {
|
||||
this.currentAnimation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"types": ["vite/client", "vite-plugin-glsl/ext"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
name: "AppleMusicLikeLyricsCore",
|
||||
fileName: "amll-core",
|
||||
formats: ["es"],
|
||||
},
|
||||
cssMinify: "lightningcss",
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"@pixi/display",
|
||||
"@pixi/app",
|
||||
"@pixi/filter-blur",
|
||||
"@pixi/filter-color-matrix",
|
||||
"@pixi/filter-bulge-pinch",
|
||||
"@pixi/core",
|
||||
"@pixi/sprite",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@applemusic-like-lyrics/lyric": path.resolve(__dirname, "../lyric/pkg"),
|
||||
"@applemusic-like-lyrics/ttml": path.resolve(__dirname, "../ttml/src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
dts({
|
||||
exclude: ["src/test.ts"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user