Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1786986699046.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
core-bundle: patch
---

refactor(core): 拆分对齐焦点状态机
138 changes: 138 additions & 0 deletions packages/core/src/lyric-player/base/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { FocalTarget } from "./layout.ts";
import type { TimelineSnapshot } from "./timeline.ts";

/**
* 焦点推导所依赖的播放器交互状态
*/
export interface FocusResolveFlags {
/**
* 是否处于用户滚动过,尚未回归自动对齐的状态
*/
isAutoAlignSuspended: boolean;

/**
* 底栏当前是否有内容
*/
hasBottomContent: boolean;
}

/**
* 对齐焦点状态机
*
* 持有跨帧的冻结对齐目标,并在每一帧根据时间线快照与交互状态推导出本帧排版应当对齐的焦点
*
* @remarks
* 规则为:
* - 用户滚动挂起期间冻结上一帧的焦点,不再跟随播放进度
* - 若冻结在间奏点上而间奏已经结束,则自动前移至间奏后的第一行歌词
* - 未挂起时跟随播放状态:间奏中对齐间奏点,曲末对齐底栏或末行,其余对齐 `scrollToIndex`
* - 任何情况下产出的歌词行索引都被钳制在 `[0, lineCount - 1]` 内
*/
export class FocusController {
private target: FocalTarget = { type: "line", index: 0 };

/**
* 推导本帧的对齐焦点,并将其记录为新的冻结目标
* @param snapshot 当前帧的时间线快照
* @param lineCount 当前歌词行总数
* @param flags 播放器的交互状态
*/
public resolve(
snapshot: TimelineSnapshot,
lineCount: number,
flags: FocusResolveFlags,
): FocalTarget {
const nextTarget = flags.isAutoAlignSuspended
? this.resolveSuspendedTarget(snapshot, lineCount)
: this.resolveActiveTarget(snapshot, lineCount, flags.hasBottomContent);

this.target = nextTarget;
return nextTarget;
}

/**
* 用户滚动挂起期间的焦点解析(维持上一帧目标或在间奏结束时顺延)
*/
private resolveSuspendedTarget(
snapshot: TimelineSnapshot,
lineCount: number,
): FocalTarget {
const target = this.target;

switch (target.type) {
case "line":
return {
type: "line",
index: this.clampLineIndex(target.index, lineCount),
};

case "interlude": {
const isInterludeActive =
snapshot.isFocusOnInterlude && !!snapshot.activeInterlude;

// 离开间奏区间时,将冻结目标移动至间奏后的下一行歌词
if (!isInterludeActive) {
return {
type: "line",
index: this.clampLineIndex(target.anchorIndex + 1, lineCount),
};
}
return target;
}

case "bottom":
return target;
}
}

/**
* 正常播放状态下的自动焦点解析
*/
private resolveActiveTarget(
snapshot: TimelineSnapshot,
lineCount: number,
hasBottomContent: boolean,
): FocalTarget {
// 处于间奏区间且需聚焦间奏点时对焦到间奏点
if (snapshot.isFocusOnInterlude && snapshot.activeInterlude) {
return {
type: "interlude",
anchorIndex: snapshot.activeInterlude.anchorLineIndex,
};
}

// 播放完了,如果有底栏则对齐底栏,没有则对齐最后一行歌词
if (snapshot.isEndOfSong) {
if (hasBottomContent) {
return { type: "bottom" };
}
return {
type: "line",
index: this.clampLineIndex(lineCount - 1, lineCount),
};
}

// 常规播放跟随
return {
type: "line",
index: this.clampLineIndex(snapshot.scrollToIndex, lineCount),
};
}

/**
* 将歌词行索引钳制到当前歌词范围内
*/
private clampLineIndex(index: number, lineCount: number): number {
if (lineCount <= 0) return 0;
return Math.min(Math.max(0, index), lineCount - 1);
}

/**
* 重置焦点至首行
*
* 一般在载入新歌词、重建歌词视图时调用
*/
public reset(): void {
this.target = { type: "line", index: 0 };
}
}
67 changes: 12 additions & 55 deletions packages/core/src/lyric-player/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {
LayoutReasonStrategyMap,
type MaskObsceneWordsMode,
} from "./consts.ts";
import { FocusController } from "./focus.ts";
import type { LyricLineGroupBase } from "./group.ts";
import type { InterludeDots } from "./interlude-dots.ts";
import {
type FocalTarget,
LayoutCalculator,
type LayoutConfig,
type LayoutFrameContext,
Expand Down Expand Up @@ -53,13 +53,11 @@ function getEntrySize(entry: ResizeObserverEntry): [number, number] {
/**
* 播放器布局状态。
*
* 记录当前视口动态测量的焦点状态与尺寸
* 记录当前视口动态测量得到的尺寸
*/
interface PlayerLayoutState {
/** 间奏点元素当前测量得到的尺寸 */
interludeDotsSize: [number, number];
/** 当前的对齐目标 */
alignTarget: FocalTarget;
}

/**
Expand Down Expand Up @@ -108,7 +106,6 @@ export abstract class LyricPlayerBase

protected layoutState: PlayerLayoutState = {
interludeDotsSize: [0, 0],
alignTarget: { type: "line", index: 0 },
};
protected layoutConfig: LayoutConfig = {
alignAnchor: LayoutAlignAnchor.Center,
Expand Down Expand Up @@ -136,6 +133,7 @@ export abstract class LyricPlayerBase
};

protected layoutCalculator: LayoutCalculator = new LayoutCalculator();
private focusController: FocusController = new FocusController();

public currentLyricGroups: LyricLineGroupBase[] = [];
lyricGroupSize: WeakMap<LyricLineGroupBase, [number, number]> = new WeakMap();
Expand Down Expand Up @@ -632,7 +630,7 @@ export abstract class LyricPlayerBase
*/
public rebuildLyricView(initialTime: number = this.getCurrentTime()): void {
this.resetScroll();
this.layoutState.alignTarget = { type: "line", index: 0 };
this.focusController.reset();

for (const group of this.currentLyricGroups) {
group.dispose();
Expand Down Expand Up @@ -714,57 +712,16 @@ export abstract class LyricPlayerBase

const snapshot = this.timelineController.getSnapshot();
const interlude = snapshot.activeInterlude;
const isInterludeFocused = snapshot.isFocusOnInterlude && !!interlude;
const count = this.currentLyricGroups.length;
const maxValidIndex = Math.max(0, count - 1);
const clampLineIndex = (index: number) =>
count > 0 ? Math.min(Math.max(0, index), maxValidIndex) : 0;

const safeScrollToIndex = clampLineIndex(snapshot.scrollToIndex);

// 确定这一帧焦点应该对齐谁
let focalTarget: FocalTarget = {
type: "line",
index: safeScrollToIndex,
};

// 如果用户正在滚动,对齐冻结的对齐目标
if (this.scrollState.isAutoAlignSuspended) {
const target = this.layoutState.alignTarget;
if (target.type === "line") {
focalTarget = { type: "line", index: clampLineIndex(target.index) };
} else if (target.type === "interlude") {
if (!isInterludeFocused) {
// 离开间奏区间时,自动将对齐目标移动至下一行歌词,并更新冻结目标
focalTarget = {
type: "line",
index: clampLineIndex(target.anchorIndex + 1),
};
} else {
focalTarget = target;
}
} else {
focalTarget = target;
}
} else {
// 正常自动跟随播放状态
if (isInterludeFocused && interlude) {
// 处于间奏区间,对齐间奏点
focalTarget = {
type: "interlude",
anchorIndex: interlude.anchorLineIndex,
};
} else if (snapshot.isEndOfSong) {
// 播放完了,如果有底栏则对齐底栏,没有则对齐最后一行歌词
if (this.hasBottomContent) {
focalTarget = { type: "bottom" };
} else if (count > 0) {
focalTarget = { type: "line", index: count - 1 };
}
}
}

this.layoutState.alignTarget = focalTarget;
const focalTarget = this.focusController.resolve(
snapshot,
this.currentLyricGroups.length,
{
isAutoAlignSuspended: this.scrollState.isAutoAlignSuspended,
hasBottomContent: this.hasBottomContent,
},
);

// 组装给布局计算器和滚动引擎用的数据
const fontSize = this.baseFontSize || 24;
Expand Down
Loading