Files
QZMusic_PC-pre/amll-local/packages/react/src/lyric-player.tsx
lqtmcstudio 72f4510dc8 fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork)
- 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
2026-06-07 00:02:14 +08:00

386 lines
12 KiB
TypeScript

import type {
LyricLine,
LyricLineMouseEvent,
LyricPlayerBase,
OptimizeLyricOptions,
spring,
} from "@applemusic-like-lyrics/core";
import {
LyricPlayer as DefaultLyricPlayer,
MaskObsceneWordsMode,
} from "@applemusic-like-lyrics/core";
import {
type ForwardRefExoticComponent,
forwardRef,
type HTMLProps,
type RefAttributes,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
/**
* 歌词播放组件的属性
*/
export interface LyricPlayerProps {
/**
* 是否禁用歌词播放组件,默认为 `false`,歌词组件启用后将会开始逐帧更新歌词的动画效果,并对传入的其他参数变更做出反馈。
*
* 如果禁用了歌词组件动画,你也可以通过引用取得原始渲染组件实例,手动逐帧调用其 `update` 函数来更新动画效果。
*/
disabled?: boolean;
/**
* 是否演出部分效果,目前会控制播放间奏点的动画的播放暂停与否,默认为 `true`
*/
playing?: boolean;
/**
* 设置歌词行的对齐方式,如果为 `undefined` 则默认为 `center`
*
* - 设置成 `top` 的话将会向目标歌词行的顶部对齐
* - 设置成 `bottom` 的话将会向目标歌词行的底部对齐
* - 设置成 `center` 的话将会向目标歌词行的垂直中心对齐
*/
alignAnchor?: "top" | "bottom" | "center";
/**
* 设置默认的歌词行对齐位置,相对于整个歌词播放组件的大小位置,如果为 `undefined`
* 则默认为 `0.5`
*
* 可以设置一个 `[0.0-1.0]` 之间的任意数字,代表组件高度由上到下的比例位置
*/
alignPosition?: number;
/**
* 设置是否使用物理弹簧算法实现歌词动画效果,默认启用
*
* 如果启用,则会通过弹簧算法实时处理歌词位置,但是需要性能足够强劲的电脑方可流畅运行
*
* 如果不启用,则会回退到基于 `transition` 的过渡效果,对低性能的机器比较友好,但是效果会比较单一
*/
enableSpring?: boolean;
/**
* 设置是否启用歌词行的模糊效果,默认为 `true`
*/
enableBlur?: boolean;
/**
* 设置是否使用物理弹簧算法实现歌词动画效果,默认启用
*
* 如果启用,则会通过弹簧算法实时处理歌词位置,但是需要性能足够强劲的电脑方可流畅运行
*
* 如果不启用,则会回退到基于 `transition` 的过渡效果,对低性能的机器比较友好,但是效果会比较单一
*/
enableScale?: boolean;
/**
* 设置是否隐藏已经播放过的歌词行,默认不隐藏
*/
hidePassedLines?: boolean;
/**
* 设置歌词中不雅用语的掩码模式,默认为 `MaskObsceneWordsMode.Disabled`,即不掩码
*/
maskObsceneWordsMode?: MaskObsceneWordsMode;
/**
* 设置不雅用语掩码使用的字符,默认为 `*`
*/
maskObsceneWordChar?: string;
/**
* 设置歌词优化选项
*/
optimizeOptions?: OptimizeLyricOptions;
/**
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
*/
lyricLines?: LyricLine[];
/**
* 设置当前播放进度,单位为毫秒且**必须是整数**,此时将会更新内部的歌词进度信息
* 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好
*/
currentTime?: number;
isSeeking?: boolean;
/**
* 设置文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5,即一个全角字符的一半宽度
*
* 如果要模拟 Apple Music for Android 的效果,可以设置为 1
*
* 如果要模拟 Apple Music for iPad 的效果,可以设置为 0.5
*
* 如果想要近乎禁用渐变效果,可以设置成非常接近 0 的小数(例如 `0.0001` ),但是**不可以为 0**
*/
wordFadeWidth?: number;
/**
* 设置所有歌词行在横坐标上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
*/
linePosXSpringParams?: Partial<spring.SpringParams>;
/**
* 设置所有歌词行在​纵坐标上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
*/
linePosYSpringParams?: Partial<spring.SpringParams>;
/**
* 设置所有歌词行在​缩放大小上的弹簧属性,包括重量、弹力和阻力。
*
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
*/
lineScaleSpringParams?: Partial<spring.SpringParams>;
/**
* 在一个特殊的底栏元素内加入指定元素,默认是空白的,可以往内部添加任意元素
*
* 这个元素始终在歌词的底部,可以用于显示歌曲创作者等信息
*/
bottomLine?: Parameters<typeof createPortal>[0];
/**
* 需要用于创建歌词播放组件的类实例
*/
lyricPlayer?: {
new (
...args: ConstructorParameters<typeof LyricPlayerBase>
): LyricPlayerBase;
};
/**
* 当某个歌词行被左键点击时触发的事件
* @param line 歌词行的事件对象,可以访问到对应的歌词行信息和歌词行索引
*/
onLyricLineClick?: (line: LyricLineMouseEvent) => void;
/**
* 当某个歌词行被右键点击时触发的事件
* @param line 歌词行的事件对象,可以访问到对应的歌词行信息和歌词行索引
*/
onLyricLineContextMenu?: (line: LyricLineMouseEvent) => void;
}
/**
* 歌词播放组件的引用
*/
export interface LyricPlayerRef {
/**
* 歌词播放实例
*/
lyricPlayer?: LyricPlayerBase;
/**
* 将歌词播放实例的元素包裹起来的 DIV 元素实例
*/
wrapperEl: HTMLDivElement | null;
}
/**
* 歌词播放组件,本框架的核心组件
*
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
*/
export const LyricPlayer: ForwardRefExoticComponent<
Omit<HTMLProps<HTMLDivElement> & LyricPlayerProps, "ref"> &
RefAttributes<LyricPlayerRef>
> = forwardRef<LyricPlayerRef, HTMLProps<HTMLDivElement> & LyricPlayerProps>(
(
{
disabled,
playing,
alignAnchor,
alignPosition,
enableSpring,
enableBlur,
enableScale,
maskObsceneWordsMode,
maskObsceneWordChar,
hidePassedLines,
optimizeOptions,
lyricLines,
currentTime,
isSeeking,
wordFadeWidth,
linePosXSpringParams,
linePosYSpringParams,
lineScaleSpringParams,
bottomLine,
lyricPlayer,
onLyricLineClick,
onLyricLineContextMenu,
...props
},
ref,
) => {
// const corePlayerRef = useRef<LyricPlayerBase>();
const [corePlayer, setCorePlayer] = useState<LyricPlayerBase>();
const wrapperRef = useRef<HTMLDivElement>(null);
const currentTimeRef = useRef(currentTime);
useLayoutEffect(() => {
const newPlayer = new (lyricPlayer ?? DefaultLyricPlayer)();
setCorePlayer(newPlayer);
wrapperRef.current?.appendChild(newPlayer.getElement());
return () => {
newPlayer?.dispose();
setCorePlayer(undefined);
};
}, [lyricPlayer]);
useLayoutEffect(() => {
if (optimizeOptions !== undefined) {
corePlayer?.setOptimizeOptions(optimizeOptions);
}
if (lyricLines !== undefined) {
corePlayer?.setLyricLines(lyricLines, currentTimeRef.current);
if (currentTimeRef.current !== undefined) {
corePlayer?.setCurrentTime(currentTimeRef.current, true);
}
corePlayer?.update();
} else {
corePlayer?.setLyricLines([]);
corePlayer?.update();
}
}, [corePlayer, lyricLines, optimizeOptions]);
useEffect(() => {
if (!disabled) {
let canceled = false;
let lastTime = -1;
const onFrame = (time: number) => {
if (canceled) return;
if (lastTime === -1) {
lastTime = time;
}
corePlayer?.update(time - lastTime);
lastTime = time;
requestAnimationFrame(onFrame);
};
corePlayer?.calcLayout();
requestAnimationFrame(onFrame);
return () => {
canceled = true;
};
}
return;
}, [corePlayer, disabled]);
useEffect(() => {
if (playing !== undefined) {
if (playing) {
corePlayer?.resume();
} else {
corePlayer?.pause();
}
} else corePlayer?.resume();
}, [corePlayer, playing]);
useEffect(() => {
if (alignAnchor !== undefined) corePlayer?.setAlignAnchor(alignAnchor);
}, [corePlayer, alignAnchor]);
useEffect(() => {
if (hidePassedLines !== undefined)
corePlayer?.setHidePassedLines(hidePassedLines);
}, [corePlayer, hidePassedLines]);
useEffect(() => {
if (alignPosition !== undefined)
corePlayer?.setAlignPosition(alignPosition);
}, [corePlayer, alignPosition]);
useEffect(() => {
if (enableSpring !== undefined) corePlayer?.setEnableSpring(enableSpring);
else corePlayer?.setEnableSpring(true);
}, [corePlayer, enableSpring]);
useEffect(() => {
if (enableScale !== undefined) corePlayer?.setEnableScale(enableScale);
else corePlayer?.setEnableScale(true);
}, [corePlayer, enableScale]);
useEffect(() => {
corePlayer?.setEnableBlur(enableBlur ?? true);
}, [corePlayer, enableBlur]);
useLayoutEffect(() => {
if (currentTime !== undefined) {
corePlayer?.setCurrentTime(currentTime, isSeeking);
currentTimeRef.current = currentTime;
} else {
corePlayer?.setCurrentTime(0);
currentTimeRef.current = 0;
}
}, [corePlayer, currentTime, isSeeking]);
useEffect(() => {
corePlayer?.setIsSeeking(!!isSeeking);
}, [corePlayer, isSeeking]);
useEffect(() => {
corePlayer?.setWordFadeWidth(wordFadeWidth);
}, [corePlayer, wordFadeWidth]);
useEffect(() => {
if (linePosXSpringParams !== undefined)
corePlayer?.setLinePosXSpringParams(linePosXSpringParams);
}, [corePlayer, linePosXSpringParams]);
useEffect(() => {
if (linePosYSpringParams !== undefined)
corePlayer?.setLinePosYSpringParams(linePosYSpringParams);
}, [corePlayer, linePosYSpringParams]);
useEffect(() => {
if (lineScaleSpringParams !== undefined)
corePlayer?.setLineScaleSpringParams(lineScaleSpringParams);
}, [corePlayer, lineScaleSpringParams]);
useEffect(() => {
if (maskObsceneWordsMode !== undefined) {
corePlayer?.setMaskObsceneWords(maskObsceneWordsMode);
} else {
corePlayer?.setMaskObsceneWords(MaskObsceneWordsMode.Disabled);
}
}, [corePlayer, maskObsceneWordsMode]);
useEffect(() => {
if (maskObsceneWordChar !== undefined) {
corePlayer?.setMaskObsceneWordChar(maskObsceneWordChar);
}
}, [corePlayer, maskObsceneWordChar]);
useEffect(() => {
if (onLyricLineClick) {
const handler = (e: Event) =>
onLyricLineClick(e as LyricLineMouseEvent);
corePlayer?.addEventListener("line-click", handler);
return () => corePlayer?.removeEventListener("line-click", handler);
}
return;
}, [corePlayer, onLyricLineClick]);
useEffect(() => {
if (onLyricLineContextMenu) {
const handler = (e: Event) =>
onLyricLineContextMenu(e as LyricLineMouseEvent);
corePlayer?.addEventListener("line-contextmenu", handler);
return () =>
corePlayer?.removeEventListener("line-contextmenu", handler);
}
return;
}, [corePlayer, onLyricLineContextMenu]);
useImperativeHandle(
ref,
() => ({
wrapperEl: wrapperRef.current,
lyricPlayer: corePlayer,
}),
[corePlayer],
);
return (
<>
<div {...props} ref={wrapperRef} />
{corePlayer?.getBottomLineElement() && bottomLine
? createPortal(bottomLine, corePlayer?.getBottomLineElement())
: null}
</>
);
},
);