diff --git a/.nx/version-plans/version-plan-1786986699046.md b/.nx/version-plans/version-plan-1786986699046.md new file mode 100644 index 0000000000..60222233cb --- /dev/null +++ b/.nx/version-plans/version-plan-1786986699046.md @@ -0,0 +1,5 @@ +--- +core-bundle: patch +--- + +refactor(core): 拆分对齐焦点状态机 diff --git a/packages/core/src/lyric-player/base/focus.ts b/packages/core/src/lyric-player/base/focus.ts new file mode 100644 index 0000000000..9b8209c1e6 --- /dev/null +++ b/packages/core/src/lyric-player/base/focus.ts @@ -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 }; + } +} diff --git a/packages/core/src/lyric-player/base/index.ts b/packages/core/src/lyric-player/base/index.ts index edf43992b0..5919592415 100644 --- a/packages/core/src/lyric-player/base/index.ts +++ b/packages/core/src/lyric-player/base/index.ts @@ -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, @@ -53,13 +53,11 @@ function getEntrySize(entry: ResizeObserverEntry): [number, number] { /** * 播放器布局状态。 * - * 记录当前视口动态测量的焦点状态与尺寸 + * 记录当前视口动态测量得到的尺寸 */ interface PlayerLayoutState { /** 间奏点元素当前测量得到的尺寸 */ interludeDotsSize: [number, number]; - /** 当前的对齐目标 */ - alignTarget: FocalTarget; } /** @@ -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, @@ -136,6 +133,7 @@ export abstract class LyricPlayerBase }; protected layoutCalculator: LayoutCalculator = new LayoutCalculator(); + private focusController: FocusController = new FocusController(); public currentLyricGroups: LyricLineGroupBase[] = []; lyricGroupSize: WeakMap = new WeakMap(); @@ -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(); @@ -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; diff --git a/packages/core/test/focus-controller.test.ts b/packages/core/test/focus-controller.test.ts new file mode 100644 index 0000000000..2f1fafc75b --- /dev/null +++ b/packages/core/test/focus-controller.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { FocusController } from "#lyric/base/focus.ts"; +import type { + PlayerInterlude, + TimelineSnapshot, +} from "#lyric/base/timeline.ts"; +import { MediaTime } from "#utils/time.ts"; + +function makeSnapshot(over: Partial = {}): TimelineSnapshot { + return { + currentTime: MediaTime.ZERO, + isSeeking: false, + playingGroups: new Set(), + highlightedGroups: new Set(), + scrollToIndex: 0, + isTimelineEmpty: false, + isEndOfSong: false, + isFocusOnInterlude: false, + ...over, + }; +} + +function makeInterlude(anchorLineIndex: number): PlayerInterlude { + return { + startTime: MediaTime.ZERO, + endTime: MediaTime.ZERO, + anchorLineIndex, + }; +} + +/** 自动跟随,未被用户滚动挂起 */ +const FOLLOWING = { isAutoAlignSuspended: false, hasBottomContent: false }; +/** 自动跟随,且底栏有内容 */ +const FOLLOWING_WITH_BOTTOM = { + isAutoAlignSuspended: false, + hasBottomContent: true, +}; +/** 用户滚动挂起中 */ +const SUSPENDED = { isAutoAlignSuspended: true, hasBottomContent: false }; + +describe("FocusController", () => { + describe("auto-align", () => { + it("aligns with scrollToIndex", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ scrollToIndex: 3 }); + + expect(focus.resolve(snapshot, 10, FOLLOWING)).toEqual({ + type: "line", + index: 3, + }); + }); + + it("clamps out-of-bounds index within lyric line bounds", () => { + const focus = new FocusController(); + + const overflow = focus.resolve( + makeSnapshot({ scrollToIndex: 99 }), + 10, + FOLLOWING, + ); + expect(overflow).toEqual({ type: "line", index: 9 }); + + const underflow = focus.resolve( + makeSnapshot({ scrollToIndex: -5 }), + 10, + FOLLOWING, + ); + expect(underflow).toEqual({ type: "line", index: 0 }); + }); + + it("falls back to the first line when there are no lyric lines", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ scrollToIndex: 3 }); + + expect(focus.resolve(snapshot, 0, FOLLOWING)).toEqual({ + type: "line", + index: 0, + }); + }); + + it("aligns to interlude when inside active interlude range", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ + scrollToIndex: 5, + activeInterlude: makeInterlude(4), + isFocusOnInterlude: true, + }); + + expect(focus.resolve(snapshot, 10, FOLLOWING)).toEqual({ + type: "interlude", + anchorIndex: 4, + }); + }); + + it("aligns to line when hitting interlude but focus is not on interlude", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ + scrollToIndex: 5, + activeInterlude: makeInterlude(4), + isFocusOnInterlude: false, + }); + + expect(focus.resolve(snapshot, 10, FOLLOWING)).toEqual({ + type: "line", + index: 5, + }); + }); + + it("aligns to bottom line at the end of song if present", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ isEndOfSong: true }); + + expect(focus.resolve(snapshot, 10, FOLLOWING_WITH_BOTTOM)).toEqual({ + type: "bottom", + }); + }); + + it("aligns to the last line at the end of song when there is no bottom line", () => { + const focus = new FocusController(); + const snapshot = makeSnapshot({ isEndOfSong: true }); + + expect(focus.resolve(snapshot, 10, FOLLOWING)).toEqual({ + type: "line", + index: 9, + }); + }); + }); + + describe("user scroll suspended", () => { + it("freezes focus and stops following scrollToIndex", () => { + const focus = new FocusController(); + + focus.resolve(makeSnapshot({ scrollToIndex: 3 }), 10, FOLLOWING); + + const next = focus.resolve( + makeSnapshot({ scrollToIndex: 4 }), + 10, + SUSPENDED, + ); + expect(next).toEqual({ type: "line", index: 3 }); + + const later = focus.resolve( + makeSnapshot({ scrollToIndex: 7 }), + 10, + SUSPENDED, + ); + expect(later).toEqual({ type: "line", index: 3 }); + }); + + it("maintains bottom focus when frozen at bottom", () => { + const focus = new FocusController(); + + focus.resolve( + makeSnapshot({ isEndOfSong: true }), + 10, + FOLLOWING_WITH_BOTTOM, + ); + + const frozen = focus.resolve( + makeSnapshot({ scrollToIndex: 2 }), + 10, + SUSPENDED, + ); + expect(frozen).toEqual({ type: "bottom" }); + }); + + it("clamps frozen index within bounds when line count decreases", () => { + const focus = new FocusController(); + + focus.resolve(makeSnapshot({ scrollToIndex: 9 }), 10, FOLLOWING); + + const clamped = focus.resolve( + makeSnapshot({ scrollToIndex: 9 }), + 3, + SUSPENDED, + ); + expect(clamped).toEqual({ type: "line", index: 2 }); + }); + + it("maintains interlude focus when frozen on interlude and it is still active", () => { + const focus = new FocusController(); + const activeInterlude = makeInterlude(4); + + focus.resolve( + makeSnapshot({ activeInterlude, isFocusOnInterlude: true }), + 10, + FOLLOWING, + ); + + const frozen = focus.resolve( + makeSnapshot({ + scrollToIndex: 5, + activeInterlude, + isFocusOnInterlude: true, + }), + 10, + SUSPENDED, + ); + expect(frozen).toEqual({ type: "interlude", anchorIndex: 4 }); + }); + + it("advances by one line when frozen interlude ends and remains stable", () => { + const focus = new FocusController(); + + focus.resolve( + makeSnapshot({ + activeInterlude: makeInterlude(4), + isFocusOnInterlude: true, + }), + 10, + FOLLOWING, + ); + + const promoted = focus.resolve( + makeSnapshot({ scrollToIndex: 5 }), + 10, + SUSPENDED, + ); + expect(promoted).toEqual({ type: "line", index: 5 }); + + const stable = focus.resolve( + makeSnapshot({ scrollToIndex: 8 }), + 10, + SUSPENDED, + ); + expect(stable).toEqual({ type: "line", index: 5 }); + }); + + it("clamps advanced index within lyric line bounds", () => { + const focus = new FocusController(); + + focus.resolve( + makeSnapshot({ + activeInterlude: makeInterlude(8), + isFocusOnInterlude: true, + }), + 10, + FOLLOWING, + ); + + const promoted = focus.resolve( + makeSnapshot({ scrollToIndex: 8 }), + 9, + SUSPENDED, + ); + expect(promoted).toEqual({ type: "line", index: 8 }); + }); + }); + + describe("reset", () => { + it("resets frozen focus back to the first line", () => { + const focus = new FocusController(); + + focus.resolve(makeSnapshot({ scrollToIndex: 6 }), 10, FOLLOWING); + focus.reset(); + + const afterReset = focus.resolve( + makeSnapshot({ scrollToIndex: 6 }), + 10, + SUSPENDED, + ); + expect(afterReset).toEqual({ type: "line", index: 0 }); + }); + }); +});