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

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+25
View File
@@ -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/
+48
View File
@@ -0,0 +1,48 @@
# AMLL Core
[English](./README.md) / 简体中文
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件。
此处的东西都是 UI 框架无关的,所以可以间接在各种动态页面框架下引用。
或者如果你需要使用组件绑定的话,这里有 [React 绑定版本](../react/README.md) 和 [Vue 绑定版本](../vue/README.md)
## 安装
安装使用的依赖(如果以下列出的依赖包没有安装的话需要自行安装):
```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) 中的代码。
+48
View File
@@ -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!
![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
AMLL's pure JS core component framework, including lyric display components and background components and other reusable components.
Everything here is UI framework-independent, so it can be indirectly referenced under various dynamic page frameworks.
Or if you need to use component bindings, there's a [React binding version](../react/README.md) and a [Vue binding version](../vue/README.md)
## 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).
+12
View File
@@ -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>
+37
View File
@@ -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>
+74
View File
@@ -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>
+78
View File
@@ -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"
}
}
+12
View File
@@ -0,0 +1,12 @@
*.lrc
*.yrc
*.qrc
*.lys
*.ttml
*.wav
*.mp3
*.flac
*.jpg
*.png
*.webp
!empty.png
+152
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,71 @@
/**
* @fileoverview
* 一个播放歌词的组件
* @author SteveXMH
*/
export { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
export { MeshGradientRenderer } from "./mesh-renderer/index.ts";
export { PixiRenderer } from "./pixi-renderer.ts";
import type { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
export class BackgroundRender<Renderer extends BaseRenderer>
implements AbstractBaseRenderer
{
private element: HTMLCanvasElement;
private renderer: Renderer;
constructor(renderer: Renderer, canvas: HTMLCanvasElement) {
this.renderer = renderer;
this.element = canvas;
canvas.style.pointerEvents = "none";
canvas.style.zIndex = "-1";
canvas.style.contain = "strict";
}
static new<Renderer extends BaseRenderer>(type: {
new (canvas: HTMLCanvasElement): Renderer;
}): BackgroundRender<Renderer> {
const newCanvas = document.createElement("canvas");
return new BackgroundRender(new type(newCanvas), newCanvas);
}
setRenderScale(scale: number): void {
this.renderer.setRenderScale(scale);
}
setFlowSpeed(speed: number): void {
this.renderer.setFlowSpeed(speed);
}
setStaticMode(enable: boolean): void {
this.renderer.setStaticMode(enable);
}
setFPS(fps: number): void {
this.renderer.setFPS(fps);
}
pause(): void {
this.renderer.pause();
}
resume(): void {
this.renderer.resume();
}
setLowFreqVolume(volume: number): void {
this.renderer.setLowFreqVolume(volume);
}
setHasLyric(hasLyric: boolean): void {
this.renderer.setHasLyric(hasLyric);
}
setAlbum(
albumSource: string | HTMLImageElement | HTMLVideoElement,
isVideo?: boolean,
): Promise<void> {
return this.renderer.setAlbum(albumSource, isVideo);
}
getElement() {
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;
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
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
#version 300 es
precision highp float;
uniform sampler2D src;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
vec2 coord = f_v_coord;
fragColor = texture(src, coord);
}
+10
View File
@@ -0,0 +1,10 @@
#version 300 es
precision highp float;
in vec2 v_coord;
out vec2 f_v_coord;
void main() {
gl_Position = vec4(v_coord, 0.0f, 1.0f);
f_v_coord = (vec2(v_coord.x, v_coord.y) + vec2(1.0f, 1.0f)) / 2.0f;
}
+17
View File
@@ -0,0 +1,17 @@
#version 300 es
precision highp float;
uniform sampler2D src;
uniform float lerp;
uniform float scale;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
vec2 tex_coord = f_v_coord;
if(scale < 1.0f) {
tex_coord /= scale;
}
vec4 srcColor = texture(src, tex_coord);
fragColor = mix(vec4(srcColor.xyz, 0.0f), srcColor, lerp);
}
+20
View File
@@ -0,0 +1,20 @@
#version 300 es
precision highp float;
uniform sampler2D src;
in vec2 f_v_coord;
out vec4 fragColor;
/* Gradient noise from Jorge Jimenez's presentation: */
/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */
float gradientNoise(in vec2 uv) {
return fract(52.9829189f * fract(dot(uv, vec2(0.06711056f, 0.00583715f))));
}
void main() {
vec2 tex_coord = f_v_coord;
float dither = (1.0f / 255.0f) * gradientNoise(gl_FragCoord.xy) - (0.5f / 255.0f);
vec4 color = texture(src, tex_coord);
color += dither;
fragColor = color;
}
+42
View File
@@ -0,0 +1,42 @@
#version 300 es
precision highp float;
uniform sampler2D src;
uniform sampler2D historyFrame0;
uniform vec2 texSize;
in vec2 f_v_coord;
out vec4 fragColor;
void main() {
fragColor = texture(src, f_v_coord);
return;
// get the neighborhood min / max from this frame's render
vec3 center = texture(src, f_v_coord).rgb;
vec3 minColor = center;
vec3 maxColor = center;
for (int iy = -1; iy <= 1; ++iy)
{
for (int ix = -1; ix <= 1; ++ix)
{
if (ix == 0 && iy == 0)
continue;
vec2 offsetUV = ((f_v_coord * texSize + vec2(ix, iy))) / texSize;
vec3 color = texture(src, offsetUV).rgb;
minColor = min(minColor, color);
maxColor = max(maxColor, color);
}
}
// get last frame's pixel and clamp it to the neighborhood of this frame
vec3 old = texture(historyFrame0, f_v_coord).rgb;
old = max(minColor, old);
old = min(maxColor, old);
// interpolate from the clamped old color to the new color.
// Reject all history when the mouse moves.
float lerpAmount = 0.1f;
vec3 pixelColor = mix(old, center, lerpAmount);
fragColor = vec4(pixelColor, 1.0f);
}
+89
View File
@@ -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,
},
);
+4
View File
@@ -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";
+69
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
import type { Disposable, HasElement } from "../interfaces.ts";
import styles from "../styles/lyric-player.module.css";
import { measure } from "../utils/schedule.ts";
import { Spring } from "../utils/spring.ts";
import type { LyricPlayerBase } from "./base.ts";
export class BottomLineEl implements HasElement, Disposable {
private element: HTMLElement = document.createElement("div");
private left = 0;
private top = 0;
private delay = 0;
// 由 LyricPlayer 来设置
lineSize: [number, number] = [0, 0];
readonly lineTransforms = {
posX: new Spring(0),
posY: new Spring(0),
};
constructor(private lyricPlayer: LyricPlayerBase) {
this.element.setAttribute("class", styles.lyricLine);
this.rebuildStyle();
}
async measureSize(): Promise<[number, number]> {
const size: [number, number] = await measure(() => [
this.element.clientWidth,
this.element.clientHeight,
]);
return size;
}
private lastStyle = "";
show() {
this.rebuildStyle();
}
hide() {
this.rebuildStyle();
}
private rebuildStyle() {
let style = `transform:translate(${this.lineTransforms.posX
.getCurrentPosition()
.toFixed(2)}px,${this.lineTransforms.posY
.getCurrentPosition()
.toFixed(2)}px);`;
if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
style += `transition-delay:${this.delay}ms;`;
}
if (style !== this.lastStyle) {
this.lastStyle = style;
this.element.setAttribute("style", style);
}
}
getElement() {
return this.element;
}
setTransform(
left: number = this.left,
top: number = this.top,
force = false,
delay = 0,
) {
this.left = left;
this.top = top;
this.delay = (delay * 1000) | 0;
if (force || !this.lyricPlayer.getEnableSpring()) {
if (force) this.element.classList.add(styles.tmpDisableTransition);
this.lineTransforms.posX.setPosition(left);
this.lineTransforms.posY.setPosition(top);
if (!this.lyricPlayer.getEnableSpring()) this.show();
else this.rebuildStyle();
if (force)
requestAnimationFrame(() => {
this.element.classList.remove(styles.tmpDisableTransition);
});
} else {
this.lineTransforms.posX.setTargetPosition(left, delay);
this.lineTransforms.posY.setTargetPosition(top, delay);
}
}
update(delta = 0) {
if (!this.lyricPlayer.getEnableSpring()) return;
this.lineTransforms.posX.update(delta);
this.lineTransforms.posY.update(delta);
if (this.isInSight) {
this.show();
} else {
this.hide();
}
}
get isInSight() {
const l = this.lineTransforms.posX.getCurrentPosition();
const t = this.lineTransforms.posY.getCurrentPosition();
const r = l + this.lineSize[0];
const b = t + this.lineSize[1];
const pr = this.lyricPlayer.size[0];
const pb = this.lyricPlayer.size[1];
return !(l > pr || t > pb || r < 0 || b < 0);
}
dispose(): void {
this.element.remove();
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { LyricLine } from "../../interfaces.ts";
import { LyricPlayerBase } from "../base.ts";
import { CanvasLyricLine } from "./lyric-line.ts";
export class CanvasLyricPlayer extends LyricPlayerBase {
private canvasElement = document.createElement("canvas");
override currentLyricLineObjects: CanvasLyricLine[] = [];
/** @internal */
readonly ctx = this.canvasElement.getContext("2d")!;
/** @internal */
baseLineHeight = 1;
/** @internal */
baseFontSize = 30;
/** @internal */
baseFontFamily = "sans-serif";
constructor() {
super();
this.element.classList.add("amll-lyric-player", "dom");
this.canvasElement.style.width = "100%";
this.canvasElement.style.height = "100%";
this.canvasElement.style.display = "block";
this.canvasElement.style.position = "absolute";
this.onResize();
this.update();
// TODO: 添加鼠标事件监听
this.element.addEventListener("mousemove", (evt) => {
evt.preventDefault();
});
this.element.addEventListener("click", (evt) => {
evt.preventDefault();
});
this.element.addEventListener("contextmenu", (evt) => {
evt.preventDefault();
});
this.element.appendChild(this.canvasElement);
this.element.appendChild(this.interludeDots.getElement());
this.element.appendChild(this.bottomLine.getElement());
}
override setLyricLines(lines: LyricLine[], initialTime?: number): void {
super.setLyricLines(lines, initialTime);
this.currentLyricLineObjects = this.processedLines.map(
(line) => new CanvasLyricLine(this, line),
);
this.setLinePosYSpringParams({});
this.setLineScaleSpringParams({});
this.calcLayout(true);
}
override onResize(): void {
const computedStyle = getComputedStyle(this.element);
this.baseFontSize = Number.parseFloat(computedStyle.fontSize) || 30;
this.baseFontFamily = computedStyle.fontFamily;
const realWidth = this.canvasElement.clientWidth;
const realHeight = this.canvasElement.clientHeight;
this.size[0] = realWidth - this.baseFontSize * 2;
this.size[1] = realHeight;
this.canvasElement.width = realWidth * devicePixelRatio;
this.canvasElement.height = realHeight * devicePixelRatio;
for (const line of this.currentLyricLineObjects) {
line.relayout();
}
console.log("CanvasLyricPlayer.onResize", this.size);
this.calcLayout(true);
}
/**
* @internal
* @param size
*/
setFontSize(emSize: number): void {
this.ctx.font = `${this.baseFontSize * emSize}px ${this.baseFontFamily}`;
}
override update(delta = 0): void {
super.update(delta);
const ctx = this.ctx;
const width = this.size[0];
const height = this.size[1];
ctx.resetTransform();
ctx.scale(devicePixelRatio, devicePixelRatio);
ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);
ctx.fillStyle = "currentColor";
ctx.font = `${this.baseFontSize}px ${this.baseFontFamily}`;
ctx.textRendering = "optimizeSpeed";
ctx.textAlign = "left";
ctx.save();
ctx.translate(this.baseFontSize, 0);
for (const line of this.currentLyricLineObjects) {
line.update(delta / 1000);
}
ctx.restore();
ctx.font = `15px ${this.baseFontFamily}`;
ctx.fillStyle = "#FFFFFF55";
ctx.textAlign = "right";
ctx.fillText("CanvasLyricPlayer 播放器", width - 16, height - 16);
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { LyricLine } from "../../interfaces.ts";
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
import { LyricLineBase } from "../base.ts";
import type { CanvasLyricPlayer } from "./index.ts";
import {
layoutLine,
type TextLayoutConfig,
type TextLayoutResult,
} from "./text-layout";
export class CanvasLyricLine extends LyricLineBase {
constructor(
private player: CanvasLyricPlayer,
private line: LyricLine = {
words: [],
translatedLyric: "",
romanLyric: "",
startTime: 0,
endTime: 0,
isBG: false,
isDuet: false,
},
) {
super();
this.relayout();
}
override getLine(): LyricLine {
return this.line;
}
private lineSize: [number, number] = [0, 0];
measureSize(): [number, number] {
const maxMainLineIndex = Math.max(
0,
...this.layoutWords.flat().map((w) => w.lineIndex + 1),
);
const maxTranslatedLineIndex = Math.max(
0,
...this.translatedLayoutWords.map((w) => w.lineIndex + 1),
);
const maxRomanLineIndex = Math.max(
0,
...this.romanLayoutWords.map((w) => w.lineIndex + 1),
);
const lineHeight = this.player.baseFontSize;
this.lineSize = [
this.player.size[0],
(maxMainLineIndex + maxTranslatedLineIndex + maxRomanLineIndex) *
lineHeight +
this.player.size[1] * 0.04,
];
return [...this.lineSize];
}
private layoutWords: TextLayoutResult[][] = [];
private translatedLayoutWords: TextLayoutResult[] = [];
private romanLayoutWords: TextLayoutResult[] = [];
private lineCanvas: HTMLCanvasElement = document.createElement("canvas");
/** @internal */
relayout(): void {
const config: TextLayoutConfig = {
fontSize: this.player.baseFontSize,
maxWidth: this.player.size[0] - 50,
lineHeight: this.player.baseFontSize,
uniformSpace: true,
};
const ctx = this.player.ctx;
this.player.setFontSize(1);
for (const chunk of chunkAndSplitLyricWords(this.line.words)) {
if (Array.isArray(chunk)) {
if (chunk.length === 0) continue;
} else {
}
}
this.layoutWords = [
[...layoutLine(ctx, this.line.words.map((w) => w.word).join(""), config)],
];
this.player.setFontSize(0.5);
this.translatedLayoutWords = [
...layoutLine(ctx, this.line.translatedLyric, config),
];
this.romanLayoutWords = [...layoutLine(ctx, this.line.romanLyric, config)];
// 在自己的 Canvas 上绘制
this.measureSize();
this.lineCanvas.width = this.player.ctx.canvas.width;
this.lineCanvas.height = this.lineSize[1] * devicePixelRatio;
const lctx = this.lineCanvas.getContext("2d")!;
lctx.globalAlpha = 1;
this.player.setFontSize(1);
lctx.font = ctx.font;
lctx.scale(devicePixelRatio, devicePixelRatio);
lctx.fillStyle = "white";
lctx.textBaseline = "top";
lctx.textAlign = "left";
lctx.font = `${this.player.baseFontSize}px ${this.player.baseFontFamily}`;
let lineIndex = 0;
for (const word of this.layoutWords) {
for (const layout of word) {
lctx.fillText(
layout.text,
layout.x,
layout.lineIndex *
this.player.baseFontSize *
this.player.baseLineHeight,
);
lineIndex = layout.lineIndex;
}
}
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
this.player.setFontSize(0.5);
lctx.font = ctx.font;
lctx.globalAlpha = 0.5;
lineIndex = 0;
for (const layout of this.translatedLayoutWords) {
lctx.fillText(
layout.text,
layout.x,
layout.lineIndex *
this.player.baseFontSize *
this.player.baseLineHeight,
);
lineIndex = layout.lineIndex;
}
lctx.translate(0, (lineIndex + 1) * this.player.baseFontSize);
for (const layout of this.romanLayoutWords) {
lctx.fillText(
layout.text,
layout.x,
layout.lineIndex *
this.player.baseFontSize *
this.player.baseLineHeight,
);
}
}
override enable(): void {}
override disable(): void {}
override resume(): void {}
override pause(): void {}
override setTransform(
top = this.top,
scale = this.scale,
opacity = this.opacity,
blur = this.blur,
force = false,
delay = this.delay,
): void {
const targetBlur = Math.min(32, blur);
// TODO: 实现模糊效果和不透明度效果的动画化
this.blur = targetBlur;
this.opacity = opacity;
if (force) {
this.lineTransforms.posY.setPosition(top);
this.lineTransforms.scale.setPosition(scale);
} else {
this.lineTransforms.posY.setTargetPosition(top, delay);
this.lineTransforms.scale.setTargetPosition(scale);
}
}
get isInSight() {
const t = this.lineTransforms.posY.getCurrentPosition();
const r = this.lineSize[0];
const b = t + this.lineSize[1];
const pb = this.player.size[1];
return !(t > pb || r < 0 || b < 0);
}
override update(delta?: number): void {
this.lineTransforms.posY.update(delta);
this.lineTransforms.scale.update(delta);
if (!this.isInSight) return;
const ctx = this.player.ctx;
ctx.save();
ctx.fillStyle = "white";
ctx.filter = `blur(${this.blur}px)`;
ctx.textRendering = "geometricPrecision";
ctx.globalAlpha = this.opacity;
ctx.translate(0, this.lineTransforms.posY.getCurrentPosition());
ctx.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
if (this.lineCanvas.width * this.lineCanvas.height > 0)
ctx.drawImage(this.lineCanvas, 0, 0);
ctx.restore();
}
}
+186
View File
@@ -0,0 +1,186 @@
const WHITE_SPACE = /^\s+/;
const LATIN = /^[\p{L}0-9!"#$%&’()*+,-./:;<=>?@\[\]^_`\{|\}~]+/iu;
export interface TextLayoutResult {
text: string;
index: number;
lineIndex: number;
width: number;
height: number;
x: number;
}
export interface TextLayoutConfig {
fontSize: number;
maxWidth: number;
lineHeight: number;
/**
* 是否统一空格宽度,即不论空白字符有多少个,都只占用一个字符的宽度
*/
uniformSpace: boolean;
}
export interface TextLayoutFinalState {
x: number;
lineIndex: number;
}
/**
* 对指定文本进行布局,返回每个字符的位置信息
* 目前仅可支持普通拉丁字符和 CJK 字符
* @param ctx 2D 画板上下文
* @param text 文本
* @param config 字体大小
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
*/
export function* layoutWord(
ctx: CanvasRenderingContext2D,
text: string,
config: TextLayoutConfig,
initialX = 0,
): Generator<TextLayoutResult, TextLayoutFinalState, void> {
if (import.meta.env.DEV) {
if (!ctx) throw new Error("ctx is null");
if (!(config.fontSize > 0)) throw new Error("fontSize is invalid");
}
let x = initialX;
let lineIndex = 0;
let lastWhitespaceIndex = 0;
let curLayoutIndex = 0;
let shouldWhitespace = false;
let match: RegExpMatchArray | null = null;
const spaceMetrics = ctx.measureText(" ");
while (curLayoutIndex < text.length) {
const rest = text.substring(curLayoutIndex);
match = rest.match(WHITE_SPACE);
if (match) {
shouldWhitespace = true;
lastWhitespaceIndex = curLayoutIndex;
curLayoutIndex += match[0].length;
continue;
}
// 拉丁文字应尽量保持连续而不在中间换行
match = rest.match(LATIN);
if (match) {
curLayoutIndex += match[0].length;
const text = match[0];
const wholeMetrics = ctx.measureText(text);
if (x + wholeMetrics.width > config.maxWidth) {
// 在当前行放不下,换行
x = 0;
lineIndex++;
shouldWhitespace = false;
}
if (shouldWhitespace) {
shouldWhitespace = false;
yield {
text: " ",
index: lastWhitespaceIndex,
lineIndex,
width: 0,
height: config.fontSize,
x,
};
x += spaceMetrics.width;
}
let prevChar = "";
let lastMetrics: TextMetrics | null = null;
for (const c of text) {
const lastMergedMetrics = ctx.measureText(`${prevChar}${c}`);
const metrics = ctx.measureText(c);
if (prevChar !== "") {
x = x + lastMergedMetrics.width - metrics.width;
}
if (x + metrics.width > config.maxWidth) {
x = 0;
lineIndex++;
}
yield {
text: c,
index: curLayoutIndex,
lineIndex,
width: metrics.width,
height: config.fontSize,
x,
};
prevChar = c;
lastMetrics = metrics;
}
if (lastMetrics) x += lastMetrics.width;
continue;
}
// 无需保持连续的字符,直接布局,推进游标
{
if (shouldWhitespace) {
shouldWhitespace = false;
yield {
text: " ",
index: lastWhitespaceIndex,
lineIndex,
width: 0,
height: config.fontSize,
x,
};
x += spaceMetrics.width;
}
const metrics = ctx.measureText(text[curLayoutIndex]);
if (x + metrics.width > config.maxWidth) {
x = 0;
lineIndex++;
}
yield {
text: text[curLayoutIndex],
index: curLayoutIndex,
lineIndex,
width: metrics.width,
height: config.fontSize,
x,
};
x += metrics.width;
}
curLayoutIndex++;
}
return { x, lineIndex };
}
/**
* 对指定文本进行布局,返回每段文字的位置信息
* 目前仅可支持普通拉丁字符和 CJK 字符
* @param ctx 2D 画板上下文
* @param text 文本
* @param config 字体大小
* @param initialX 初始 X 坐标,对于需要布局多段文本的情况下有所帮助
*/
export function* layoutLine(
ctx: CanvasRenderingContext2D,
text: string,
config: TextLayoutConfig,
initialX = 0,
): Generator<TextLayoutResult, void, void> {
let currentLine: TextLayoutResult = {
text: "",
index: 0,
lineIndex: 0,
width: 0,
height: 0,
x: 0,
};
for (const word of layoutWord(ctx, text, config, initialX)) {
if (word.lineIndex !== currentLine.lineIndex) {
if (currentLine.text.length) yield currentLine;
currentLine = {
...word,
};
} else {
currentLine.text += word.text;
currentLine.width = word.x + word.width;
}
}
if (currentLine.text.length) yield currentLine;
}
@@ -0,0 +1,136 @@
/*
给各个内部元素使用的样式
*/
/* 给每行歌词应用的样式 */
.lyricLine {
width: var(--amll-lp-width, 100%);
min-width: var(--amll-lp-width, 100%);
max-width: var(--amll-lp-width, 100%);
width: 100%;
height: fit-content;
padding: 2vh 1em;
contain: content;
transition:
opacity 0.25s,
filter 0.2s,
background-color 0.25s,
box-shadow 0.25s;
box-sizing: border-box;
border-radius: 0.25em;
padding-left: 1em;
padding-right: 1em;
&:has(> *):hover {
background-color: var(--amll-lp-hover-bg-color, #fff1);
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
}
&:has(> *):active {
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
}
}
.lyricBgLine {
opacity: 0;
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
transition:
opacity 0.25s,
scale 0.5s,
filter 0.2s,
background-color 0.25s,
box-shadow 0.25s;
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
padding: 1vh
calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
&.active {
transition:
opacity 0.5s 0.25s,
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
filter 0.2s,
background-color 0.25s,
box-shadow 0.25s;
opacity: 0.4;
}
}
:global(.amll-lyric-player) {
&:hover .lyricLine {
filter: unset !important;
}
&.hasDuetLine {
.lyricLine:not(.lyricDuetLine) {
padding-right: 15%;
}
.lyricDuetLine {
padding-left: 15%;
}
}
}
.lyricDuetLine {
text-align: right;
transform-origin: right;
}
.lyricMainLine {
transition: opacity 0.3s 0.1s;
contain: content paint;
span {
display: inline-block;
white-space: pre;
}
}
.lyricSubLine {
font-size: max(0.5em, 10px);
line-height: 1.5em;
transition: opacity 0.2s 0.25s;
opacity: 0.3;
}
.interludeDots {
height: clamp(0.5em, 1vh, 3em);
transition: opacity 0.25s;
transform-origin: center;
width: fit-content;
padding: 2.5% 0.75em;
display: flex;
gap: 0.25em;
left: 0;
opacity: 0;
&.enabled {
opacity: 1;
}
& > * {
width: clamp(0.5em, 1vh, 3em);
height: clamp(0.5em, 1vh, 3em);
display: inline-block;
border-radius: 50%;
aspect-ratio: 1 / 1;
background-color: var(--amll-lp-color, white);
margin-right: 4px;
}
&.duet {
right: 0;
transform-origin: center;
}
}
@supports (mix-blend-mode: plus-lighter) {
.lyricSubLine {
opacity: 0.3;
}
}
.tmpDisableTransition {
transition: none !important;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* @fileoverview
* 一个播放歌词的组件,但是进行了部分效果的阉割精简,以尝试改善在低性能设备上的性能问题
* @author SteveXMH
*/
import type { LyricLine } from "../../interfaces.ts";
import "../../styles/index.css";
import { debounce } from "../../utils/debounce.ts";
import { LyricPlayerBase } from "../base.ts";
import { LyricLineMouseEvent } from "../dom/index.ts";
import styles from "./index.module.css";
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
/**
* 歌词播放组件,本框架的核心组件
*
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
*/
export class DomSlimLyricPlayer extends LyricPlayerBase {
override currentLyricLineObjects: LyricLineEl[] = [];
private debounceCalcLayout = debounce(
() =>
this.calcLayout(true).then(() =>
this.currentLyricLineObjects.map(async (el, i) => {
el.markMaskImageDirty("DomLyricPlayer onResize");
await el.waitMaskImageUpdated();
if (this.hotLines.has(i)) {
el.enable(this.currentTime);
el.resume();
}
}),
),
1000,
);
override onResize(): void {
const computedStyles = getComputedStyle(this.element);
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
const innerWidth =
this.element.clientWidth -
Number.parseFloat(computedStyles.paddingLeft) -
Number.parseFloat(computedStyles.paddingRight);
const innerHeight =
this.element.clientHeight -
Number.parseFloat(computedStyles.paddingTop) -
Number.parseFloat(computedStyles.paddingBottom);
this.innerSize[0] = innerWidth;
this.innerSize[1] = innerHeight;
this.rebuildStyle();
// for (const obj of this.currentLyricLineObjects) {
// if (!obj.getElement().classList.contains(styles.dirty))
// obj.getElement().classList.add(styles.dirty);
// }
this.debounceCalcLayout();
}
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
readonly supportMaskImage = CSS.supports("mask-image", "none");
readonly innerSize: [number, number] = [0, 0];
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
const evt = new LyricLineMouseEvent(
this.lyricLinesIndexes.get(e.line) ?? -1,
e.line,
e,
);
if (!this.dispatchEvent(evt)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
/**
* 是否为非逐词歌词
* @internal
*/
_getIsNonDynamic() {
return this.isNonDynamic;
}
private _baseFontSize = Number.parseFloat(
getComputedStyle(this.element).fontSize,
);
public get baseFontSize() {
return this._baseFontSize;
}
constructor() {
super();
this.onResize();
this.element.classList.add("amll-lyric-player", "dom-slim");
if (this.disableSpring) {
this.element.classList.add(styles.disableSpring);
}
}
private rebuildStyle() {
const width = this.innerSize[0];
const height = this.innerSize[1];
this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
this.element.style.setProperty(
"--amll-lp-height",
`${height.toFixed(4)}px`,
);
}
override setWordFadeWidth(value = 0.5) {
super.setWordFadeWidth(value);
for (const el of this.currentLyricLineObjects) {
el.markMaskImageDirty("DomLyricPlayer setWordFadeWidth");
}
}
/**
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
* @param lines 歌词数组
* @param initialTime 初始时间,默认为 0
*/
override setLyricLines(lines: LyricLine[], initialTime = 0) {
super.setLyricLines(lines, initialTime);
if (this.hasDuetLine) {
this.element.classList.add(styles.hasDuetLine);
} else {
this.element.classList.remove(styles.hasDuetLine);
}
for (const line of this.currentLyricLineObjects) {
line.removeMouseEventListener("click", this.onLineClickedHandler);
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
line.dispose();
}
// 创建新的歌词行元素
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
const lineEl = new LyricLineEl(this, line);
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
this.element.appendChild(lineEl.getElement());
this.lyricLinesIndexes.set(lineEl, i);
lineEl.markMaskImageDirty("DomLyricPlayer setLyricLines");
return lineEl;
});
this.setLinePosXSpringParams({});
this.setLinePosYSpringParams({});
this.setLineScaleSpringParams({});
this.calcLayout(true).then(() => {
this.initialLayoutFinished = true;
});
}
override pause() {
super.pause();
this.interludeDots.pause();
for (const line of this.currentLyricLineObjects) {
line.pause();
}
}
override resume() {
super.resume();
this.interludeDots.resume();
for (const line of this.currentLyricLineObjects) {
line.resume();
}
}
override update(delta = 0) {
if (!this.initialLayoutFinished) return;
super.update(delta);
if (!this.isPageVisible) return;
const deltaS = delta / 1000;
this.interludeDots.update(delta);
this.bottomLine.update(deltaS);
for (const line of this.currentLyricLineObjects) {
line.update(deltaS);
}
}
override async calcLayout(sync?: boolean): Promise<void> {
await super.calcLayout(sync);
const curLine = this.currentLyricLineObjects[this.targetAlignIndex];
const curLineEl = curLine.getElement();
const curLineVisibility = curLineEl.checkVisibility({
contentVisibilityAuto: true,
});
const playerTop = this.element.getBoundingClientRect().top;
if (!curLineVisibility) {
curLineEl.scrollIntoView({
block: "center",
behavior: "instant",
});
}
const curLineHeight = curLineEl.clientHeight;
const curLineRect = curLineEl.getBoundingClientRect();
let scrollToPos =
curLineRect.top - playerTop - this.size[1] * this.alignPosition;
if (curLine) {
switch (this.alignAnchor) {
case "bottom":
scrollToPos += curLineHeight;
break;
case "center":
scrollToPos += curLineHeight / 2;
break;
case "top":
break;
}
}
this.element.scrollBy({
top: scrollToPos,
behavior: "smooth",
});
}
override dispose(): void {
super.dispose();
this.element.remove();
for (const el of this.currentLyricLineObjects) {
el.dispose();
}
this.bottomLine.dispose();
this.interludeDots.dispose();
}
}
@@ -0,0 +1,167 @@
import type { Disposable, HasElement } from "../../interfaces.ts";
import styles from "../../styles/lyric-player.module.css";
function easeInOutBack(x: number): number {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
}
function easeOutExpo(x: number): number {
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
}
const clamp = (min: number, cur: number, max: number) =>
Math.max(min, Math.min(cur, max));
export class InterludeDots implements HasElement, Disposable {
private element: HTMLElement = document.createElement("div");
private dot0: HTMLElement = document.createElement("span");
private dot1: HTMLElement = document.createElement("span");
private dot2: HTMLElement = document.createElement("span");
private left = 0;
private top = 0;
private playing = true;
private lastStyle = "";
private currentInterlude?: [number, number];
private currentTime = 0;
private targetBreatheDuration = 1500;
constructor() {
this.element.className = styles.interludeDots;
this.element.appendChild(this.dot0);
this.element.appendChild(this.dot1);
this.element.appendChild(this.dot2);
}
getElement() {
return this.element;
}
setTransform(left: number = this.left, top: number = this.top) {
this.left = left;
this.top = top;
this.update();
}
setInterlude(interlude?: [number, number]) {
this.currentInterlude = interlude;
this.currentTime = interlude?.[0] ?? 0;
if (interlude) {
this.element.classList.add(styles.enabled);
} else {
this.element.classList.remove(styles.enabled);
}
}
pause() {
this.playing = false;
this.element.classList.remove(styles.playing);
}
resume() {
this.playing = true;
this.element.classList.add(styles.playing);
}
update(delta = 0) {
if (!this.playing) return;
this.currentTime += delta;
let curStyle = "";
// 计算缩放大小
if (this.currentInterlude) {
const interludeDuration =
this.currentInterlude[1] - this.currentInterlude[0];
const currentDuration = this.currentTime - this.currentInterlude[0];
if (currentDuration <= interludeDuration) {
const breatheDuration =
interludeDuration /
Math.ceil(interludeDuration / this.targetBreatheDuration);
let scale = 1;
let globalOpacity = 1;
scale *=
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
20 +
1;
if (currentDuration < 2000) {
scale *= easeOutExpo(currentDuration / 2000);
}
if (currentDuration < 500) {
globalOpacity = 0;
} else if (currentDuration < 1000) {
globalOpacity *= (currentDuration - 500) / 500;
}
if (interludeDuration - currentDuration < 750) {
scale *=
1 -
easeInOutBack(
(750 - (interludeDuration - currentDuration)) / 750 / 2,
);
}
if (interludeDuration - currentDuration < 375) {
globalOpacity *= clamp(
0,
(interludeDuration - currentDuration) / 375,
1,
);
}
const dotsDuration = Math.max(0, interludeDuration - 750);
scale = Math.max(0, scale) * 0.7;
curStyle += ` scale(${scale})`;
const dot0Opacity = clamp(
0.25,
((currentDuration * 3) / dotsDuration) * 0.75,
1,
);
const dot1Opacity = clamp(
0.25,
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
1,
);
const dot2Opacity = clamp(
0.25,
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
0.75,
1,
);
this.dot0.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot0Opacity),
1,
)}`;
this.dot1.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot1Opacity),
1,
)}`;
this.dot2.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot2Opacity),
1,
)}`;
} else {
curStyle += " scale(0)";
this.dot0.style.opacity = "0";
this.dot1.style.opacity = "0";
this.dot2.style.opacity = "0";
}
curStyle += ";";
if (this.lastStyle !== curStyle) {
this.element.setAttribute("style", curStyle);
this.lastStyle = curStyle;
}
}
}
dispose() {
this.element.remove();
}
}
+976
View File
@@ -0,0 +1,976 @@
import bezier from "bezier-easing";
import type { LyricLine, LyricWord } from "../../interfaces.ts";
import { chunkAndSplitLyricWords } from "../../utils/lyric-split-words.ts";
import {
createMatrix4,
matrix4ToCSS,
scaleMatrix4,
} from "../../utils/matrix.ts";
import { mutexifyFunction } from "../../utils/mutex.ts";
import { measure, mutate } from "../../utils/schedule.ts";
import { LyricLineBase } from "../base.ts";
import styles from "./index.module.css";
import type { DomSlimLyricPlayer } from "./index.ts";
interface RealWord extends LyricWord {
mainElement: HTMLSpanElement;
subElements: HTMLSpanElement[];
elementAnimations: Animation[];
maskAnimations: Animation[];
width: number;
height: number;
padding: number;
shouldEmphasize: boolean;
}
const ANIMATION_FRAME_QUANTITY = 32;
const norNum = (min: number, max: number) => (x: number) =>
Math.min(1, Math.max(0, (x - min) / (max - min)));
const EMP_EASING_MID = 0.5;
const beginNum = norNum(0, EMP_EASING_MID);
const endNum = norNum(EMP_EASING_MID, 1);
const bezIn = bezier(0.2, 0.4, 0.58, 1.0);
const bezOut = bezier(0.3, 0.0, 0.58, 1.0);
const makeEmpEasing = (mid: number) => {
return (x: number) => (x < mid ? bezIn(beginNum(x)) : 1 - bezOut(endNum(x)));
};
function generateFadeGradient(
width: number,
padding = 0,
bright = "rgba(0,0,0,var(--bright-mask-alpha, 1.0))",
dark = "rgba(0,0,0,var(--dark-mask-alpha, 1.0))",
): [string, number] {
const totalAspect = 2 + width + padding;
const widthInTotal = width / totalAspect;
const leftPos = (1 - widthInTotal) / 2;
return [
`linear-gradient(to right,${bright} ${leftPos * 100}%,${dark} ${
(leftPos + widthInTotal) * 100
}%)`,
totalAspect,
];
}
export class RawLyricLineMouseEvent extends MouseEvent {
constructor(
public readonly line: LyricLineEl,
event: MouseEvent,
) {
super(event.type, event);
}
}
function getScaleFromTransform(transform: string): number {
const match = transform.match(/matrix\(([^)]+)\)/);
if (match) {
const values = match[1].split(", ");
const scaleX = Number.parseFloat(values[0]);
const scaleY = Number.parseFloat(values[3]);
return (scaleX + scaleY) / 2; // Average of scaleX and scaleY
}
return 1; // Default scale value if not found
}
type MouseEventMap = {
[evt in keyof HTMLElementEventMap]: HTMLElementEventMap[evt] extends MouseEvent
? evt
: never;
};
type MouseEventTypes = MouseEventMap[keyof MouseEventMap];
type MouseEventListener = (
this: LyricLineEl,
ev: RawLyricLineMouseEvent,
) => void;
export class LyricLineEl extends LyricLineBase {
private element: HTMLElement = document.createElement("div");
private splittedWords: RealWord[] = [];
// 由 LyricPlayer 来设置
lineSize: number[] = [0, 0];
constructor(
private lyricPlayer: DomSlimLyricPlayer,
private lyricLine: LyricLine = {
words: [],
translatedLyric: "",
romanLyric: "",
startTime: 0,
endTime: 0,
isBG: false,
isDuet: false,
},
) {
super();
this.element.setAttribute("class", styles.lyricLine);
if (this.lyricLine.isBG) {
this.element.classList.add(styles.lyricBgLine);
}
if (this.lyricLine.isDuet) {
this.element.classList.add(styles.lyricDuetLine);
}
this.element.appendChild(document.createElement("div")); // 歌词行
this.element.appendChild(document.createElement("div")); // 翻译行
this.element.appendChild(document.createElement("div")); // 音译行
const main = this.element.children[0] as HTMLDivElement;
const trans = this.element.children[1] as HTMLDivElement;
const roman = this.element.children[2] as HTMLDivElement;
main.setAttribute("class", styles.lyricMainLine);
trans.setAttribute("class", styles.lyricSubLine);
roman.setAttribute("class", styles.lyricSubLine);
this.rebuildElement();
this.rebuildStyle();
this.markMaskImageDirty("Initial construction");
}
private listenersMap = new Map<string, Set<MouseEventListener>>();
private readonly onMouseEvent = (e: MouseEvent) => {
const wrapped = new RawLyricLineMouseEvent(this, e);
for (const listener of this.listenersMap.get(e.type) ?? []) {
listener.call(this, wrapped);
}
if (!this.dispatchEvent(wrapped) || wrapped.defaultPrevented) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
}
};
addMouseEventListener(
type: MouseEventTypes,
callback: MouseEventListener | null,
options?: boolean | AddEventListenerOptions | undefined,
): void {
if (callback) {
const listeners = this.listenersMap.get(type) ?? new Set();
if (listeners.size === 0)
this.element.addEventListener(type, this.onMouseEvent, options);
listeners.add(callback);
this.listenersMap.set(type, listeners);
}
}
removeMouseEventListener(
type: MouseEventTypes,
callback: MouseEventListener | null,
options?: boolean | EventListenerOptions | undefined,
): void {
if (callback) {
const listeners = this.listenersMap.get(type);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0)
this.element.removeEventListener(type, this.onMouseEvent, options);
}
}
}
areWordsOnSameLine(word1: RealWord, word2: RealWord) {
if (word1?.mainElement && word2?.mainElement) {
const word1el = word1.mainElement;
const word2el = word2.mainElement;
const rect1 = word1el.getBoundingClientRect();
const rect2 = word2el.getBoundingClientRect();
// 检查两个单词的顶部距离是否相等(或者差值很小)
const topDifference = Math.abs(rect1.top - rect2.top);
// 如果顶部距离相差很小,可以认为它们在同一行上
return topDifference < 10;
}
return true;
}
private isEnabled = false;
async enable(maskAnimationTime = this.lyricLine.startTime) {
this.isEnabled = true;
this.element.classList.add(styles.active);
await this.waitMaskImageUpdated();
const main = this.element.children[0] as HTMLDivElement;
for (const word of this.splittedWords) {
for (const a of word.elementAnimations) {
a.currentTime = 0;
a.playbackRate = 1;
a.play();
}
for (const a of word.maskAnimations) {
a.currentTime = Math.min(
this.totalDuration,
Math.max(0, maskAnimationTime - this.lyricLine.startTime),
);
a.playbackRate = 1;
a.play();
}
}
main.classList.add(styles.active);
}
disable() {
this.isEnabled = false;
this.element.classList.remove(styles.active);
const main = this.element.children[0] as HTMLDivElement;
for (const word of this.splittedWords) {
for (const a of word.elementAnimations) {
if (
a.id === "float-word" ||
a.id.includes("emphasize-word-float-only")
) {
a.playbackRate = -1;
a.play();
}
}
}
main.classList.remove(styles.active);
}
private lastWord?: RealWord;
async resume() {
await this.waitMaskImageUpdated();
if (!this.isEnabled) return;
for (const word of this.splittedWords) {
for (const a of word.elementAnimations) {
if (
!this.lastWord ||
this.splittedWords.indexOf(this.lastWord) <
this.splittedWords.indexOf(word)
) {
a.play();
}
}
for (const a of word.maskAnimations) {
if (
!this.lastWord ||
this.splittedWords.indexOf(this.lastWord) <
this.splittedWords.indexOf(word)
) {
a.play();
}
}
}
}
async pause() {
await this.waitMaskImageUpdated();
if (!this.isEnabled) return;
for (const word of this.splittedWords) {
for (const a of word.elementAnimations) {
a.pause();
}
for (const a of word.maskAnimations) {
a.pause();
}
}
}
setMaskAnimationState(maskAnimationTime = 0) {
const t = maskAnimationTime - this.lyricLine.startTime;
for (const word of this.splittedWords) {
for (const a of word.maskAnimations) {
a.currentTime = Math.min(this.totalDuration, Math.max(0, t));
a.playbackRate = 1;
if (t >= 0 && t < this.totalDuration) a.play();
else a.pause();
}
}
}
private measureLockMark = false;
private measureLock = mutexifyFunction(
async (callback: () => Promise<void>): Promise<void> => {
if (this.measureLockMark) return;
this.measureLockMark = true;
// if (this._hide) {
// await mutate(() => {
// this._prevParentEl.appendChild(this.element);
// this.element.style.display = "";
// this.element.style.visibility = "hidden";
// });
// }
await callback();
// if (this._hide) {
// await mutate(() => {
// this._prevParentEl.removeChild(this.element);
// this.element.style.display = "none";
// this.element.style.visibility = "";
// });
// }
this.measureLockMark = false;
},
);
getLine() {
return this.lyricLine;
}
show() {
// this._hide = false;
// if (!this.measureLockMark && !this.element.parentElement) {
// this._prevParentEl.appendChild(this.element);
// }
this.rebuildStyle();
}
hide() {
// this._hide = true;
// if (!this.measureLockMark && this.element.parentElement) {
// this._prevParentEl.removeChild(this.element);
// }
}
private rebuildStyle() {
// let style = "";
// if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
// style += `transition-delay:${this.delay}ms;`;
// }
// style += `filter:blur(${Math.min(32, this.blur)}px);`;
// if (style !== this.lastStyle) {
// this.lastStyle = style;
// this.element.setAttribute("style", style);
// }
}
rebuildElement() {
this.disposeElements();
const main = this.element.children[0] as HTMLDivElement;
const trans = this.element.children[1] as HTMLDivElement;
const roman = this.element.children[2] as HTMLDivElement;
// 如果是非动态歌词,那么就不需要分词了
if (this.lyricPlayer._getIsNonDynamic()) {
main.innerText = this.lyricLine.words.map((w) => w.word).join("");
trans.innerText = this.lyricLine.translatedLyric;
roman.innerText = this.lyricLine.romanLyric;
return;
}
const chunkedWords = chunkAndSplitLyricWords(this.lyricLine.words);
main.innerHTML = "";
for (const chunk of chunkedWords) {
if (Array.isArray(chunk)) {
// 多个没有空格的单词组合成的一个单词数组
if (chunk.length === 0) continue;
const merged = chunk.reduce(
(a, b) => {
a.endTime = Math.max(a.endTime, b.endTime);
a.startTime = Math.min(a.startTime, b.startTime);
a.word += b.word;
return a;
},
{
word: "",
romanWord: "",
startTime: Number.POSITIVE_INFINITY,
endTime: Number.NEGATIVE_INFINITY,
wordType: "normal",
obscene: false,
} as LyricWord,
);
const emp = chunk
.map((word) => LyricLineBase.shouldEmphasize(word))
.reduce((a, b) => a || b, LyricLineBase.shouldEmphasize(merged));
const wrapperWordEl = document.createElement("span");
wrapperWordEl.classList.add(styles.emphasizeWrapper);
const characterElements: HTMLElement[] = [];
for (const word of chunk) {
const mainWordEl = document.createElement("span");
// const mainWordFloatAnimation = this.initFloatAnimation(
// merged,
// mainWordEl,
// );
if (emp) {
mainWordEl.classList.add(styles.emphasize);
const charEls: HTMLSpanElement[] = [];
for (const char of word.word.trim()) {
const charEl = document.createElement("span");
charEl.innerText = char;
charEls.push(charEl);
characterElements.push(charEl);
mainWordEl.appendChild(charEl);
}
const realWord: RealWord = {
...word,
mainElement: mainWordEl,
subElements: charEls,
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
maskAnimations: [],
width: 0,
height: 0,
padding: 0,
shouldEmphasize: emp,
};
this.splittedWords.push(realWord);
} else {
mainWordEl.innerText = word.word;
this.splittedWords.push({
...word,
mainElement: mainWordEl,
subElements: [],
// elementAnimations: [this.initFloatAnimation(word, mainWordEl)],
elementAnimations: [], // this.initFloatAnimation(word, mainWordEl)
maskAnimations: [],
width: 0,
height: 0,
padding: 0,
shouldEmphasize: emp,
});
}
wrapperWordEl.appendChild(mainWordEl);
}
if (emp) {
this.splittedWords[
this.splittedWords.length - 1
].elementAnimations.push(
...this.initEmphasizeAnimation(
merged,
characterElements,
merged.endTime - merged.startTime,
merged.startTime - this.lyricLine.startTime,
),
);
}
if (merged.word.trimStart() !== merged.word) {
main.appendChild(document.createTextNode(" "));
}
main.appendChild(wrapperWordEl);
if (
merged.word.trimEnd() !== merged.word &&
LyricLineBase.shouldEmphasize(merged)
) {
main.appendChild(document.createTextNode(" "));
}
} else if (chunk.word.trim().length === 0) {
// 纯空格
main.appendChild(document.createTextNode(" "));
} else {
// 单个单词
const emp = LyricLineBase.shouldEmphasize(chunk);
const mainWordEl = document.createElement("span");
const realWord: RealWord = {
...chunk,
mainElement: mainWordEl,
subElements: [],
// elementAnimations: [this.initFloatAnimation(chunk, mainWordEl)],
elementAnimations: [], // this.initFloatAnimation(chunk, mainWordEl)
maskAnimations: [],
width: 0,
height: 0,
padding: 0,
shouldEmphasize: emp,
};
if (LyricLineBase.shouldEmphasize(chunk)) {
mainWordEl.classList.add(styles.emphasize);
const charEls: HTMLSpanElement[] = [];
for (const char of chunk.word.trim()) {
const charEl = document.createElement("span");
charEl.innerText = char;
charEls.push(charEl);
mainWordEl.appendChild(charEl);
}
realWord.subElements = charEls;
const duration = Math.abs(realWord.endTime - realWord.startTime);
realWord.elementAnimations.push(
...this.initEmphasizeAnimation(
chunk,
charEls,
duration,
realWord.startTime - this.lyricLine.startTime,
),
);
// realWord.elementAnimations = this.initEmphasizeAnimation(realWord);
} else {
mainWordEl.innerText = chunk.word.trim();
}
if (chunk.word.trimStart() !== chunk.word) {
main.appendChild(document.createTextNode(" "));
}
main.appendChild(mainWordEl);
if (chunk.word.trimEnd() !== chunk.word) {
main.appendChild(document.createTextNode(" "));
}
this.splittedWords.push(realWord);
}
}
trans.innerText = this.lyricLine.translatedLyric;
roman.innerText = this.lyricLine.romanLyric;
}
// 按照原 Apple Music 参考,强调效果只应用缩放、轻微左右位移和辉光效果,原主要的悬浮位移效果不变
// 为了避免产生锯齿抖动感,使用 matrix3d 来实现缩放和位移
private initEmphasizeAnimation(
word: LyricWord,
characterElements: HTMLElement[],
duration: number,
delay: number,
): Animation[] {
const de = Math.max(0, delay);
let du = Math.max(1000, duration);
let result: Animation[] = [];
let amount = du / 2000;
amount = amount > 1 ? Math.sqrt(amount) : amount ** 3;
let blur = du / 3000;
blur = blur > 1 ? Math.sqrt(blur) : blur ** 3;
amount *= 0.6;
blur *= 0.5;
if (
this.lyricLine.words.length > 0 &&
word.word.includes(
this.lyricLine.words[this.lyricLine.words.length - 1].word,
)
) {
amount *= 1.6;
blur *= 1.5;
du *= 1.2;
}
amount = Math.min(1.2, amount);
blur = Math.min(0.8, blur);
const animateDu = Number.isFinite(du) ? du : 0;
const empEasing = makeEmpEasing(EMP_EASING_MID);
result = characterElements.flatMap((el, i, arr) => {
const wordDe = de + (du / 2.5 / arr.length) * i;
const result: Animation[] = [];
const frames: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
.fill(0)
.map((_, j) => {
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
const transX = empEasing(x);
const glowLevel = empEasing(x) * blur;
const mat = scaleMatrix4(createMatrix4(), 1 + transX * 0.1 * amount);
const offsetX = -transX * 0.03 * amount * (arr.length / 2 - i);
const offsetY = -transX * 0.025 * amount;
return {
offset: x,
transform: `${matrix4ToCSS(
mat,
4,
)} translate(${offsetX}em, ${offsetY}em)`,
textShadow: `0 0 ${Math.min(
0.3,
blur * 0.3,
)}em rgba(255, 255, 255, ${glowLevel})`,
};
});
const glow = el.animate(frames, {
duration: animateDu,
delay: Number.isFinite(wordDe) ? wordDe : 0,
id: `emphasize-word-${el.innerText}-${i}`,
iterations: 1,
composite: "replace",
fill: "both",
});
glow.onfinish = () => {
glow.pause();
};
glow.pause();
result.push(glow);
const floatFrame: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY)
.fill(0)
.map((_, j) => {
const x = (j + 1) / ANIMATION_FRAME_QUANTITY;
let y = Math.sin(x * Math.PI);
// y = x < 0.5 ? y : Math.max(y, 1.0);
if (this.lyricLine.isBG) {
y *= 2;
}
return {
offset: x,
transform: `translateY(${-y * 0.05}em)`,
};
});
const float = el.animate(floatFrame, {
duration: animateDu * 1.4,
delay: Number.isFinite(wordDe) ? wordDe - 400 : 0,
id: "emphasize-word-float",
iterations: 1,
composite: "add",
fill: "both",
});
float.onfinish = () => {
float.pause();
};
float.pause();
result.push(float);
return result;
});
return result;
}
private get totalDuration() {
return this.lyricLine.endTime - this.lyricLine.startTime;
}
private maskImageDirty = false;
private markImageDirtyPromiseResolve: Set<() => void> = new Set();
private markImageDirtyPromise: Promise<void> = new Promise((resolve) => {
this.markImageDirtyPromiseResolve.add(resolve);
});
markMaskImageDirty(_debugReason = ""): Promise<void> {
this.maskImageDirty = true;
if (!this.element.classList.contains(styles.dirty))
this.element.classList.add(styles.dirty);
// if (import.meta.env.DEV) {
// console.log("Mark mask image dirty: ", _debugReason);
// }
const newPromise = Promise.all([
this.markImageDirtyPromise,
new Promise<void>((resolve) => {
this.markImageDirtyPromiseResolve.add(resolve);
}),
]).then(() => {});
this.markImageDirtyPromise = newPromise;
return newPromise;
}
waitMaskImageUpdated(): Promise<void> {
return this.markImageDirtyPromise;
}
async updateMaskImage() {
if (
!this.element.checkVisibility({
contentVisibilityAuto: true,
})
)
return;
this.maskImageDirty = false;
await this.measureLock(async () => {
await Promise.all(
this.splittedWords.map(async (word) => {
const el = word.mainElement;
if (el) {
await measure(() => {
word.padding = Number.parseFloat(
getComputedStyle(el).paddingLeft,
);
word.width = el.clientWidth - word.padding * 2;
word.height = el.clientHeight - word.padding * 2;
});
} else {
word.width = 0;
word.height = 0;
word.padding = 0;
}
if (word.width * word.height === 0) {
console.warn("Word size is zero");
}
}),
);
await mutate(() => {
if (this.lyricPlayer.supportMaskImage) {
this.generateWebAnimationBasedMaskImage();
} else {
this.generateCalcBasedMaskImage();
}
});
});
for (const resolve of this.markImageDirtyPromiseResolve) {
resolve();
this.markImageDirtyPromiseResolve.delete(resolve);
}
await mutate(() => {
this.element.classList.remove(styles.dirty);
});
}
private generateCalcBasedMaskImage() {
for (const word of this.splittedWords) {
const wordEl = word.mainElement;
if (wordEl) {
word.width = wordEl.clientWidth;
word.height = wordEl.clientHeight;
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
const [maskImage, totalAspect] = generateFadeGradient(
fadeWidth / word.width,
);
const totalAspectStr = `${totalAspect * 100}% 100%`;
if (this.lyricPlayer.supportMaskImage) {
wordEl.style.maskImage = maskImage;
wordEl.style.maskRepeat = "no-repeat";
wordEl.style.maskOrigin = "left";
wordEl.style.maskSize = totalAspectStr;
} else {
wordEl.style.webkitMaskImage = maskImage;
wordEl.style.webkitMaskRepeat = "no-repeat";
wordEl.style.webkitMaskOrigin = "left";
wordEl.style.webkitMaskSize = totalAspectStr;
}
const w = word.width + fadeWidth;
const maskPos = `clamp(${-w}px,calc(${-w}px + (var(--amll-player-time) - ${
word.startTime
})*${
w / Math.abs(word.endTime - word.startTime)
}px),0px) 0px, left top`;
wordEl.style.maskPosition = maskPos;
wordEl.style.webkitMaskPosition = maskPos;
}
}
}
private generateWebAnimationBasedMaskImage() {
// 因为歌词行有可能比行内单词的结束时间早,有可能导致过渡动画提早停止出现瑕疵
// 所以要以单词的结束时间为准
const totalFadeDuration =
Math.max(
this.splittedWords.reduce((pv, w) => Math.max(w.endTime, pv), 0),
this.lyricLine.endTime,
) - this.lyricLine.startTime;
this.splittedWords.forEach((word, i) => {
const wordEl = word.mainElement;
if (wordEl) {
const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth();
const [maskImage, totalAspect] = generateFadeGradient(
fadeWidth / (word.width + word.padding * 2),
);
const totalAspectStr = `${totalAspect * 100}% 100%`;
if (this.lyricPlayer.supportMaskImage) {
wordEl.style.maskImage = maskImage;
wordEl.style.maskRepeat = "no-repeat";
wordEl.style.maskOrigin = "left";
wordEl.style.maskSize = totalAspectStr;
} else {
wordEl.style.webkitMaskImage = maskImage;
wordEl.style.webkitMaskRepeat = "no-repeat";
wordEl.style.webkitMaskOrigin = "left";
wordEl.style.webkitMaskSize = totalAspectStr;
}
// 为了尽可能将渐变动画在相连的每个单词间近似衔接起来
// 要综合每个单词的效果时间和间隙生成动画帧数组
const widthBeforeSelf =
this.splittedWords.slice(0, i).reduce((a, b) => a + b.width, 0) +
(this.splittedWords[0] ? fadeWidth : 0);
const minOffset = -(word.width + word.padding * 2 + fadeWidth);
const clampOffset = (x: number) => Math.max(minOffset, Math.min(0, x));
let curPos = -widthBeforeSelf - word.width - word.padding - fadeWidth;
let timeOffset = 0;
const frames: Keyframe[] = [];
let lastPos = curPos;
let lastTime = 0;
const pushFrame = () => {
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
// const easing = "cubic-bezier(.33,.12,.83,.9)";
const moveOffset = curPos - lastPos;
const time = Math.max(0, Math.min(1, timeOffset));
const duration = time - lastTime;
const d = Math.abs(duration / moveOffset);
// 因为有可能会和之前的动画有边界
if (curPos > minOffset && lastPos < minOffset) {
const staticTime = Math.abs(lastPos - minOffset) * d;
const value = `${clampOffset(lastPos)}px 0`;
const frame: Keyframe = {
offset: lastTime + staticTime,
maskPosition: value,
};
frames.push(frame);
}
if (curPos > 0 && lastPos < 0) {
const staticTime = Math.abs(lastPos) * d;
const value = `${clampOffset(curPos)}px 0`;
const frame: Keyframe = {
offset: lastTime + staticTime,
maskPosition: value,
};
frames.push(frame);
}
const value = `${clampOffset(curPos)}px 0`;
const frame: Keyframe = {
offset: time,
maskPosition: value,
};
frames.push(frame);
lastPos = curPos;
lastTime = time;
};
pushFrame();
let lastTimeStamp = 0;
this.splittedWords.forEach((otherWord, j) => {
// 停顿
{
const curTimeStamp = otherWord.startTime - this.lyricLine.startTime;
const staticDuration = curTimeStamp - lastTimeStamp;
timeOffset += staticDuration / totalFadeDuration;
if (staticDuration > 0) pushFrame();
lastTimeStamp = curTimeStamp;
}
// 移动
{
const fadeDuration = otherWord.endTime - otherWord.startTime;
timeOffset += fadeDuration / totalFadeDuration;
curPos += otherWord.width;
if (j === 0) {
curPos += fadeWidth * 1.5;
}
if (j === this.splittedWords.length - 1) {
curPos += fadeWidth * 0.5;
}
if (fadeDuration > 0) pushFrame();
lastTimeStamp += fadeDuration;
}
});
for (const a of word.maskAnimations) {
a.cancel();
}
try {
// TODO: 如果此处动画帧计算出错,需要一个后备方案
// 此处如果添加过渡函数,会导致单词时序不准确,所以不添加
const ani = wordEl.animate(frames, {
duration: totalFadeDuration || 1,
id: `fade-word-${word.word}-${i}`,
fill: "both",
});
ani.pause();
word.maskAnimations = [ani];
} catch (err) {
console.warn("应用渐变动画发生错误", frames, totalFadeDuration, err);
}
}
});
}
getElement() {
return this.element;
}
override setTransform(
top: number = this.top,
scale: number = this.scale,
opacity = 1,
blur = 0,
force = false,
delay = 0,
) {
super.setTransform(top, scale, opacity, blur, force, delay);
const beforeInSight = this.isInSight;
const enableSpring = this.lyricPlayer.getEnableSpring();
this.top = top;
this.scale = scale;
this.delay = (delay * 1000) | 0;
const main = this.element.children[0] as HTMLDivElement;
// main.style.opacity = `${opacity *
// (!this.hasFaded ? 1 : this.lyricPlayer._getIsNonDynamic() ? 1 : 0.3)
// }`;
main.style.opacity = `${opacity}`;
// trans.style.opacity = `${subopacity}`;
// roman.style.opacity = `${subopacity}`;
if (force || !enableSpring) {
if (force) this.element.classList.add(styles.tmpDisableTransition);
// this.lineWebAnimationTransforms.posX.setTargetPosition(left);
// this.lineWebAnimationTransforms.posY.setTargetPosition(top);
// this.lineWebAnimationTransforms.scale.setTargetPosition(scale);
this.lineTransforms.posY.setPosition(top);
this.lineTransforms.scale.setPosition(scale);
if (!enableSpring) {
const afterInSight = this.isInSight;
if (beforeInSight || afterInSight) {
this.show();
} else {
this.hide();
}
} else this.rebuildStyle();
if (force)
requestAnimationFrame(() => {
this.element.classList.remove(styles.tmpDisableTransition);
});
} else {
// this.lineWebAnimationTransforms.posX.stop();
// this.lineWebAnimationTransforms.posY.stop();
// this.lineWebAnimationTransforms.scale.stop();
this.lineTransforms.posY.setTargetPosition(top, delay);
this.lineTransforms.scale.setTargetPosition(scale);
}
}
update(delta = 0) {
if (!this.lyricPlayer.getEnableSpring()) return;
this.lineTransforms.posY.update(delta);
this.lineTransforms.scale.update(delta);
if (this.isInSight) {
this.show();
if (this.maskImageDirty) {
this.updateMaskImage();
}
} else {
this.hide();
}
if (this.lyricPlayer.getEnableSpring()) {
this.element.style.setProperty(
"--bright-mask-alpha",
`${
Math.max(
0.0,
Math.min(
1.0,
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
) / 0.03,
) *
0.8 +
0.2
}`,
);
this.element.style.setProperty(
"--dark-mask-alpha",
`${
Math.max(
0.0,
Math.min(
1.0,
this.lineTransforms.scale.getCurrentPosition() / 100 - 0.97,
) / 0.03,
) *
0.2 +
0.2
}`,
);
} else {
const computedStyle = window.getComputedStyle(this.element);
const transform = computedStyle.transform;
// Extract the scale value from the transform property
const scale = getScaleFromTransform(transform);
this.element.style.setProperty(
"--bright-mask-alpha",
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.8 + 0.2}`,
);
this.element.style.setProperty(
"--dark-mask-alpha",
`${Math.max(0.0, Math.min(1.0, (scale - 0.97) / 0.03)) * 0.2 + 0.2}`,
);
}
}
_getDebugTargetPos(): string {
return `[位移: ${this.top}; 缩放: ${this.scale}; 延时: ${this.delay}]`;
}
get isInSight() {
const t = this.lineTransforms.posY.getCurrentPosition();
const h = this.lineSize[1];
const b = t + h;
const pb = this.lyricPlayer.size[1];
return !(t > pb + h || b < -h);
}
private disposeElements() {
for (const realWord of this.splittedWords) {
for (const a of realWord.elementAnimations) {
a.cancel();
}
for (const a of realWord.maskAnimations) {
a.cancel();
}
for (const sub of realWord.subElements) {
sub.remove();
sub.parentNode?.removeChild(sub);
}
realWord.elementAnimations = [];
realWord.maskAnimations = [];
realWord.subElements = [];
realWord.mainElement.remove();
realWord.mainElement.parentNode?.removeChild(realWord.mainElement);
}
this.splittedWords = [];
}
override dispose(): void {
this.disposeElements();
this.element.remove();
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* @fileoverview
* 一个播放歌词的组件
* @author SteveXMH
*/
import type { LyricLine } from "../../interfaces.ts";
import "../../styles/index.css";
import styles from "../../styles/lyric-player.module.css";
import { type LyricLineBase, LyricPlayerBase } from "../base.ts";
import { LyricLineEl, type RawLyricLineMouseEvent } from "./lyric-line.ts";
/**
* 歌词行鼠标相关事件,可以获取到歌词行的索引和歌词行元素
*/
export class LyricLineMouseEvent extends MouseEvent {
constructor(
/**
* 歌词行索引
*/
public readonly lineIndex: number,
/**
* 歌词行元素
*/
public readonly line: LyricLineBase,
event: MouseEvent,
) {
super(`line-${event.type}`, event);
}
}
export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void;
/**
* 歌词播放组件,本框架的核心组件
*
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
*/
export class DomLyricPlayer extends LyricPlayerBase {
override currentLyricLineObjects: LyricLineEl[] = [];
override onResize(): void {
const computedStyles = getComputedStyle(this.element);
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
this.rebuildStyle();
}
readonly supportPlusLighter = CSS.supports("mix-blend-mode", "plus-lighter");
readonly supportMaskImage = CSS.supports("mask-image", "none");
readonly innerSize: [number, number] = [0, 0];
private readonly onLineClickedHandler = (e: RawLyricLineMouseEvent) => {
const evt = new LyricLineMouseEvent(
this.lyricLinesIndexes.get(e.line) ?? -1,
e.line,
e,
);
if (!this.dispatchEvent(evt)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
/**
* 是否为非逐词歌词
* @internal
*/
_getIsNonDynamic() {
return this.isNonDynamic;
}
private _baseFontSize = Number.parseFloat(
getComputedStyle(this.element).fontSize,
);
public get baseFontSize() {
return this._baseFontSize;
}
constructor() {
super();
this.onResize();
this.element.classList.add("amll-lyric-player", "dom");
if (this.disableSpring) {
this.element.classList.add(styles.disableSpring);
}
}
private rebuildStyle() {
// const width = this.innerSize[0];
// const height = this.innerSize[1];
// this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
// this.element.style.setProperty(
// "--amll-lp-height",
// `${height.toFixed(4)}px`,
// );
}
override setWordFadeWidth(value = 0.5) {
super.setWordFadeWidth(value);
for (const el of this.currentLyricLineObjects) {
el.updateMaskImageSync();
}
}
/**
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
* @param lines 歌词数组
* @param initialTime 初始时间,默认为 0
*/
override setLyricLines(lines: LyricLine[], initialTime = 0) {
super.setLyricLines(lines, initialTime);
if (this.hasDuetLine) {
this.element.classList.add(styles.hasDuetLine);
} else {
this.element.classList.remove(styles.hasDuetLine);
}
if (!this.supportMaskImage) {
this.element.style.setProperty("--amll-player-time", `${initialTime}`);
}
for (const line of this.currentLyricLineObjects) {
line.removeMouseEventListener("click", this.onLineClickedHandler);
line.removeMouseEventListener("contextmenu", this.onLineClickedHandler);
line.dispose();
}
// 创建新的歌词行元素
this.currentLyricLineObjects = this.processedLines.map((line, i) => {
const lineEl = new LyricLineEl(this, line);
lineEl.addMouseEventListener("click", this.onLineClickedHandler);
lineEl.addMouseEventListener("contextmenu", this.onLineClickedHandler);
// 不立即挂载到 DOM,进入视图(含 overscan)后在 LyricLineEl 内部挂载
this.lyricLinesIndexes.set(lineEl, i);
// 仍需建立元素到行对象的映射,供 ResizeObserver 使用
this.lyricLineElementMap.set(lineEl.getElement(), lineEl);
return lineEl;
});
this.setLinePosXSpringParams({});
this.setLinePosYSpringParams({});
this.setLineScaleSpringParams({});
this.calcLayout(true);
// 触发一次更新以便立即挂载在视区/overscan 内的行元素
this.update(0);
}
override pause() {
super.pause();
this.element.classList.remove("playing");
this.interludeDots.pause();
for (const line of this.currentLyricLineObjects) {
line.pause();
}
}
override resume() {
super.resume();
this.element.classList.add("playing");
this.interludeDots.resume();
for (const line of this.currentLyricLineObjects) {
line.resume();
}
}
override update(delta = 0) {
if (!this.initialLayoutFinished) return;
super.update(delta);
if (!this.supportMaskImage) {
this.element.style.setProperty(
"--amll-player-time",
`${this.currentTime}`,
);
}
if (!this.isPageVisible) return;
const deltaS = delta / 1000;
this.interludeDots.update(delta);
this.bottomLine.update(deltaS);
for (const line of this.currentLyricLineObjects) {
line.update(deltaS);
}
}
override dispose(): void {
super.dispose();
this.element.remove();
for (const el of this.currentLyricLineObjects) {
el.dispose();
}
this.bottomLine.dispose();
this.interludeDots.dispose();
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { Disposable, HasElement } from "../../interfaces.ts";
import styles from "../../styles/lyric-player.module.css";
function easeInOutBack(x: number): number {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
}
function easeOutExpo(x: number): number {
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
}
const clamp = (min: number, cur: number, max: number) =>
Math.max(min, Math.min(cur, max));
export class InterludeDots implements HasElement, Disposable {
private element: HTMLElement = document.createElement("div");
private dot0: HTMLElement = document.createElement("span");
private dot1: HTMLElement = document.createElement("span");
private dot2: HTMLElement = document.createElement("span");
private left = 0;
private top = 0;
private playing = true;
private lastStyle = "";
private currentInterlude?: [number, number];
private currentTime = 0;
private targetBreatheDuration = 1500;
constructor() {
this.element.className = styles.interludeDots;
this.element.appendChild(this.dot0);
this.element.appendChild(this.dot1);
this.element.appendChild(this.dot2);
}
getElement() {
return this.element;
}
setTransform(left: number = this.left, top: number = this.top) {
this.left = left;
this.top = top;
this.update();
}
setInterlude(interlude?: [number, number]) {
this.currentInterlude = interlude;
this.currentTime = interlude?.[0] ?? 0;
if (interlude) {
this.element.classList.add(styles.enabled);
} else {
this.element.classList.remove(styles.enabled);
}
}
pause() {
this.playing = false;
this.element.classList.remove(styles.playing);
}
resume() {
this.playing = true;
this.element.classList.add(styles.playing);
}
update(delta = 0) {
if (!this.playing) return;
this.currentTime += delta;
let curStyle = "";
curStyle += `transform:translate(${this.left.toFixed(
2,
)}px, ${this.top.toFixed(2)}px)`;
// 计算缩放大小
if (this.currentInterlude) {
const interludeDuration =
this.currentInterlude[1] - this.currentInterlude[0];
const currentDuration = this.currentTime - this.currentInterlude[0];
if (currentDuration <= interludeDuration) {
const breatheDuration =
interludeDuration /
Math.ceil(interludeDuration / this.targetBreatheDuration);
let scale = 1;
let globalOpacity = 1;
scale *=
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
20 +
1;
if (currentDuration < 2000) {
scale *= easeOutExpo(currentDuration / 2000);
}
if (currentDuration < 500) {
globalOpacity = 0;
} else if (currentDuration < 1000) {
globalOpacity *= (currentDuration - 500) / 500;
}
if (interludeDuration - currentDuration < 750) {
scale *=
1 -
easeInOutBack(
(750 - (interludeDuration - currentDuration)) / 750 / 2,
);
}
if (interludeDuration - currentDuration < 375) {
globalOpacity *= clamp(
0,
(interludeDuration - currentDuration) / 375,
1,
);
}
const dotsDuration = Math.max(0, interludeDuration - 750);
scale = Math.max(0, scale) * 0.7;
curStyle += ` scale(${scale})`;
const dot0Opacity = clamp(
0.25,
((currentDuration * 3) / dotsDuration) * 0.75,
1,
);
const dot1Opacity = clamp(
0.25,
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
1,
);
const dot2Opacity = clamp(
0.25,
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
0.75,
1,
);
this.dot0.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot0Opacity),
1,
)}`;
this.dot1.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot1Opacity),
1,
)}`;
this.dot2.style.opacity = `${clamp(
0,
Math.max(0, globalOpacity * dot2Opacity),
1,
)}`;
} else {
curStyle += " scale(0)";
this.dot0.style.opacity = "0";
this.dot1.style.opacity = "0";
this.dot2.style.opacity = "0";
}
curStyle += ";";
if (this.lastStyle !== curStyle) {
this.element.setAttribute("style", curStyle);
this.lastStyle = curStyle;
}
}
}
dispose() {
this.element.remove();
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
import { DomLyricPlayer } from "./dom/index.ts";
export { LyricPlayerBase } from "./base.ts";
export * from "./canvas/index.ts";
export * from "./dom-slim/index.ts";
export * from "./dom/index.ts";
/**
* 歌词中不雅用语的掩码模式
*/
enum MaskObsceneWordsMode {
/** 禁用任何不雅用语掩码 */
Disabled = "",
/** 完全掩码所有不雅用语 */
FullMask = "full-mask",
// TODO: 更多模式
}
export {
MaskObsceneWordsMode,
/**
* 默认导出的歌词播放组件
*/
DomLyricPlayer as LyricPlayer,
};
+231
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+390
View File
@@ -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();
})();
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*.glsl?raw" {
const content: string;
export default content;
}
+30
View File
@@ -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
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
export const eqSet: <T>(xs: Set<T>, ys: Set<T>) => boolean = (
xs,
ys,
): boolean => xs.size === ys.size && [...xs].every((x) => ys.has(x));
+59
View File
@@ -0,0 +1,59 @@
const SAFE_FLAG_CHUNK_SIZE = Number.MAX_SAFE_INTEGER.toString(2).length;
export class FlagSets {
private flags: number[] = [];
private _size = 0;
get size() {
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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export const isCJK = (char: string) => {
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
};
+120
View File
@@ -0,0 +1,120 @@
type seconds = number;
export interface LinearParams {
duration: number; // Duration of the transition in seconds
}
export class Linear {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<LinearParams> = {};
private currentSolver: (t: seconds) => number;
private startTime = 0;
private queueParams:
| (Partial<LinearParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
}
private resetSolver() {
this.currentTime = 0;
this.startTime = 0;
this.currentSolver = solveLinear(
this.currentPosition,
this.targetPosition,
this.params,
);
}
arrived() {
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
View File
@@ -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;
}
+47
View File
@@ -0,0 +1,47 @@
// biome-ignore format: matrix
export type Matrix4 = [
number, number, number, number,
number, number, number, number,
number, number, number, number,
number, number, number, number,
];
export function createMatrix4(): Matrix4 {
// biome-ignore format: matrix
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
export function scaleMatrix4(
m: Matrix4,
scale = 1,
origin = { x: 0, y: 0 },
): Matrix4 {
const [ox, oy] = [origin.x, origin.y];
// biome-ignore format: matrix
return [
m[0] * scale , m[1] * scale , m[2] * scale , m[3],
m[4] * scale , m[5] * scale , m[6] * scale , m[7],
m[8] * scale , m[9] * scale , m[10] * scale, m[11],
m[12] - ox * scale + ox, m[13] - oy * scale + oy, m[14] , m[15]
];
}
export function translateMatrix4(m: Matrix4, x = 0, y = 0, z = 0): Matrix4 {
// biome-ignore format: matrix
return [
m[0] , m[1] , m[2] , m[3] ,
m[4] , m[5] , m[6] , m[7] ,
m[8] , m[9] , m[10] , m[11],
m[12] + x, m[13] + y, m[14] + z, m[15]
];
}
export function matrix4ToCSS(m: Matrix4, fractionDigits = 4): string {
const format = (n: number, _: number) => n.toFixed(fractionDigits);
return `matrix3d(${m.map(format).join(", ")})`;
}
+40
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,79 @@
export function loadImage(imageUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => resolve(img);
img.onerror = reject;
img.src = imageUrl;
img.crossOrigin = "anonymous";
img.loading = "eager";
});
}
export function loadVideo(videoUrl: string): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
let playing = false;
let timeupdate = false;
let rejected = false;
video.addEventListener(
"playing",
() => {
playing = true;
checkReady();
},
true,
);
video.addEventListener(
"timeupdate",
() => {
timeupdate = true;
checkReady();
},
true,
);
video.addEventListener(
"error",
(err) => {
rejected = true;
reject(err);
},
true,
);
function checkReady() {
if (playing && timeupdate && !rejected) {
resolve(video);
}
}
video.src = videoUrl;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.autoplay = true;
video.loop = true;
video.muted = true;
video.play();
});
}
export function loadResourceFromUrl(
url: string,
isVideo = false,
): Promise<HTMLImageElement | HTMLVideoElement> {
return isVideo ? loadVideo(url) : loadImage(url);
}
export function loadResourceFromElement(
element: HTMLImageElement | HTMLVideoElement,
): Promise<HTMLImageElement | HTMLVideoElement> {
return new Promise((resolve, reject) => {
if (
element instanceof HTMLImageElement
? element.complete
: element.readyState >= 3
) {
resolve(element);
} else {
element.onload = () => resolve(element);
element.onerror = reject;
}
});
}
+73
View File
@@ -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;
}
+158
View File
@@ -0,0 +1,158 @@
import { getVelocity } from "./derivative.ts";
/** MIT License github.com/pushkine/ */
export interface SpringParams {
mass: number; // = 1.0
damping: number; // = 10.0
stiffness: number; // = 100.0
soft: boolean; // = false
}
type seconds = number;
export class Spring {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number;
private getV: (t: seconds) => number;
private getV2: (t: seconds) => number;
private queueParams:
| (Partial<SpringParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
private resetSolver() {
const curV = this.getV(this.currentTime);
this.currentTime = 0;
this.currentSolver = solveSpring(
this.currentPosition,
curV,
this.targetPosition,
0,
this.params,
);
this.getV = getVelocity(this.currentSolver);
this.getV2 = getVelocity(this.getV);
}
arrived() {
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
View File
@@ -0,0 +1,139 @@
import bezier from "bezier-easing";
import type { Disposable } from "../interfaces.ts";
import { getVelocity } from "./derivative";
// export interface SpringParams {
// mass: number; // = 1.0
// damping: number; // = 10.0
// stiffness: number; // = 100.0
// }
type seconds = number;
type CSSStyleKeys = {
[Style in keyof CSSStyleDeclaration]: Style extends string
? CSSStyleDeclaration[Style] extends string
? Style
: never
: never;
}[keyof CSSStyleDeclaration];
/**
* 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本
*/
export class WebAnimationSpring extends EventTarget implements Disposable {
private currentAnimation: Animation;
private targetPosition = 0;
private isStatic = true;
// private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number = () => this.targetPosition;
private getV: (t: seconds) => number = () => 0;
constructor(
private element: HTMLElement,
private styleName: CSSStyleKeys,
private valueGenerator: (value: number) => string,
private currentPosition = 0,
) {
super();
this.targetPosition = currentPosition;
this.currentAnimation = element.animate(
[
{
[styleName]: valueGenerator(currentPosition),
},
],
{
duration: 1000,
fill: "both",
composite: "add",
},
);
}
makeStatic() {
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();
}
}
+29
View File
@@ -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"]
}
+40
View File
@@ -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"],
}),
],
});