diff --git a/.nx/version-plans/version-plan-1787549320366.md b/.nx/version-plans/version-plan-1787549320366.md new file mode 100644 index 0000000000..e3ec6c704e --- /dev/null +++ b/.nx/version-plans/version-plan-1787549320366.md @@ -0,0 +1,7 @@ +--- +core-bundle: minor +--- + +refactor(core)!: 移除持续性的跳转状态 + +`LyricPlayerBase.setIsSeeking` 现已置为空方法,并引入一个默认开启的自动跳转状态推导作为替代 diff --git a/packages/core/src/lyric-player/base/index.ts b/packages/core/src/lyric-player/base/index.ts index b07ca433d7..b570451534 100644 --- a/packages/core/src/lyric-player/base/index.ts +++ b/packages/core/src/lyric-player/base/index.ts @@ -30,6 +30,7 @@ import { LyricDataManager, } from "./lyric-data-manager.ts"; import { type ScrollInputType, ScrollInteractionEngine } from "./scroll.ts"; +import { SeekDetector } from "./seek-detector.ts"; import { getPosYSpringPolicy } from "./spring"; import { TimelineController, type TimelineSnapshot } from "./timeline.ts"; @@ -84,6 +85,8 @@ export abstract class LyricPlayerBase protected isPlaying = false; protected timelineController: TimelineController = new TimelineController(); + protected seekDetector: SeekDetector = new SeekDetector(); + protected enableAutoSeekDetection = true; private hasBottomContent = false; private bottomLineObserver: MutationObserver; @@ -365,12 +368,62 @@ export abstract class LyricPlayerBase return this.wordFadeWidth; } - setIsSeeking(isSeeking: boolean): void { - this.timelineController.setSeekingState(isSeeking); - this.updateSpringParams( - !!this.timelineController.getSnapshot().activeInterlude, - ); + /** + * 设置持续性的跳转状态 + * + * @deprecated 此方法已无实际作用,调用它不会产生任何效果,仅为兼容保留,将在未来移除 + * + * 跳转状态现在由每次进度推送逐帧推导,不再存在需要外部显式解除的持续状态 + * + * 跳转会通过以下三条途径被识别,三者同时生效: + * + * - {@link setCurrentTime} 的 `isSeek` 参数,由调用方明确告知某次进度变化是跳转 + * - 进度倒退或停滞,由内部无条件识别,不受任何开关控制 + * - 自动推导,由内部比对进度的实际推进量与它应有的推进量识别超量前进,默认启用, + * 可通过 {@link setEnableAutoSeekDetection} 关闭 + */ + setIsSeeking(_isSeeking: boolean): void {} + + /** + * 设置是否自动推导跳转状态,默认启用 + * + * 启用后,即使调用 {@link setCurrentTime} 时没有传入 `isSeek`, + * 内部也会在进度前进时比较它的实际推进量与应有的推进量,超量前进即视为跳转 + * + * 应有的推进量取决于当前的播放状态,因此请按 {@link pause} 与 {@link resume} + * 的文档正确同步播放状态: + * - 播放时以物理时钟的推进量为准,容差随之按比例放宽,以容纳倍速播放与不均匀的推送节奏 + * - 暂停时进度本不该前进,应有的推进量是零,因此任何超出抖动幅度的前进都会被视为跳转 + * + * 这意味着进度信息的粒度粗于推送间隔时,绝大多数推送都会被视为跳转, + * 此时应当改善进度来源的精度,或在进度未发生变化时跳过推送 + * + * 较小的向前跳转可能无法被识别,但一般影响不大 + * + * 此开关只控制上述超量前进的判定。进度倒退与停滞不受它控制,无论是否启用推导都会 + * 被视为跳转:正常播放不会让进度停滞不前,因此重复推送同一个时间表达的是把逐字遮罩 + * 这类自行推进的动画拉回到该时间的意图 + * + * 推导只会额外识别出跳转,不会否决已显式传入的跳转标志,因此如果你已经在正确传入 + * 跳转标志了,则一般无需关心此开关。若你的进度来源精度很差而导致超量前进被频繁误判, + * 可以选择关闭 + * + * @param enable 是否启用自动推导 + */ + setEnableAutoSeekDetection(enable = true): void { + if (this.enableAutoSeekDetection === enable) return; + this.enableAutoSeekDetection = enable; + this.seekDetector.reset(); } + + /** + * 获取当前是否启用了跳转状态的自动推导 + * @returns 是否启用自动推导 + */ + getEnableAutoSeekDetection(): boolean { + return this.enableAutoSeekDetection; + } + /** * 设置是否隐藏已经播放过的歌词行,默认不隐藏 * @param hide 是否隐藏已经播放过的歌词行,默认不隐藏 @@ -564,48 +617,82 @@ export abstract class LyricPlayerBase /** * 设置当前播放进度,此时将会更新内部的歌词进度信息。 * - * 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好。 - * 调用完成后,应每帧调用 {@link update} 方法来执行歌词动画效果。**此函数本身不会触发动画效果**。 + * 内部会根据调用间隔和播放进度自动决定应如何滚动和显示歌词,所以此方法的调用频率越快越准确越好。 + * 调用频率较低或进度细度过粗可能会导致歌词显示延迟或导致自动跳转推导错误。 + * 调用完成后,应每帧调用 {@link update} 方法来执行歌词动画效果。此函数本身不会触发动画效果。 * - * 当 `isSeek` 为 `true` 时,将触发重新排版,代价较高,因此请只在真正跳转时设为 `true` + * 当 `isSeek` 为 `true` 时,将强制按跳转处理,并在下次调用 {@link update} 时触发一系列的行为变更, + * 具体请参考 ,因此请只在真正跳转时设为 `true` * - * @param time 当前播放进度,单位为毫秒 - * @param isSeek 这个进度变化是否为跳转触发的 + * @param time 当前播放进度,单位为毫秒,非有限值会被静默忽略 + * @param isSeek 是否强制按跳转处理,默认交由内部推导 + * @see {@link setEnableAutoSeekDetection} 自动推导跳转状态的文档 + * @see https://amll.dev/guides/component/sequence#播放进度 */ setCurrentTime(time: number, isSeek = false): void { + if (!Number.isFinite(time)) return; + const mediaTime = MediaTime.round(MediaTime.fromMillis(time)); + if ( + !isSeek && + !this.isPlaying && + mediaTime === this.timelineController.getSnapshot().currentTime + ) { + return; + } + + // 探测器必须消费每一次进度推送才能维持内部状态正确,即使本次已经被调用方标记为跳转 + const isDetectedSeek = this.enableAutoSeekDetection + ? this.seekDetector.detect(mediaTime, this.isPlaying) + : false; + + this.syncTime(mediaTime, isSeek || isDetectedSeek); + } + + /** + * 推进时间线并把增量变化应用到视图上 + * @param mediaTime 当前播放进度 + * @param isSeek 这次进度变化是否为跳转 + */ + private syncTime(mediaTime: MediaTime, isSeek: boolean): void { const diff = this.timelineController.sync(mediaTime, isSeek); if (!diff.hasChanged) { return; } + // 时间轴是否跳跃由时间线统一推导,除了传入的 isSeek 外还包含进度倒退和停滞的情况 + const isTimeJumped = diff.isTimeJumped; + const snapshot = this.timelineController.getSnapshot(); + for (let i = 0; i < diff.removedHighlighted.length; i++) { this.currentLyricGroups[diff.removedHighlighted[i]]?.disable(); } - for (let i = 0; i < diff.addedHighlighted.length; i++) { - this.currentLyricGroups[diff.addedHighlighted[i]]?.enable(); + if (isTimeJumped) { + for (const index of snapshot.highlightedGroups) { + this.currentLyricGroups[index]?.enable(); + } + } else { + for (let i = 0; i < diff.addedHighlighted.length; i++) { + this.currentLyricGroups[diff.addedHighlighted[i]]?.enable(); + } } - if (diff.isTimeJumped) { + if (isTimeJumped) { if (!this.scrollState.isTouchScrolled) { this.resetScroll(); } } - if ( - diff.isInterludeChanged || - diff.isScrollToChanged || - diff.isTimeJumped - ) { - const isInterludeActive = - !!this.timelineController.getSnapshot().activeInterlude; - this.updateSpringParams(isInterludeActive); + if (diff.isInterludeChanged || diff.isScrollToChanged || isTimeJumped) { + this.updateSpringParams(!!snapshot.activeInterlude, isTimeJumped); } - this.calcLayout(isSeek ? LayoutReason.Seek : LayoutReason.PlaybackTick); + this.calcLayout( + isTimeJumped ? LayoutReason.Seek : LayoutReason.PlaybackTick, + ); } /** @@ -673,6 +760,7 @@ export abstract class LyricPlayerBase this.defaultLineHeight, ); + this.seekDetector.reset(); this.setCurrentTime(initialTime, true); this.calcLayout(LayoutReason.RebuildView); @@ -686,14 +774,19 @@ export abstract class LyricPlayerBase * 其策略为: * - seeking 或间奏时使用更稳定的固定参数 * - 普通播放时根据相邻歌词的时间间隔动态调整 stiffness / damping + * + * @param isInterludeActive 当前是否命中间奏区间 + * @param isSeeking 本次同步的时间轴是否发生了跳转 */ - private updateSpringParams(isInterludeActive: boolean): void { + private updateSpringParams( + isInterludeActive: boolean, + isSeeking: boolean, + ): void { if (!this.getEnableSpring() || this.currentLyricGroups.length === 0) { return; } - const snapshot = this.timelineController.getSnapshot(); - const { scrollToIndex, isSeeking } = snapshot; + const { scrollToIndex } = this.timelineController.getSnapshot(); const currentGroup = this.currentLyricGroups[scrollToIndex]; const prevGroup = this.currentLyricGroups[scrollToIndex - 1]; @@ -785,12 +878,10 @@ export abstract class LyricPlayerBase this.interludeDots.setTransform(targetX, result.interludeY + dotMargin); - const shouldResetAnimation = - snapshot.isSeeking || strategy.resetInterlude; this.interludeDots.setInterlude( [interlude.startTime, interlude.endTime], snapshot.currentTime, - shouldResetAnimation, + strategy.resetInterlude, ); } else { this.interludeDots.setInterlude(undefined); @@ -832,7 +923,7 @@ export abstract class LyricPlayerBase // 应用阶梯式的动画延迟 const lineH = instruction.height; - if (curPos + lineH >= 0 && !snapshot.isSeeking) { + if (curPos + lineH >= 0) { delay = Duration.add(delay, baseDelay); if (i >= snapshot.scrollToIndex) { baseDelay = Duration.mulF64(baseDelay, 1 / 1.05); diff --git a/packages/core/src/lyric-player/base/seek-detector.ts b/packages/core/src/lyric-player/base/seek-detector.ts new file mode 100644 index 0000000000..7d1dd67547 --- /dev/null +++ b/packages/core/src/lyric-player/base/seek-detector.ts @@ -0,0 +1,109 @@ +import { Duration, MediaTime } from "#utils/time.ts"; + +/** + * 读取单调递增的物理时钟的函数,单位为毫秒 + * + * 默认使用 `performance.now` + */ +export type WallClock = () => number; + +/** + * 媒体时钟偏离期望推进量时的固定底限容差 + */ +const JITTER_TOLERANCE = Duration.fromMillis(150); + +/** + * 媒体时钟相对于物理时钟允许的动态漂移比例 + */ +const DRIFT_SLACK = 0.5; + +/** + * 单次判定所信任的最大物理时钟跨度 + */ +const MAX_TRUSTED_GAP = Duration.fromMillis(800); + +/** + * 跳转状态自动推导器 + * + * 让下游使用者只推送播放进度、无需自己判断某次进度变化是否为跳转 + * + * 判定的思路是把本次媒体时钟的推进量与当前播放状态下它应有的推进量比较, + * 超出容差的偏离即视为跳转。判定规则为: + * - 进度倒退或保持不变视为跳转 + * - 播放时应有的推进量是物理时钟的推进量,容差随之按比例放宽 + * - 暂停时应有的推进量是零,容差只留固定底限 + */ +export class SeekDetector { + private readonly now: WallClock; + + private lastMediaTime: MediaTime = MediaTime.ZERO; + private lastWallTime = 0; + private hasBaseline = false; + + /** + * @param now 物理时钟读取函数,默认为 `performance.now` + */ + public constructor(now: WallClock = () => performance.now()) { + this.now = now; + } + + /** + * 推导本次进度变化是否为跳转 + * + * @param time 下游推送的当前播放进度 + * @param isPlaying 当前是否在播放,决定本次推送应有的推进量与容差 + * @returns 是否应当按跳转处理 + */ + public detect(time: MediaTime, isPlaying: boolean): boolean { + const wall = this.now(); + + // 没有基线可比,无从推导,此时不视为跳转 + // + // 需要强制对齐的场合(重新构建歌词、页面恢复等)由调用方显式传入跳转状态, + // 无需在这里代为判断;在这里报告跳转会让恢复播放这类本质连续的场景被当作跳转 + if (!this.hasBaseline) { + this.rebase(time, wall); + this.hasBaseline = true; + return false; + } + + // 进度不再前进,正常播放不会产生这种位置,因此按跳转处理 + if (time <= this.lastMediaTime) { + this.rebase(time, wall); + return true; + } + + const mediaDelta = MediaTime.since(time, this.lastMediaTime); + const elapsed = Duration.clampPositive( + Duration.fromMillis(wall - this.lastWallTime), + ); + const wallDelta = Duration.min(elapsed, MAX_TRUSTED_GAP); + + this.rebase(time, wall); + + // 暂停时进度本不该前进,因此应有的推进量是零,容差也只留固定底限, + // 任何超过抖动幅度的前进都是跳转 + // + // 播放时才以物理时钟应有的推进量为基准,并按其比例放宽容差, + // 以容纳倍速播放与推送节奏的不均匀 + const expected = isPlaying ? wallDelta : Duration.ZERO; + const tolerance = isPlaying + ? Duration.max(JITTER_TOLERANCE, Duration.mulF64(wallDelta, DRIFT_SLACK)) + : JITTER_TOLERANCE; + + const drift = Duration.sub(mediaDelta, expected); + + return drift > tolerance; + } + + public reset(): void { + this.hasBaseline = false; + this.lastMediaTime = MediaTime.ZERO; + this.lastWallTime = 0; + } + + private rebase(time: MediaTime, wall: number): void { + this.lastMediaTime = time; + this.lastWallTime = wall; + } +} diff --git a/packages/core/src/lyric-player/base/timeline.ts b/packages/core/src/lyric-player/base/timeline.ts index 1ffd16dedf..ebf4bb4d03 100644 --- a/packages/core/src/lyric-player/base/timeline.ts +++ b/packages/core/src/lyric-player/base/timeline.ts @@ -54,13 +54,6 @@ export interface TimelineSnapshot { */ readonly currentTime: MediaTime; - /** - * 标识当前帧是否处于跳转状态 - * - * 例如跳转期间使用更缓慢的弹簧参数 (参见 `spring.ts`),并关闭各个歌词行的延时递增动画 - */ - readonly isSeeking: boolean; - /** * 当前进度命中的、正在播放的歌词组 */ @@ -123,12 +116,22 @@ export interface TimelineSnapshot { */ export interface TimelineDiff { /** - * 当前帧是否有任何实质性的状态变更,如播放行更替、高亮行新增/移除、间奏状态切换、焦点切换,或处于 Seek 状态中 + * 当前帧是否有任何实质性的状态变更,如播放行更替、高亮行新增/移除、间奏状态切换、焦点切换,或发生了跳转 * * UI 层接收到 diff 后,检查此标志即可直接跳过后续所有的布局计算 */ readonly hasChanged: boolean; + /** + * 标识本次同步的时间轴是否发生了跳转 + * + * 在显式跳转 (`sync` 的 `forceSeek`) 或时间倒退 / 停滞(重复推送同一时间)时为 `true` + * + * 例如跳转时使用更缓慢的弹簧参数 (参见 `spring.ts`), + * 以及在非触摸状态下重置滚动坐标系 + */ + readonly isTimeJumped: boolean; + /** * 在当前时间进度下,最新被命中的、正在播放的歌词索引列表 * @@ -175,13 +178,6 @@ export interface TimelineDiff { * 用于通知 UI 需要移动到新的歌词行 */ readonly isScrollToChanged: boolean; - - /** - * 标识时间轴是否发生了非连续的跳跃,会在时间轴倒退和 Seek 状态下为 true - * - * 用于通知 UI 层在非触摸状态下重置滚动坐标系 - */ - readonly isTimeJumped: boolean; } /** @@ -199,10 +195,6 @@ export class TimelineController { * 全部歌词行中最晚的结束时间,用于判定歌曲是否播放完毕 */ private maxEndTime: MediaTime = MediaTime.ZERO; - /** - * 外部显式传入的持续性 Seek 状态(例如正在拖拽进度条) - */ - private isManualSeeking = false; /** * 预先计算的间奏区域 @@ -229,7 +221,6 @@ export class TimelineController { private readonly snapshot: Mutable = { currentTime: MediaTime.ZERO, - isSeeking: false, playingGroups: this.playingGroupsSet, highlightedGroups: this.highlightedGroupsSet, scrollToIndex: 0, @@ -241,13 +232,13 @@ export class TimelineController { private readonly diff: Mutable = { hasChanged: false, + isTimeJumped: false, addedPlaying: this.addedPlayingIds, removedPlaying: this.removedPlayingIds, addedHighlighted: this.addedHighlightedIds, removedHighlighted: this.removedHighlightedIds, isInterludeChanged: false, isScrollToChanged: false, - isTimeJumped: false, }; //#endregion @@ -315,13 +306,15 @@ export class TimelineController { const prevScrollToIndex = this.snapshot.scrollToIndex; const prevEndOfSong = this.snapshot.isEndOfSong; - // 将时间倒退视为 seek 是为了避免 performPlayback 的顺序扫描失效 - // performPlayback 会保存上次扫描停止的位置,下次从该位置继续扫描以提高性能 - // 若时间倒退,倒退到的行可能位于扫描位置之前,需要按跳转路径重新推导 - const isTimeRegression = time < this.snapshot.currentTime; - const isJump = forceSeek || isTimeRegression; - - this.snapshot.isSeeking = this.isManualSeeking || isJump; + // 时间不再前进时一律按跳转处理,这里有两个各自独立的理由 + // + // 倒退是机制上的必需:performPlayback 会保存上次扫描停止的位置,下次从该位置 + // 继续扫描以提高性能,若时间倒退,倒退到的行可能位于扫描位置之前,只能重新推导 + // + // 停滞则是语义上的约定:正常播放不会让进度停在原地,推送同一个时间表达的是把 + // 逐字遮罩这类自行推进的动画重新对齐到该时间的意图,因此也走跳转路径 + const isTimeNotAdvancing = time <= this.snapshot.currentTime; + const isJump = forceSeek || isTimeNotAdvancing; // 间奏命中情况需要先于歌词状态确定 // Seek 时要按同样的规则决定是否保留已经唱完的行,需要提前知道结果 @@ -331,7 +324,7 @@ export class TimelineController { const isPastLastLine = this.lyricBounds.length > 0 && time >= this.maxEndTime; - if (this.snapshot.isSeeking) { + if (isJump) { this.performSeek(time, !!activeInterlude || isPastLastLine); } else { this.performPlayback(time); @@ -355,7 +348,7 @@ export class TimelineController { const isScrollToChanged = prevScrollToIndex !== this.snapshot.scrollToIndex; const hasChanged = - this.snapshot.isSeeking || + isJump || this.addedPlayingIds.length > 0 || this.removedPlayingIds.length > 0 || this.addedHighlightedIds.length > 0 || @@ -380,26 +373,12 @@ export class TimelineController { this.snapshot.isEndOfSong = isPastLastLine; this.diff.hasChanged = hasChanged; + this.diff.isTimeJumped = isJump; this.diff.isInterludeChanged = isInterludeChanged; this.diff.isScrollToChanged = isScrollToChanged; - this.diff.isTimeJumped = isJump; return this.diff; } - - /** - * 设置持续性的跳转状态,例如用户正按住进度条拖拽 - * - * @remarks - * 此状态由外部持有,内部只做镜像,因此加载新歌词时不会被清除, - * 需要由调用方在拖拽结束时显式置回 false - * - * @param isSeeking 当前是否处于持续跳转状态 - */ - public setSeekingState(isSeeking: boolean): void { - this.isManualSeeking = isSeeking; - this.snapshot.isSeeking = isSeeking; - } //#endregion //#region 时间线推导 @@ -740,7 +719,6 @@ export class TimelineController { this.nextHighlightedSet.clear(); this.snapshot.currentTime = MediaTime.ZERO; - this.snapshot.isSeeking = false; this.snapshot.scrollToIndex = 0; this.snapshot.latestHighlightedIndex = undefined; this.snapshot.isEndOfSong = false; diff --git a/packages/core/test/focus-controller.test.ts b/packages/core/test/focus-controller.test.ts index 1dc8678cdf..9fe432452e 100644 --- a/packages/core/test/focus-controller.test.ts +++ b/packages/core/test/focus-controller.test.ts @@ -9,7 +9,6 @@ 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, diff --git a/packages/core/test/seek-detector.test.ts b/packages/core/test/seek-detector.test.ts new file mode 100644 index 0000000000..fc3b2970f4 --- /dev/null +++ b/packages/core/test/seek-detector.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { SeekDetector } from "#lyric/base/seek-detector.ts"; +import { MediaTime } from "#utils/time.ts"; + +function makeDetector() { + let wall = 0; + const detector = new SeekDetector(() => wall); + + return { + detector, + push(mediaMs: number, wallDeltaMs: number, isPlaying = true): boolean { + wall += wallDeltaMs; + return detector.detect(MediaTime.fromMillis(mediaMs), isPlaying); + }, + }; +} + +describe("SeekDetector baseline", () => { + it("does not report a seek before it has an anchor to compare against", () => { + const { push } = makeDetector(); + + expect(push(0, 0)).toBe(false); + }); + + it("does not report a seek on the first push after a reset", () => { + const { detector, push } = makeDetector(); + + push(0, 0); + expect(push(16, 16)).toBe(false); + + detector.reset(); + expect(push(32, 16)).toBe(false); + }); + + it("re-anchors on reset instead of comparing against the stale baseline", () => { + const { detector, push } = makeDetector(); + + push(0, 0); + push(16, 16); + + detector.reset(); + push(60000, 16); + + expect(push(60016, 16)).toBe(false); + }); +}); + +describe("SeekDetector continuous playback", () => { + it("does not flag normal playback at animation frame cadence", () => { + const { push } = makeDetector(); + + push(0, 0); + for (let i = 1; i <= 120; i++) { + expect(push(i * 16, 16)).toBe(false); + } + }); + + it("does not flag playback pushed at a coarse cadence", () => { + const { push } = makeDetector(); + + push(0, 0); + for (let i = 1; i <= 20; i++) { + expect(push(i * 250, 250)).toBe(false); + } + }); + + it("does not flag faster than realtime playback at frame cadence", () => { + for (const rate of [2, 4, 8]) { + const { push } = makeDetector(); + push(0, 0); + for (let i = 1; i <= 120; i++) { + expect(push(i * 16 * rate, 16)).toBe(false); + } + } + }); + + it("does not flag slower than realtime playback", () => { + const { push } = makeDetector(); + + push(0, 0); + for (let i = 1; i <= 120; i++) { + expect(push(i * 8, 16)).toBe(false); + } + }); +}); + +describe("SeekDetector stalled progress", () => { + it("treats an unchanged progress push as a seek", () => { + const { push } = makeDetector(); + + push(1000, 0); + push(1016, 16); + + expect(push(1016, 16)).toBe(true); + }); + + it("keeps reporting seeks while the host holds one progress value", () => { + const { push } = makeDetector(); + + push(1000, 0); + for (let i = 0; i < 10; i++) { + expect(push(1000, 16)).toBe(true); + } + }); + + it("reports a coarsely quantized progress source as seeking", () => { + const { push } = makeDetector(); + + push(0, 0); + let flagged = 0; + for (let frame = 1; frame <= 240; frame++) { + if (push(Math.floor((frame * 16) / 250) * 250, 16)) flagged++; + } + + expect(flagged).toBeGreaterThan(200); + }); + + it("returns to normal derivation once progress advances again", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(1000, 16)).toBe(true); + expect(push(1016, 16)).toBe(false); + }); +}); + +describe("SeekDetector regression", () => { + it("always flags backward progress, however small", () => { + const { push } = makeDetector(); + + push(1000, 0); + push(1016, 16); + expect(push(1015, 16)).toBe(true); + }); + + it("flags backward progress even when the wall clock has run far ahead", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(500, 10000)).toBe(true); + }); +}); + +describe("SeekDetector while paused", () => { + it("flags a forward change beyond the jitter tolerance", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(1500, 16, false)).toBe(true); + }); + + it("does not flag a forward change within the jitter tolerance", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(1100, 16, false)).toBe(false); + }); + + it("judges by the jitter tolerance alone, regardless of how long the pause lasted", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(1500, 300000, false)).toBe(true); + }); + + it("still flags backward progress", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(999, 16, false)).toBe(true); + }); + + it("does not flag frame cadence playback pushed while marked as paused", () => { + const { push } = makeDetector(); + + push(0, 0); + for (let i = 1; i <= 120; i++) { + expect(push(i * 16, 16, false)).toBe(false); + } + }); +}); + +describe("SeekDetector forward jumps", () => { + it("flags a forward jump that outruns the wall clock", () => { + const { push } = makeDetector(); + + push(0, 0); + push(16, 16); + expect(push(30016, 16)).toBe(true); + }); + + it("does not flag a forward jump smaller than the jitter tolerance", () => { + const { push } = makeDetector(); + + push(0, 0); + push(16, 16); + expect(push(116, 16)).toBe(false); + }); + + it("returns to normal derivation on the frame after a jump", () => { + const { push } = makeDetector(); + + push(0, 0); + expect(push(30000, 16)).toBe(true); + expect(push(30016, 16)).toBe(false); + }); +}); + +describe("SeekDetector gaps in pushes", () => { + function afterGap(resumeAt: number) { + const { push } = makeDetector(); + + push(0, 0); + for (let i = 1; i <= 60; i++) { + push(i * 16, 16); + } + + return push(resumeAt, 300000); + } + + it("does not flag a gap whose first push advanced normally", () => { + expect(afterGap(960 + 16)).toBe(false); + }); + + it("still flags a large forward jump performed during the gap", () => { + expect(afterGap(240000)).toBe(true); + }); + + it("bounds how much a gap can hide", () => { + expect(afterGap(960 + 5000)).toBe(true); + }); + + it("misses a jump smaller than the trusted gap", () => { + expect(afterGap(960 + 500)).toBe(false); + }); + + it("flags a gap that ended on the very same progress value", () => { + expect(afterGap(960)).toBe(true); + }); +}); + +describe("SeekDetector degenerate input", () => { + it("judges by the jitter tolerance alone when no wall time has elapsed", () => { + const { push } = makeDetector(); + + push(1000, 0); + expect(push(1050, 0)).toBe(false); + expect(push(5000, 0)).toBe(true); + }); +}); diff --git a/packages/core/test/timeline-controller.test.ts b/packages/core/test/timeline-controller.test.ts index d7bd9f8083..829f38f9dd 100644 --- a/packages/core/test/timeline-controller.test.ts +++ b/packages/core/test/timeline-controller.test.ts @@ -448,9 +448,33 @@ describe("TimelineController seek", () => { expect(highlighted(c)).toEqual([1]); const diff = tick(c, 500); + expect(diff.hasChanged).toBe(true); expect(diff.isTimeJumped).toBe(true); expect(highlighted(c)).toEqual([0]); }); + + it("treats a repeated identical time as a seek jump", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 3500); + expect(highlighted(c)).toEqual([1]); + + const diff = tick(c, 3500); + expect(diff.hasChanged).toBe(true); + expect(diff.isTimeJumped).toBe(true); + expect(highlighted(c)).toEqual([1]); + }); + + it("returns to normal playback on the frame after a stalled push", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 3500); + expect(tick(c, 3500).isTimeJumped).toBe(true); + + const diff = tick(c, 3600); + expect(diff.isTimeJumped).toBe(false); + expect(highlighted(c)).toEqual([1]); + }); }); describe("TimelineController seek diff", () => { @@ -470,28 +494,37 @@ describe("TimelineController seek diff", () => { expect([...diff.removedHighlighted]).toEqual([]); }); - it("produces no diff on unchanged frames during continuous seeking", () => { + it("reports no set changes but keeps hasChanged while scrubbing continuously", () => { const c = makeController([0, 1000], [3000, 8000]); - c.setSeekingState(true); - - const first = tick(c, 4000); + const first = tick(c, 4000, true); expect([...first.addedHighlighted]).toEqual([1]); expect(first.hasChanged).toBe(true); for (const ms of [4500, 5000, 5500]) { - const diff = tick(c, ms); + const diff = tick(c, ms, true); expect([...diff.addedPlaying]).toEqual([]); expect([...diff.removedPlaying]).toEqual([]); expect([...diff.addedHighlighted]).toEqual([]); expect([...diff.removedHighlighted]).toEqual([]); expect(diff.hasChanged).toBe(true); + expect(diff.isTimeJumped).toBe(true); } expect(playing(c)).toEqual([1]); expect(highlighted(c)).toEqual([1]); }); + it("stops forcing hasChanged once scrubbing stops", () => { + const c = makeController([0, 1000], [3000, 8000]); + + tick(c, 4000, true); + + const diff = tick(c, 4500); + expect(diff.hasChanged).toBe(false); + expect(diff.isTimeJumped).toBe(false); + }); + it("does not both remove and re-add the same line within the same frame", () => { const c = makeController([0, 5000], [1000, 4500], [6000, 7000]); @@ -530,7 +563,7 @@ describe("TimelineController seek diff", () => { ]; const played = makeController(...ranges); - tick(played, 0); + tick(played, 500); tick(played, 1000); tick(played, 2000); expect(played.getSnapshot().scrollToIndex).toBe(0); @@ -590,18 +623,17 @@ describe("TimelineController diff flags", () => { expect(tick(c, 6000).isInterludeChanged).toBe(true); }); - it("sets isTimeJumped and isSeeking on explicit seek, resetting on the next frame", () => { + it("sets isTimeJumped on explicit seek, clearing it on the next sync", () => { const c = makeController([0, 1000], [3000, 4000]); tick(c, 500); const diff = tick(c, 3500, true); + expect(diff.hasChanged).toBe(true); expect(diff.isTimeJumped).toBe(true); - expect(c.getSnapshot().isSeeking).toBe(true); const next = tick(c, 3600); - expect(next.isTimeJumped).toBe(false); expect(next.hasChanged).toBe(false); - expect(c.getSnapshot().isSeeking).toBe(false); + expect(next.isTimeJumped).toBe(false); }); }); @@ -721,25 +753,6 @@ describe("TimelineController snapshot details", () => { expect(c.getSnapshot().latestHighlightedIndex).toBeUndefined(); }); - it("mirrors manual seeking state to snapshot and resets after exit", () => { - const c = makeController([0, 1000], [3000, 4000]); - expect(c.getSnapshot().isSeeking).toBe(false); - - c.setSeekingState(true); - expect(c.getSnapshot().isSeeking).toBe(true); - - const diff = tick(c, 2000); - expect(diff.isTimeJumped).toBe(false); - expect(diff.hasChanged).toBe(true); - expect(highlighted(c)).toEqual([0]); - expect(c.getSnapshot().isSeeking).toBe(true); - - c.setSeekingState(false); - tick(c, 3500); - expect(c.getSnapshot().isSeeking).toBe(false); - expect(highlighted(c)).toEqual([1]); - }); - it("produces no diff on sync with empty lyrics, keeping initial snapshot values", () => { const c = makeController(); diff --git a/packages/docs/astro.config.ts b/packages/docs/astro.config.ts index 00a3f7246e..3a6c0aee11 100644 --- a/packages/docs/astro.config.ts +++ b/packages/docs/astro.config.ts @@ -16,6 +16,7 @@ const docsSidebar = [ items: [ { slug: "guides/component/quickstart" }, { slug: "guides/component/sequence" }, + { slug: "guides/component/seeking" }, { slug: "guides/component/background" }, ], }, diff --git a/packages/docs/src/content/docs/en/guides/component/seeking.md b/packages/docs/src/content/docs/en/guides/component/seeking.md new file mode 100644 index 0000000000..9f33ad4c19 --- /dev/null +++ b/packages/docs/src/content/docs/en/guides/component/seeking.md @@ -0,0 +1,178 @@ +--- +title: Seeking and Progress Alignment +--- + +The lyric component sorts every progress value you push through `setCurrentTime` into one of two categories: **normal playback advance** and **seeking**. This page explains the difference between the two, how the component recognizes seeks automatically, and the cases where you may need to tell it explicitly. + +Before reading this page, it is recommended to first read the part of [Timing and Lifecycle](./sequence) about pushing playback progress frame by frame. This page assumes you already call `setCurrentTime` continuously as described there. + +## Why Seeking Is Distinguished + +Besides advancing naturally with time, playback progress may also jump. Common cases include: + +- Dragging the progress bar +- Fast-forwarding or rewinding +- Clicking a lyric line to seek +- Loop playback, where progress jumps from the end back to the beginning + +During normal playback, the displacement between two adjacent lyric lines is small, so the component can safely present them with refined animation: + +- Lyric lines animate staggered in index order, producing a cascading displacement +- The vertical spring parameters adjust dynamically according to the time interval between adjacent lines, becoming snappier as the interval gets shorter +- The word-by-word mask advances on its own through animation + +A seek, on the other hand, may span the whole song. Keeping the behavior above, every lyric line would move with its own incremental delay, so during a long-distance seek you would see lyric lines that had larger delays sitting still where they were. The word-by-word mask of already highlighted lines would also stay at its pre-seek position, out of sync with the new progress. + +Therefore, on the frame where a seek is recognized, the component switches to a different set of behavior: + +| Behavior | Normal Playback | Seeking | +| ----------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Word-by-word mask | Aligned once, only for lines that just started playing | Aligns the masks of all currently highlighted lines to the target time | +| Animation delay | Increments line by line, cascading displacement | All lines move at the same time | +| Vertical spring | Adjusted dynamically by the time interval between adjacent lines | Fixed, slower parameters | +| Interlude dot animation | Keeps playing | Restarts from the target time | +| User scroll position | Preserved | Cleared, and auto-alignment resumes | + +Note that if the user scroll position came from touch, that position is preserved. + +Seeking is an instantaneous state derived frame by frame. It only holds for the frame where the seek happens, and the next frame returns to normal playback. There is therefore no need to maintain a persistent seeking state on the host side, and you should not keep the seeking state for a long time either. + +## Automatic Derivation + +The component recognizes seeks automatically by default. Given the common premise of **pushing high-precision progress every frame**, the derivation covers the common cases very well: you do not need to do anything extra for seeking, as long as you keep pushing progress with `setCurrentTime` during playback. Passing the seek flag explicitly is a nice-to-have, not a requirement for normal use of the lyric component. + +However, automatic derivation can only reason from progress changes that the component actually receives. Some edge cases may cause it to miss a seek or leave a minor visual blemish; see [Limitations of Automatic Derivation](#limitations-of-automatic-derivation). When the host knows that a seek has occurred, passing the seek flag explicitly can cover these edge cases. + +Backward and stalled progress are both treated as a seek whether or not automatic derivation is enabled. + +Three kinds of progress change are treated as a seek. The first two hold unconditionally; only the third is the automatic derivation's job: + +- Progress going backward, however small +- Progress no longer advancing, that is, pushing the same time repeatedly +- Progress advancing significantly more than it should have: during playback the bar is the real elapsed time; while paused progress should not advance at all, so any advance beyond the jitter range counts + +:::note +During playback, if **progress advances more slowly than real time, it will not be treated as a seek**, for example when playing below 1× speed, or when the progress source has latency. +::: + +### Pushes Must Be Continuous and Dense Enough + +The derivation draws its conclusion from the relationship between two consecutive pushes, so keep syncing frame by frame and do not call `setCurrentTime` only when a seek happens. + +- Once the push interval exceeds roughly 1.2 seconds, every push is treated as a seek. +- Rate-adjusted playback is more sensitive to push density. Pushing every frame at 60 fps avoids false positives up to roughly 10× speed; at 30 fps that drops to roughly 5×, and with the push interval widened to 250 milliseconds only about 1.6× remains. When using high playback rates, push every frame or more often. + +:::caution +Seek detection does not misfire within a normal range of playback rates. But **The lyric component itself does not support rate-adjusted playback**, the word-by-word mask always advances at 1×. During rate-adjusted playback the mask therefore falls steadily behind the actual progress, reaching only 1/rate of the way through a line by the time that line is over (only halfway at 2×, for instance). +::: + +### Progress Source Granularity Must Not Be Coarser Than the Push Interval + +Pushing the same time repeatedly is always treated as a seek, regardless of the automatic derivation switch. This makes a user repeatedly clicking the same spot on the progress bar recognizable as well, and pulls back the visual effects that are mid-animation, such as the word-by-word mask. + +The cost is that if your progress source is quantized coarser than your push interval, the vast majority of pushes will be treated as seeks. For example, pushing a 250-millisecond-granular progress source once per frame leaves roughly + + + 15 + 16 + + +of the pushes unchanged, so nearly every frame is handled as a seek. If you hit this, improve the precision of your progress source, or skip the push while the progress has not changed. Turning off automatic derivation does not avoid this. + +Pausing is unaffected: pushes whose time has not changed are ignored outright, so they are neither treated as seeks nor trigger any relayout. This relies on the component knowing that it is currently paused, so keep `pause()` and `resume()` in sync as described in [Timing and Lifecycle](./sequence#play-and-pause). + +### Limitations of Automatic Derivation + +#### The Only Visual Blemish + +When the premise above holds, exactly one situation leaves a visible blemish: during playback, the user drags the progress bar forward by less than 150 milliseconds. Such a displacement is not recognized, so the word-by-word mask ends up lagging the actual progress by at most 150 milliseconds. + +This blemish is usually negligible. A 150-millisecond lag is barely visible, it does not accumulate, and the mask realigns as soon as the next line starts playing. Dragging the progress bar by less than 150 milliseconds on purpose is also extremely hard to do, so users generally do not seek by such a short distance. + +The same goes for pausing: a progress change within 150 milliseconds is not recognized, and the mask stays where it is. Again, a user is very unlikely to drag by only that much. + +#### Seeks Missed When the Premise Does Not Hold + +The following two situations also miss seeks, but both stem from the premise not holding rather than from a limit of the derivation itself: + +- **Pushing stops during an interruption**: when the tab goes to the background, pushing is throttled or stops, and a seek that happens meanwhile may go unrecognized. The frame where playback resumes realigns anyway, though, so this is usually invisible. Only an interruption lasting about a second can leave the mask lagging by up to 400 milliseconds, and that too clears when the next line starts. +- **The playback state is out of sync**: if the audio is already paused but the component still thinks it is playing, that is, `pause()` was never called, every seek within roughly 1.2 seconds while paused is missed and the mask stays where it is. Keeping the playback state in sync as described in [Timing and Lifecycle](./sequence#play-and-pause) narrows that back down to the negligible 150 milliseconds above. + +## Marking Seeks Explicitly + +The second parameter of `setCurrentTime` means **force this to be handled as a seek**: + +```ts +function onSeeked() { + player.setCurrentTime(Math.round(audio.currentTime * 1000), true); +} +audio.addEventListener("seeked", onSeeked); +``` + +Passing the seek flag explicitly is an optional nice-to-have. Automatic derivation is enabled by default and usually works very well when high-precision progress is pushed continuously. + +Because the seeking state consumes more resources and interrupts gesture interaction (except for touch), keeping the seeking state for a long time is not recommended. + +If you know for sure that a seek happened, you can mark it explicitly in the corresponding `setCurrentTime` call. This makes the decision independent of the push cadence and avoids the short-seek blemish and the two missed cases described above. + +Passing the seek flag explicitly never overrides the result of the automatic derivation, so the two are safe to use together. + +## When the Component Aligns on Its Own + +The component handles the following cases as seeks on its own, with no intervention needed from you: + +- When rebuilding the lyric view (such as `setLyricLines`, `setOptimizeOptions`, `updateLyricProcessConfig`), aligning with the initial time given at rebuild time +- When the page is shown (`pageshow`), realigning with the current progress + +## Turning Off Automatic Derivation + +If your host's progress source is too imprecise, so that the size of a forward advance is frequently misjudged, you can turn off the automatic derivation with [`setEnableAutoSeekDetection`](/en/reference/core/classlyricplayerbase#setenableautoseekdetection). + +After that, only the third rule above stops applying, that is, the media clock is no longer compared against the wall clock; backward and stalled progress are still treated as seeks. The current state can be read with [`getEnableAutoSeekDetection`](/en/reference/core/classlyricplayerbase#getenableautoseekdetection). + +## Lyric Line Click Events + +The component provides a `line-click` event, fired when a lyric line is left-clicked with the mouse. Its event type is [`LyricLineMouseEvent`](/en/reference/core/classlyriclinemouseevent). + +The component itself does not respond to lyric line clicks. You need to listen to the event and perform actions such as seeking the audio progress. For example: + +```ts +import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core"; + +player.addEventListener("line-click", (event) => { + const lineEvent = event as LyricLineMouseEvent; + audio.currentTime = lineEvent.line.getLine().startTime / 1000; + player.setCurrentTime(lineEvent.line.getLine().startTime, true); +}); +``` + +Clicking a lyric line to jump is also a seek. The explicit `true` above has the same effect as the automatic derivation, so the jump is generally still recognized correctly even if you omit it. + +When the lyric component is no longer needed, remember to remove the listener added here. See [Timing and Lifecycle](./sequence#cleanup) for details. + +## React and Vue Bindings + +The React binding provides an `isSeeking` prop, which maps to the second parameter of `setCurrentTime` and can be passed when seeking: + +```tsx + +``` + +Since automatic derivation is enabled by default, this prop can usually be omitted. As with the vanilla API, it should not stay `true` for a long time. + +This prop only annotates a change of `currentTime`; it never triggers a push by itself, so changing it while `currentTime` stays the same has no effect. + +The Vue binding does not have a corresponding prop, but automatic derivation is enabled by default, so syncing `currentTime` already handles seeks correctly. If you need to mark seeks explicitly or turn off automatic derivation, access the underlying `lyricPlayer` through a component ref and call the corresponding methods yourself. + +## Checklist + +- Keep pushing progress with `setCurrentTime` during playback; do not call it only when seeking. +- The granularity of the progress source should not be coarser than the push interval. +- When using rate-adjusted playback, keep pushing frame by frame, and note that the component does not support rate-adjusted playback: the word-by-word mask still advances at 1×. +- When you know a seek happened (a lyric line click, for instance), you can optionally use the seek flag as a supplement to automatic derivation. +- Do not leave the seek flag set to `true` for a long time. diff --git a/packages/docs/src/content/docs/en/guides/component/sequence.md b/packages/docs/src/content/docs/en/guides/component/sequence.md index 7381a875b8..15e75979d4 100644 --- a/packages/docs/src/content/docs/en/guides/component/sequence.md +++ b/packages/docs/src/content/docs/en/guides/component/sequence.md @@ -107,46 +107,9 @@ function stopFrameLoop() { ### Seeking -Outside normal playback, playback progress may jump. Common cases include: +Outside normal playback, playback progress may jump. For this kind of progress change, the lyric component switches to a different set of layout and animation behavior. -- Dragging the progress bar -- Fast-forwarding or rewinding -- Clicking a lyric line to seek -- Loop playback, where progress jumps from the end back to the beginning - -When playback progress jumps, set the second parameter of `setCurrentTime` to `true`: - -```ts -function onSeeked() { - player.setCurrentTime(Math.round(audio.currentTime * 1000), true); -} -audio.addEventListener("seeked", onSeeked); -``` - -**This parameter indicates that the current sync is a seek. Normal playback and seek state use different layout and animation behavior:** - -- During normal playback, the component lays out and applies spring animation to each visible line individually for a refined visual effect. -- During seeking, the component force-aligns the lyric position and applies layout plus spring animation to all lyric lines as a whole, reducing work and making the animation snappier. - -If seek state is not marked correctly, layout glitches may occur, such as stutters or lyric lines quickly flying from one side of the screen to the other and disappearing. You can see screenshots in [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429). - -### Lyric Line Click Events - -The component provides a `line-click` event, fired when a lyric line is clicked. Its event type is [`LyricLineMouseEvent`](/en/reference/core/classlyriclinemouseevent). - -**The component itself does not respond to lyric line clicks.** The host environment needs to listen to the event and perform actions such as seeking the audio progress. For example: - -```ts -import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core"; - -player.addEventListener("line-click", (event) => { - const lineEvent = event as LyricLineMouseEvent; - audio.currentTime = lineEvent.line.getLine().startTime / 1000; - player.setCurrentTime(lineEvent.line.getLine().startTime, true); -}); -``` - -It is worth noting that clicking a lyric line to jump is also a seek. +For more information, see [Seeking and Progress Alignment](./seeking). ## Changing Lyrics @@ -169,20 +132,7 @@ You still need to provide these states: | Current progress | `currentTime` | Synced from audio with `requestAnimationFrame` during playback | | Playback state | `playing` | Pauses or resumes the lyric component's internal presentation | -The React binding additionally provides an `isSeeking` prop, which you can pass during seeking: - -```tsx - -``` - -`isSeeking` should not stay `true` for a long time. Usually, set it to `true` briefly when the user completes a seek, then restore it to `false` after the next sync. - -The Vue binding is currently less complete and does not have a separate `isSeeking` prop. In common scenarios, syncing `currentTime` is enough to work. If you need finer state control, use the vanilla API directly. We will continue improving the Vue binding functionality and usage experience in upcoming versions. +The React binding additionally provides an `isSeeking` prop, which maps to the second parameter of `setCurrentTime`. The Vue binding is currently less complete and does not have a corresponding prop. Automatic derivation is enabled by default for both, so syncing `currentTime` is generally enough; see [Seeking and Progress Alignment](./seeking#react-and-vue-bindings) for details. We will continue improving the Vue binding functionality and usage experience in upcoming versions. If `disabled` is set, the binding no longer manages frame-by-frame animation. In that case, you can access the underlying `lyricPlayer` through a component ref and call `update` yourself, just like with the vanilla API. @@ -215,5 +165,5 @@ If you use the React or Vue bindings, the component automatically calls the unde - During playback, `currentTime` is synced with `requestAnimationFrame`. - In vanilla usage, `update(delta)` is called frame by frame. - Pause, resume, and playback end are synced to `pause()` / `resume()` or `playing`. -- Seeking uses the seek flag to align the lyric position. +- Seeks are recognized automatically by default; when you know a seek happened, you can additionally use the seek flag to mark it explicitly. - On unmount, cancel animation frames, remove event listeners, and dispose the component. diff --git a/packages/docs/src/content/docs/guides/component/seeking.md b/packages/docs/src/content/docs/guides/component/seeking.md new file mode 100644 index 0000000000..d8d2c374ea --- /dev/null +++ b/packages/docs/src/content/docs/guides/component/seeking.md @@ -0,0 +1,185 @@ +--- +title: 跳转与进度对齐 +--- + +歌词组件会把你通过 `setCurrentTime` 推送的每一次进度分成两类:**正常播放推进** 与 **跳转**。本文介绍两者的区别、组件如何自动识别跳转,以及你可能在哪些场合需要显式告知。 + +阅读本文前,建议先了解 [时序与生命周期](./sequence) 中关于逐帧推送播放进度的部分,本文假设你已经按那里所述持续调用 `setCurrentTime`。 + +## 为什么要区分跳转 + +播放进度除了随时间自然前进,还可能产生跳变,常见于: + +- 拖动进度条 +- 快进快退 +- 点击某一歌词行跳转 +- 循环播放时,进度从结尾跳至开头 + +正常播放时,相邻两行歌词之间的位移很小,组件可以放心用细腻的动画表现它们: + +- 歌词行按索引顺序错峰进行动画,形成阶梯式的位移 +- 纵向弹簧的参数按相邻两行的时间间隔动态调整,间隔越短越迅捷 +- 逐字遮罩由动画自行推进 + +跳转的位移则可能横跨整首歌。沿用上面那套行为,每一行歌词都会带着各自的递增延迟进行位移,会导致长距离跳转时,看到之前具有较大延迟的歌词行停留在原地;已经高亮的行的逐字遮罩也会停在跳转前的位置,与新的进度脱节。 + +因此,组件在识别到跳转的那一帧会切换成另一套行为: + +| 行为 | 正常播放 | 跳转 | +| ------------ | ---------------------------- | ------------------------------------ | +| 逐字遮罩 | 只为刚进入播放的行对齐一次 | 把当前所有高亮行的遮罩对齐到目标时间 | +| 动画延迟 | 逐行递增,阶梯式位移 | 所有行同时位移 | +| 纵向弹簧 | 按相邻歌词的时间间隔动态调整 | 固定使用较缓慢的参数 | +| 间奏点动画 | 继续播放 | 从目标时间重新开始 | +| 用户滚动位置 | 保留 | 清除并恢复自动对齐 | + +其中,如果用户滚动位置是触摸触发的,该位置会被保留。 + +跳转是逐帧推导的瞬时状态,只在发生跳转的那一帧成立,下一帧即回到正常播放,因此不需要在宿主侧维护一个持续的跳转状态,也不应该长时间保持跳转状态。 + +## 自动推导 + +组件默认会自动识别跳转。在**逐帧推送高精度进度**这个常见前提下,推导足以很好地覆盖常见情况,你不需要为跳转做任何额外的事,只要在播放期间持续用 `setCurrentTime` 推送进度即可。显式传入 seek 标志是一个锦上添花的可选功能,并不是正常使用歌词组件的必要步骤。 + +不过,自动推导只能根据组件实际收到的进度变化进行判断,某些边界情况可能漏掉跳转或留下轻微的视觉瑕疵,详见[自动推导的局限](#自动推导的局限)。在宿主明确知道跳转已经发生时,显式传入 seek 标志可以覆盖这些边界情况。 + +无论是否启用自动推导,进度倒退与停滞都会被视为跳转。 + +会被判为跳转的进度变化有三种,前两种无条件成立,只有第三种由自动推导负责: + +- 进度倒退,无论幅度多小 +- 进度不再前进,即重复推送同一个时间 +- 进度前进的幅度明显超过应有的推进量 + +:::note +播放时如果**进度前进得比真实时间慢,将不会被判为跳转**,例如低于 1 倍速播放、进度来源存在延迟等情况。 +::: + +### 推送需要连续且足够密集 + +推导靠前后两次推送的关系得出结论,因此请保持逐帧同步,不要只在发生跳转时才调用 `setCurrentTime`。 + +- 推送间隔超过约 1.2 秒时,每一次推送都会被判为跳转。 +- 倍速播放对推送密度更敏感。60 fps 逐帧推送时约 10 倍速以内不会误判,30 fps 时降到约 5 倍速,推送间隔放宽到 250 毫秒时只剩约 1.6 倍速。使用高倍速播放时,请保持逐帧或更高频率推送。 + +:::caution +跳转判定在正常的倍速范围内不会误判。但**歌词组件本身并不支持倍速播放**,逐字遮罩恒以 1 倍速推进。因此倍速播放时遮罩会持续落后于实际进度,一行唱完时只推进到该行的 + + + 1 + 倍速 + + +处(例如 2 倍速时只推进到一半)。 +::: + +### 进度来源的粒度不能粗于推送间隔 + +重复推送同一个时间总会被视为跳转,与自动推导的开关无关。这样用户反复点击进度条上的同一位置也能被识别,并拉回动画中的视觉效果,例如逐字遮罩效果。 + +代价是,如果你的进度来源粒度粗于推送间隔,绝大多数推送都会被判成跳转。例如逐帧推送一个 250 毫秒粒度的进度源时,约 + + + 15 + 16 + + +的推送进度未变,于是几乎每一帧都按跳转处理。遇到这种情况,应当改善进度来源的精度,或在进度未发生变化时跳过推送。关闭自动推导不能规避这一点。 + +暂停期间不受影响,时间未发生变化的推送会被直接忽略,既不会被判成跳转,也不会触发任何重排。这依赖组件知道当前处于暂停状态,因此请按 [时序与生命周期](./sequence#播放与暂停) 所述正确调用 `pause()` 与 `resume()`。 + +### 自动推导的局限 + +#### 唯一的视觉瑕疵 + +满足上面的前提时,只有一种情况会留下可见的瑕疵:播放中用户把进度条向前拖动了 150 毫秒以内。这样的位移识别不出来,逐字遮罩会因此比实际进度慢至多 150 毫秒。 + +这个瑕疵通常可以忽略。150 毫秒的滞后在视觉上几乎看不出来,也不会累积,下一行开始播放时遮罩就会重新对齐。而且在进度条上精确拖出 150 毫秒以内的位移极难操作,用户一般不会跳转这么短的距离。 + +暂停时同理,150 毫秒以内的进度变化不会被识别,遮罩会停留在原地。同样地,用户几乎不可能只拖动这么点距离。 + +#### 前提不成立时漏掉的跳转 + +下面两种情况也会漏掉跳转,但它们都源于前提没有成立,而不是推导本身的局限: + +- **推送在中断期间停止**:切到后台等情况下推送会被节流或停止,其间发生的跳转可能识别不出来。不过恢复播放的那一帧本来就会重新对齐,所以通常看不出差异。只有中断约 1 秒时才可能导致至多 400 毫秒的遮罩滞后,且同样会在下一行开始时消失。 +- **播放状态未同步**:若音频已暂停而组件仍以为在播放,即没有调用 `pause()`,则暂停期间约 1.2 秒以内的跳转都会被漏掉,遮罩停留在原地。按 [时序与生命周期](./sequence#播放与暂停) 所述同步播放状态,这个范围就会收窄回上面那个可忽略的 150 毫秒。 + +## 显式标记跳转 + +`setCurrentTime` 的第二个参数的含义是**强制按跳转处理**: + +```ts +function onSeeked() { + player.setCurrentTime(Math.round(audio.currentTime * 1000), true); +} +audio.addEventListener("seeked", onSeeked); +``` + +显式传入 seek 标志是锦上添花的可选功能。自动推导默认启用,在持续推送高精度进度时通常已经可以很好地工作。 + +由于跳转状态会消耗较多资源,且会打断手势交互(触摸除外),不建议长时间保持跳转状态。 + +如果你明确知道发生了跳转,也可以在对应的 `setCurrentTime` 调用中显式标记。这样判定不受推送节奏影响,可以避免上面提到的短距离跳转瑕疵和两种漏判。 + +显式传入跳转标志不会否决自动推导的结果,所以两者可以放心一起使用。 + +## 组件自行对齐的时机 + +下面这些场合由组件自己按跳转处理,你无需干预: + +- 重建歌词视图时(如 `setLyricLines`、`setOptimizeOptions`、`updateLyricProcessConfig`),用重建时给定的初始时间对齐 +- 页面显示(`pageshow`)时,用当前进度重新对齐 + +## 关闭自动推导 + +如果宿主的进度来源精度太差,以致进度前进的幅度频繁被误判,可以使用 [`setEnableAutoSeekDetection`](/reference/core/classlyricplayerbase#setenableautoseekdetection) 关闭自动推导。 + +关闭后只有上面第三条规则失效,即不再比较媒体时钟与物理时钟;进度倒退与停滞仍然会被视为跳转。当前状态可以用 [`getEnableAutoSeekDetection`](/reference/core/classlyricplayerbase#getenableautoseekdetection) 读取。 + +## 歌词行点击事件 + +组件提供了 `line-click` 事件,在某一歌词行被鼠标左键点击时触发,其事件类型为 [`LyricLineMouseEvent`](/reference/core/classlyriclinemouseevent)。 + +组件本身不会响应歌词行的点击操作。你需要监听该事件,并作出音频进度跳转等操作。例如: + +```ts +import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core"; + +player.addEventListener("line-click", (event) => { + const lineEvent = event as LyricLineMouseEvent; + audio.currentTime = lineEvent.line.getLine().startTime / 1000; + player.setCurrentTime(lineEvent.line.getLine().startTime, true); +}); +``` + +点击歌词行跳转时也属于跳转,上面显式传入的 `true` 与自动推导的结果作用相同,因此即使省略它一般也能正确识别。 + +不再需要歌词组件时,别忘了移除这里添加的监听器,详见 [时序与生命周期](./sequence#清理)。 + +## React 与 Vue 绑定 + +React 绑定提供 `isSeeking` 属性,对应 `setCurrentTime` 的第二个参数,可以在跳转时传入: + +```tsx + +``` + +由于自动推导默认启用,这个属性通常可以省略。和原生方式一样,它也不应长期保持为 `true`。 + +这个属性只标注 `currentTime` 的变化,本身不会触发推送,因此 `currentTime` 未变化时改动它不产生任何效果。 + +Vue 绑定没有对应的属性,但自动推导默认启用,因此同步 `currentTime` 就能正确处理跳转。如果需要显式标记跳转或关闭自动推导,可以通过组件 ref 取得底层 `lyricPlayer` 后自行调用对应方法。 + +## 检查清单 + +- 播放期间持续用 `setCurrentTime` 推送进度,不要只在跳转时调用。 +- 进度来源的粒度不应粗于推送间隔。 +- 使用倍速播放时保持逐帧推送,并注意组件不支持倍速,逐字遮罩仍会以 1 倍速推进。 +- 已知发生跳转时(歌词行点击等),可选用 seek 标志显式告知。 +- 不要让 seek 标志长期保持为 `true`。 diff --git a/packages/docs/src/content/docs/guides/component/sequence.md b/packages/docs/src/content/docs/guides/component/sequence.md index 93e47d32e1..99a15c1ee4 100644 --- a/packages/docs/src/content/docs/guides/component/sequence.md +++ b/packages/docs/src/content/docs/guides/component/sequence.md @@ -107,46 +107,9 @@ function stopFrameLoop() { ### 跳转 -在正常播放之外,播放进度有可能产生跳变,常见于: +在正常播放之外,播放进度有可能产生跳变,歌词组件对这类进度变化会切换到另一套布局与动画行为。 -- 拖动进度条 -- 快进快退 -- 点击某一歌词行跳转 -- 循环播放时,进度从结尾跳至开头 - -播放进度发生跳变时,需要把 `setCurrentTime` 的第二个参数设为 `true`: - -```ts -function onSeeked() { - player.setCurrentTime(Math.round(audio.currentTime * 1000), true); -} -audio.addEventListener("seeked", onSeeked); -``` - -**这个参数表示本次同步是一次 seek。正常播放状态与 seek 状态的布局与动画行为是不同的:** - -- 正常播放时,组件会对视图内的每一行单独执行布局与弹簧动画,实现细腻的视觉效果 -- 调整进度时,组件会强制对齐歌词位置,对所有歌词行整体执行布局与弹簧动画效果,减小性能消耗且动画更加利落 - -如果没有正确标记 seek 状态,可能出现布局异常,例如出现卡顿、歌词行从屏幕一端快速飞到另一端消失等等。你可以在 [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429) 中看到截图。 - -### 歌词行点击事件 - -组件提供了 `line-click` 事件,在某一歌词行被点击时触发,其事件类型为 [`LyricLineMouseEvent`](/reference/core/classlyriclinemouseevent)。 - -**组件本身不会响应歌词行的点击操作。** 宿主环境需要监听该事件,并作出音频进度跳转等操作。例如: - -```ts -import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core"; - -player.addEventListener("line-click", (event) => { - const lineEvent = event as LyricLineMouseEvent; - audio.currentTime = lineEvent.line.getLine().startTime / 1000; - player.setCurrentTime(lineEvent.line.getLine().startTime, true); -}); -``` - -值得一提:点击歌词行跳转时也属于 seek。 +有关更多信息,请转到 [跳转与进度对齐](./seeking)。 ## 更换歌词 @@ -169,20 +132,7 @@ React 和 Vue 绑定会创建并销毁底层 Core 组件,也会在未禁用时 | 当前播放进度 | `currentTime` | 播放中用 `requestAnimationFrame` 从音频同步 | | 播放状态 | `playing` | 控制歌词组件内部演出暂停或恢复 | -React 绑定额外提供 `isSeeking` 属性,可以在跳转时传入: - -```tsx - -``` - -`isSeeking` 不应长期保持为 `true`。通常在用户完成一次跳转时短暂置为 `true`,下一轮同步后再恢复为 `false`。 - -Vue 绑定目前功能较为残缺,没有单独的 `isSeeking` 属性。一般场景下同步 `currentTime` 就可以工作。如果需要进一步控制状态,建议直接使用原生方式引入。我们将会在接下来的版本中逐步优化 Vue 绑定的功能与使用体验。 +React 绑定额外提供 `isSeeking` 属性,对应 `setCurrentTime` 的第二个参数;Vue 绑定目前功能较为残缺,没有对应的属性。两者的自动推导都默认启用,因此一般同步 `currentTime` 即可,详见 [跳转与进度对齐](./seeking#react-与-vue-绑定)。我们将会在接下来的版本中逐步优化 Vue 绑定的功能与使用体验。 如果设置了 `disabled`,绑定将不再代管逐帧动画。此时你可以通过组件 ref 取得底层 `lyricPlayer`,并像原生方式一样自己调用 `update`。 @@ -215,5 +165,5 @@ player.dispose(); - 播放时用 `requestAnimationFrame` 同步 `currentTime`。 - 原生方式逐帧调用 `update(delta)`。 - 暂停、恢复、结束播放时同步 `pause()` / `resume()` 或 `playing`。 -- 跳转使用 seek 标志对齐。 +- 跳转默认由组件自动识别;已知发生跳转时,可以额外用 seek 标志显式标识。 - 卸载时取消动画帧、移除事件监听并释放组件。 diff --git a/packages/react/src/lyric-player.tsx b/packages/react/src/lyric-player.tsx index 319a17e191..ea1aa5c139 100644 --- a/packages/react/src/lyric-player.tsx +++ b/packages/react/src/lyric-player.tsx @@ -97,6 +97,14 @@ export interface LyricPlayerProps { * 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好 */ currentTime?: number; + /** + * 标识本次 {@link currentTime} 变化是否由跳转触发,将强制触发一次重新排版 + * + * 此属性只标注 {@link currentTime} 的变化,本身不会触发推送, + * 因此 {@link currentTime} 未变化时改动它不产生任何效果 + * + * @see https://amll.dev/guides/component/seeking + */ isSeeking?: boolean; /** * 设置文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5,即一个全角字符的一半宽度 @@ -234,9 +242,6 @@ export const LyricPlayer: ForwardRefExoticComponent< if (lyricLinesChanged || corePlayer.getLyricLines().length === 0) { if (lyricLines !== undefined) { corePlayer.setLyricLines(lyricLines, currentTimeRef.current); - if (currentTimeRef.current !== undefined) { - corePlayer.setCurrentTime(currentTimeRef.current, true); - } corePlayer.update(); } else { corePlayer.setLyricLines([]); @@ -313,6 +318,11 @@ export const LyricPlayer: ForwardRefExoticComponent< corePlayer?.setEnableBlur(enableBlur ?? true); }, [corePlayer, enableBlur]); + // isSeeking 只标注本次 currentTime 推送是否为跳转,不作为推送的触发源 + // + // currentTime 未变而 isSeeking 变化若重跑此 Effect,会推送一次重复的进度, + // 而重复推送同一个时间会被跳转推导判定为跳转,触发一次多余的完整重排 + // biome-ignore lint/correctness/useExhaustiveDependencies: isSeeking 不作为触发源 useLayoutEffect(() => { if (currentTime !== undefined) { corePlayer?.setCurrentTime(currentTime, isSeeking); @@ -321,11 +331,7 @@ export const LyricPlayer: ForwardRefExoticComponent< corePlayer?.setCurrentTime(0); currentTimeRef.current = 0; } - }, [corePlayer, currentTime, isSeeking]); - - useEffect(() => { - corePlayer?.setIsSeeking(!!isSeeking); - }, [corePlayer, isSeeking]); + }, [corePlayer, currentTime]); useEffect(() => { corePlayer?.setWordFadeWidth(wordFadeWidth);