diff --git a/.nx/version-plans/version-plan-1787230167435.md b/.nx/version-plans/version-plan-1787230167435.md new file mode 100644 index 0000000000..ce62417efb --- /dev/null +++ b/.nx/version-plans/version-plan-1787230167435.md @@ -0,0 +1,7 @@ +--- +core-bundle: minor +--- + +refactor(core)!: 优化时间线推导 + +视觉效果已发生变化,现在播放完毕的行将不再熄灭以维持可读性,同时和 Apple Music 的行为保持一致 diff --git a/packages/core/src/lyric-player/base/timeline.ts b/packages/core/src/lyric-player/base/timeline.ts index 4280dc2267..1ffd16dedf 100644 --- a/packages/core/src/lyric-player/base/timeline.ts +++ b/packages/core/src/lyric-player/base/timeline.ts @@ -1,5 +1,10 @@ import { Duration, MediaTime } from "#utils/time.ts"; +/** + * 判定为间奏所需的最小空隙时长 + */ +const MIN_INTERLUDE_GAP = Duration.fromMillis(4000); + //#region 类型定义 /** * 用于进度计算的最小歌词数据 @@ -13,12 +18,20 @@ export interface TimeBounds { * 当前命中的间奏区间信息 */ export interface PlayerInterlude { + /** + * 间奏开始时间,即此前全部歌词行中最晚的结束时间 + */ readonly startTime: MediaTime; + + /** + * 间奏结束时间,即间奏后第一行歌词的开始时间 + */ readonly endTime: MediaTime; + /** * 间奏点应插入的位置基准 * - * 即间奏前最后一句歌词的索引,-1 表示第一句之前 + * 即显示顺序上间奏前的最后一行歌词的索引,`-1` 表示第一句之前 */ readonly anchorLineIndex: number; } @@ -56,33 +69,38 @@ export interface TimelineSnapshot { /** * 当前进度命中的、正在高亮的歌词组 * - * 高亮的歌词组可能会多于正在播放的,一般用于多行高亮时、保留上一行播放完毕的歌词的高亮状态 + * 高亮的歌词组可能会多于正在播放的,例如一行歌词唱完后不会自行熄灭,而是保持高亮直到下一行开始播放 + * + * 因此多行重叠时先唱完的行会陪着后面的行一起亮,句间空隙内上一行也保持高亮以维持可读性 */ readonly highlightedGroups: ReadonlySet; /** * 自动滚动应该对齐到哪一行歌词 + * + * 取值始终落在 `[0, 歌词行数 - 1]` 内,没有歌词时为 `0`,因此可以直接用于索引 + * + * @remarks + * 仅在 {@link isFocusOnInterlude} 为 `false` 时有效 + * + * 间奏期间此值仍然指向间奏前的那一组歌词,此时应当改为对齐 {@link activeInterlude} 的间奏点 */ readonly scrollToIndex: number; /** * 处于高亮状态的歌词行中,最靠后的一行 * - * 例如,如果当前有高亮行索引 `[1, 2, 3]`,则 `latesthighlightedIndex` 为 `3` + * 例如,如果当前有高亮行索引 `[1, 2, 3]`,则 `latestHighlightedIndex` 为 `3` * - * 如果当前没有任何高亮行,如两行歌词之间的间隙,被置为 undefined + * 如果当前没有任何高亮行,被置为 undefined,如首行开始之前、间奏区间内、 + * 歌曲播放完毕、或已开始的行均为零时长的情况 */ readonly latestHighlightedIndex?: number; /** - * 标识当前是否有任何高亮中的歌词组 - */ - readonly isTimelineEmpty: boolean; - - /** - * 标识歌曲是否播放完毕 + * 标识歌曲是否播放完毕,即当前时间已经越过全部歌词行中最晚的结束时间 * - * 一般用于展示底栏 + * 此时所有高亮都已被清空,一般用于展示底栏 */ readonly isEndOfSong: boolean; @@ -114,12 +132,15 @@ export interface TimelineDiff { /** * 在当前时间进度下,最新被命中的、正在播放的歌词索引列表 * - * 用于通知 UI 哪些歌词行开始播放了了 + * 用于通知 UI 哪些歌词行开始播放了 */ readonly addedPlaying: ReadonlyArray; /** - * 在当前时间进度下,刚刚脱离正在播放状态的歌词索引列表,即上一帧还在播放、但本帧时间已超过其 endTime 的歌词行 + * 在当前时间进度下,刚刚脱离正在播放状态的歌词索引列表, + * 即上一帧还在播放、但本帧已不再命中其 `[startTime, endTime)` 的歌词行 + * + * 正常播放时是时间越过了 `endTime`,跳转时也可能是跳到了 `startTime` 之前 * * 用于通知 UI 侧哪些歌词行已结束,后续可能会转入高亮行以保持高亮状态 */ @@ -173,7 +194,11 @@ type Mutable = { export class TimelineController { //#region 内部状态 - private lyricBounds: TimeBounds[] = []; + private lyricBounds: readonly TimeBounds[] = []; + /** + * 全部歌词行中最晚的结束时间,用于判定歌曲是否播放完毕 + */ + private maxEndTime: MediaTime = MediaTime.ZERO; /** * 外部显式传入的持续性 Seek 状态(例如正在拖拽进度条) */ @@ -185,15 +210,17 @@ export class TimelineController { private precalculatedInterludes: PlayerInterlude[] = []; /** - * 保存上次检索到的正在播放歌词的位置,用于避免每次都从头遍历所有歌词,提高性能 + * 保存上次顺序扫描停止的位置,用于避免每次都从头遍历所有歌词,提高性能 */ private playbackCursor = 0; private interludeCursor = 0; - private isFocusOnInterludeState = false; private playingGroupsSet: Set = new Set(); private highlightedGroupsSet: Set = new Set(); + private nextPlayingSet: Set = new Set(); + private nextHighlightedSet: Set = new Set(); + private addedPlayingIds: number[] = []; private removedPlayingIds: number[] = []; private addedHighlightedIds: number[] = []; @@ -207,7 +234,6 @@ export class TimelineController { highlightedGroups: this.highlightedGroupsSet, scrollToIndex: 0, latestHighlightedIndex: undefined, - isTimelineEmpty: true, isEndOfSong: false, activeInterlude: undefined, isFocusOnInterlude: false, @@ -228,11 +254,22 @@ export class TimelineController { //#region 外部 API /** * 提前设置好歌词的时间数据,内部会根据此数据来进行时间线推导,同时预计算间奏区间 + * + * @param bounds 歌词行的时间边界,**必须按 `startTime` 升序排列**,不按 `startTime` + * 排列可能会导致时间推导出现意外情况 */ - public setTimeBounds(bounds: TimeBounds[]): void { + public setTimeBounds(bounds: readonly TimeBounds[]): void { this.lyricBounds = bounds; this.precalculatedInterludes = this.calculateInterludes(bounds); + // 歌词行按开始时间排序,末行的结束时间不一定是最大值 + // 例如末尾存在时间上被前一行包住的重叠行,因此单独预计算一次 + let maxEnd = MediaTime.ZERO; + for (const bound of bounds) { + maxEnd = MediaTime.max(maxEnd, bound.endTime); + } + this.maxEndTime = maxEnd; + this.reset(); } @@ -248,49 +285,87 @@ export class TimelineController { return this.snapshot; } + /** + * 将播放进度推进到指定时间,并返回相对上一帧的增量变化 + * + * @remarks + * 歌词行的高亮生命周期为: + * * 命中 `[startTime, endTime)` 时高亮 + * * 唱完后不会自行熄灭,而是继续保持高亮 + * + * 直到出现下列任一情况: + * + * 1. 有新的歌词行开始播放,此时已唱完的行被一起熄灭 + * 2. 进入间奏区间,此时清空全部高亮并把焦点交给间奏点 + * 3. 歌曲播放完毕,此时清空全部高亮并设置 {@link TimelineSnapshot.isEndOfSong} 为 true + * 4. 发生跳转,此时按跳转后的时间重新推导 + * + * @param time 当前播放时间 + * @param forceSeek 这次时间变化是否由跳转触发 + * @returns 相对上一帧的增量变化 + */ public sync(time: MediaTime, forceSeek = false): TimelineDiff { this.addedPlayingIds.length = 0; this.removedPlayingIds.length = 0; this.addedHighlightedIds.length = 0; this.removedHighlightedIds.length = 0; - this.expiredHighlightedIds.length = 0; const prevInterlude = this.snapshot.activeInterlude; const prevFocusOnInterlude = this.snapshot.isFocusOnInterlude; const prevScrollToIndex = this.snapshot.scrollToIndex; + const prevEndOfSong = this.snapshot.isEndOfSong; - // 将时间倒退视为 seek 是为了避免 performPlayback 顺序查找时失效 - // performPlayback 会保存上次找到的最小的播放行的索引,下次从该索引查找以提高性能 - // 若时间倒退,将会导致倒退到的那行直到 playbackCursor 之间都无法高亮 + // 将时间倒退视为 seek 是为了避免 performPlayback 的顺序扫描失效 + // performPlayback 会保存上次扫描停止的位置,下次从该位置继续扫描以提高性能 + // 若时间倒退,倒退到的行可能位于扫描位置之前,需要按跳转路径重新推导 const isTimeRegression = time < this.snapshot.currentTime; const isJump = forceSeek || isTimeRegression; this.snapshot.isSeeking = this.isManualSeeking || isJump; + + // 间奏命中情况需要先于歌词状态确定 + // Seek 时要按同样的规则决定是否保留已经唱完的行,需要提前知道结果 + const activeInterlude = this.resolveActiveInterlude(time, isJump); + this.snapshot.activeInterlude = activeInterlude; + + const isPastLastLine = + this.lyricBounds.length > 0 && time >= this.maxEndTime; + if (this.snapshot.isSeeking) { - this.performSeek(time); + this.performSeek(time, !!activeInterlude || isPastLastLine); } else { this.performPlayback(time); } - this.updateInterludeState(time, isJump); + // 高亮行本身不会因为唱完而熄灭,这里处理两个需要清空的场景 + // 1. 进入间奏区间:间奏点接过焦点,不应该再有亮着的旧歌词 + // 2. 歌曲播放完毕:不会再有新歌词接续,需要主动熄灭并把焦点交给底栏 + if (activeInterlude && this.playingGroupsSet.size === 0) { + this.flushAllHighlighted(); + } + if (isPastLastLine) { + this.flushAllHighlighted(); + } + + this.updateInterludeFocus(activeInterlude); - const isInterludeChanged = prevInterlude !== this.snapshot.activeInterlude; + const isInterludeChanged = prevInterlude !== activeInterlude; const isFocusChanged = prevFocusOnInterlude !== this.snapshot.isFocusOnInterlude; const isScrollToChanged = prevScrollToIndex !== this.snapshot.scrollToIndex; const hasChanged = - isJump || + this.snapshot.isSeeking || this.addedPlayingIds.length > 0 || this.removedPlayingIds.length > 0 || this.addedHighlightedIds.length > 0 || this.removedHighlightedIds.length > 0 || isInterludeChanged || isFocusChanged || - isScrollToChanged; + isScrollToChanged || + isPastLastLine !== prevEndOfSong; this.snapshot.currentTime = time; - this.snapshot.isTimelineEmpty = this.highlightedGroupsSet.size === 0; if (this.highlightedGroupsSet.size > 0) { let maxIndex = -1; @@ -302,14 +377,7 @@ export class TimelineController { this.snapshot.latestHighlightedIndex = undefined; } - // 判断歌曲是否播放完毕,UI 会根据此标志决定是否聚焦到底栏 - this.snapshot.isEndOfSong = false; - if (this.highlightedGroupsSet.size === 0 && this.lyricBounds.length > 0) { - const lastLine = this.lyricBounds[this.lyricBounds.length - 1]; - if (time >= lastLine.endTime) { - this.snapshot.isEndOfSong = true; - } - } + this.snapshot.isEndOfSong = isPastLastLine; this.diff.hasChanged = hasChanged; this.diff.isInterludeChanged = isInterludeChanged; @@ -319,6 +387,15 @@ export class TimelineController { return this.diff; } + /** + * 设置持续性的跳转状态,例如用户正按住进度条拖拽 + * + * @remarks + * 此状态由外部持有,内部只做镜像,因此加载新歌词时不会被清除, + * 需要由调用方在拖拽结束时显式置回 false + * + * @param isSeeking 当前是否处于持续跳转状态 + */ public setSeekingState(isSeeking: boolean): void { this.isManualSeeking = isSeeking; this.snapshot.isSeeking = isSeeking; @@ -331,11 +408,12 @@ export class TimelineController { */ private performPlayback(time: MediaTime): void { // 我在这里定义了歌词的不同状态: - // 播放行:只要当前时间落在 [startTime, endTime) 内,就是在播放行,播放行是高亮行的真子集 + // 播放行:只要当前时间落在 [startTime, endTime) 内,就是在播放行,播放行是高亮行的子集 // 高亮行:UI 层真正看到的高亮状态 // - // 一行歌词播放完毕后,会立刻退出播放状态,但可以继续高亮,用于多行高亮时保留播放完的歌词继续高亮, - // 直到所有高亮行全部播放完毕后全部退出高亮 + // 一行歌词播放完毕后会立刻退出播放状态,但会继续保持高亮,直到下一行开始播放才熄灭 + // 这样多行重叠时先唱完的行会陪着后面的行一起亮,句间空隙内上一行也不会提前变暗 + // 空隙长到构成间奏、以及歌曲已经播完这两种没有下一行接续的情况,由 sync 统一清空 // 清理不再播放的行 for (const lastPlayingId of this.playingGroupsSet) { @@ -369,18 +447,10 @@ export class TimelineController { } // 更新 this.playbackCursor 指针 - if (this.playingGroupsSet.size > 0) { - let minPlaying = Number.POSITIVE_INFINITY; - for (const id of this.playingGroupsSet) { - if (id < minPlaying) minPlaying = id; - } - this.playbackCursor = minPlaying; - } else { - this.playbackCursor = cursor; - } + this.playbackCursor = cursor; // 找出那些已经唱完但仍处于高亮状态的歌词行 - // 稍后会结合下一行的开启来决定什么时候熄灭高亮 + // 它们会保持高亮,直到有新歌词开始播放才被一起熄灭 this.expiredHighlightedIds.length = 0; for (const id of this.highlightedGroupsSet) { if (!this.playingGroupsSet.has(id)) { @@ -388,49 +458,34 @@ export class TimelineController { } } - // 只要有新歌词开始播放,将其存入 highlightedGroupsSet,并向 addedHighlightedIds 压入 Diff - // UI 将会启用这些歌词 const addedPlayingCount = this.addedPlayingIds.length; const expiredCount = this.expiredHighlightedIds.length; - if (addedPlayingCount > 0) { - for (let i = 0; i < addedPlayingCount; i++) { - const id = this.addedPlayingIds[i]; - this.highlightedGroupsSet.add(id); - this.addedHighlightedIds.push(id); - } + // 只要有新歌词开始播放,将其存入 highlightedGroupsSet,并向 addedHighlightedIds 压入 Diff + // UI 将会启用这些歌词 + for (let i = 0; i < addedPlayingCount; i++) { + const id = this.addedPlayingIds[i]; + this.highlightedGroupsSet.add(id); + this.addedHighlightedIds.push(id); } - // 定义两个应该清理旧高亮歌词的充分条件,满足其一即可: - // 1. 有新歌词进入播放状态 - // 2. 当前处于高亮状态的歌词全部播放完了 + // 清理旧高亮的唯一条件是「有新歌词进入播放状态」 // - // 注意,expiredCount > 0 不作为清理条件,这是为了在多行高亮时,让播放完毕的行保持高亮状态 - const shouldTransitionToNext = addedPlayingCount > 0; - - const isCurrentGroupAllFinished = - expiredCount > 0 && expiredCount === this.highlightedGroupsSet.size; - - const shouldFlushExpiredLines = - shouldTransitionToNext || isCurrentGroupAllFinished; - - if (shouldFlushExpiredLines && expiredCount > 0) { + // 注意 expiredCount > 0 不作为清理条件,一行歌词唱完时若没有新行接续, + // 它会继续保持高亮,这样多行高亮时先唱完的行和句间空隙的上一行不会失去可读性 + if (addedPlayingCount > 0) { for (let i = 0; i < expiredCount; i++) { const id = this.expiredHighlightedIds[i]; this.highlightedGroupsSet.delete(id); this.removedHighlightedIds.push(id); } - } - // 不更新 scrollToIndex,以便在播放完毕后保持聚焦在这行歌词 - if ( - (addedPlayingCount > 0 || shouldFlushExpiredLines) && - this.highlightedGroupsSet.size > 0 - ) { let minHighlighted = Number.POSITIVE_INFINITY; for (const id of this.highlightedGroupsSet) { if (id < minHighlighted) minHighlighted = id; } + + // 只在歌词更替时更新,以便在播放完毕后保持聚焦在这一组歌词 this.snapshot.scrollToIndex = minHighlighted; } } @@ -438,77 +493,161 @@ export class TimelineController { /** * 处理 Seek 时的时间线推导 * - * 将会丢弃所有高亮状态的行,直接根据当前时间重新计算播放状态的行 + * 直接按目标时间重建播放与高亮行集合,结果与正常播放到该时刻时一致 + * + * @param time 跳转到的时间 + * @param dropLingeringWhenIdle 在没有任何行正在播放时,是否丢弃那些已经唱完、 + * 但在正常播放中仍会保持高亮的行,用于在间奏和播放完时清空高亮行 */ - private performSeek(time: MediaTime): void { - for (const id of this.playingGroupsSet) { - this.removedPlayingIds.push(id); - } - for (const id of this.highlightedGroupsSet) { - this.removedHighlightedIds.push(id); - } - - this.playingGroupsSet.clear(); - this.highlightedGroupsSet.clear(); + private performSeek(time: MediaTime, dropLingeringWhenIdle: boolean): void { + const nextPlaying = this.nextPlayingSet; + const nextHighlighted = this.nextHighlightedSet; + nextPlaying.clear(); + nextHighlighted.clear(); + // 二分法找出第一个开始时间晚于目标时间的歌词行,排除所有尚未开始唱的歌词 let left = 0; let right = this.lyricBounds.length - 1; - let firstGreaterOrEqual = this.lyricBounds.length; + let firstGreater = this.lyricBounds.length; while (left <= right) { const mid = (left + right) >> 1; - if (this.lyricBounds[mid].startTime >= time) { - firstGreaterOrEqual = mid; + if (this.lyricBounds[mid].startTime > time) { + firstGreater = mid; right = mid - 1; } else { left = mid + 1; } } - let minPlayingIndex = Number.POSITIVE_INFINITY; - const startIndex = Math.min( - firstGreaterOrEqual, - this.lyricBounds.length - 1, - ); + // 往前找到最后一个真正开始播放过的歌词行作为锚点 + let anchorIndex = -1; + for (let i = firstGreater - 1; i >= 0; i--) { + const bound = this.lyricBounds[i]; + // 有意跳过时长为 0 的歌词行 + if (bound.endTime > bound.startTime) { + anchorIndex = i; + break; + } + } - for (let i = startIndex; i >= 0; i--) { + if (anchorIndex === -1) { + // 目标时间在第一行歌词开始之前,或此前所有已开始的行都是零时长 + // 正常播放时尚未有任何歌词行进入播放状态,scrollToIndex 保持为 0 + this.playbackCursor = firstGreater; + this.snapshot.scrollToIndex = 0; + this.commitSeekDiff(); + return; + } + + // 还原「歌词唱完不熄灭,直到下一句开始才更替」的状态 + // 锚点之后的歌词行要么开始时间晚于目标时间、要么时长为零,都不可能命中,无需遍历 + const anchorStart = this.lyricBounds[anchorIndex].startTime; + let minPlaying = -1; + let minHighlighted = -1; + + for (let i = anchorIndex; i >= 0; i--) { const bound = this.lyricBounds[i]; - if (bound && bound.startTime <= time && bound.endTime > time) { - this.playingGroupsSet.add(i); - this.highlightedGroupsSet.add(i); - this.addedPlayingIds.push(i); - this.addedHighlightedIds.push(i); - if (i < minPlayingIndex) minPlayingIndex = i; + + // 锚点时刻尚未唱完的行,在正常播放中会一直亮到下一行开始 + // 正在播放的行必然满足此条件,因为其结束时间晚于目标时间,而目标时间不早于锚点 + if (bound.endTime <= anchorStart) continue; + + if (bound.startTime <= time && bound.endTime > time) { + nextPlaying.add(i); + minPlaying = i; } + + nextHighlighted.add(i); + minHighlighted = i; } - if (this.highlightedGroupsSet.size > 0) { - // 聚焦到命中的第一行歌词 - this.snapshot.scrollToIndex = minPlayingIndex; - this.playbackCursor = minPlayingIndex; - } else { - // 如果跳转到了两行歌词间隔里 (不是间奏),聚焦到即将播放的下一行歌词 - this.snapshot.scrollToIndex = firstGreaterOrEqual; - this.playbackCursor = firstGreaterOrEqual; + // 间奏与曲末清空高亮行,如果没有行在播放的话 + if (dropLingeringWhenIdle && nextPlaying.size === 0) { + nextHighlighted.clear(); + } + + this.playbackCursor = minPlaying === -1 ? firstGreater : minPlaying; + this.snapshot.scrollToIndex = minHighlighted; + + this.commitSeekDiff(); + } + + /** + * 把 Seek 重建出的目标集合与上一帧的集合求对称差,输出发生变化的部分 + */ + private commitSeekDiff(): void { + for (const id of this.playingGroupsSet) { + if (!this.nextPlayingSet.has(id)) { + this.playingGroupsSet.delete(id); + this.removedPlayingIds.push(id); + } + } + for (const id of this.nextPlayingSet) { + if (!this.playingGroupsSet.has(id)) { + this.playingGroupsSet.add(id); + this.addedPlayingIds.push(id); + } + } + + for (const id of this.highlightedGroupsSet) { + if (!this.nextHighlightedSet.has(id)) { + this.highlightedGroupsSet.delete(id); + this.removedHighlightedIds.push(id); + } + } + for (const id of this.nextHighlightedSet) { + if (!this.highlightedGroupsSet.has(id)) { + this.highlightedGroupsSet.add(id); + this.addedHighlightedIds.push(id); + } } } + + /** + * 立即熄灭当前全部高亮歌词行 + * + * 用于间奏与曲末这两个没有下一行接续、但必须清空高亮的场景 + */ + private flushAllHighlighted(): void { + if (this.highlightedGroupsSet.size === 0) return; + + for (const id of this.highlightedGroupsSet) { + this.removedHighlightedIds.push(id); + } + this.highlightedGroupsSet.clear(); + } //#endregion //#region 间奏计算 - private calculateInterludes(bounds: TimeBounds[]): PlayerInterlude[] { + /** + * 预计算全部间奏区间 + * @param bounds 按 `startTime` 升序排列的歌词时间边界 + * @returns 按时间升序排列、互不重叠的间奏区间,供二分查找与游标推进使用 + */ + private calculateInterludes( + bounds: readonly TimeBounds[], + ): PlayerInterlude[] { const interludes: PlayerInterlude[] = []; - const minGap = Duration.fromMillis(4000); + // 已扫描过的歌词行中最晚的结束时间 + let maxEnd = MediaTime.ZERO; + + // 歌词行只保证按 `startTime` 升序,前一行的 `endTime` 并不等于此前 + // 所有行的最晚结束时间 (例如多行高亮的情况) + // + // 所以这里按前缀最大结束时间做一次区间并集扫描,保证产出的区间与 + // 任何歌词行都不重叠 for (let i = -1; i < bounds.length - 1; i++) { - const prevGroup = i === -1 ? null : bounds[i]; - const nextGroup = bounds[i + 1]; + if (i >= 0) { + maxEnd = MediaTime.max(maxEnd, bounds[i].endTime); + } - const gapStart = prevGroup ? prevGroup.endTime : MediaTime.ZERO; - const gapEnd = MediaTime.max(gapStart, nextGroup.startTime); + const gapEnd = MediaTime.max(maxEnd, bounds[i + 1].startTime); - if (MediaTime.since(gapEnd, gapStart) >= minGap) { + if (MediaTime.since(gapEnd, maxEnd) >= MIN_INTERLUDE_GAP) { interludes.push({ - startTime: gapStart, + startTime: maxEnd, endTime: gapEnd, anchorLineIndex: i, }); @@ -518,78 +657,92 @@ export class TimelineController { return interludes; } - private updateInterludeState(time: MediaTime, isSeek: boolean): void { - let activeInterlude: PlayerInterlude | undefined; - - if (this.precalculatedInterludes.length > 0) { - if (isSeek) { - let cursor = this.precalculatedInterludes.length; - let left = 0; - let right = this.precalculatedInterludes.length - 1; - - while (left <= right) { - const mid = (left + right) >> 1; - const inter = this.precalculatedInterludes[mid]; - - if (inter.endTime > time) { - cursor = mid; - right = mid - 1; - } else { - left = mid + 1; - } + /** + * 查找当前时间命中的间奏区间,并顺带推进或重定位间奏游标 + * @param time 当前播放时间 + * @param isSeek 当前帧是否为跳转 + * @returns 命中的间奏区间,未命中时为 undefined + */ + private resolveActiveInterlude( + time: MediaTime, + isSeek: boolean, + ): PlayerInterlude | undefined { + if (this.precalculatedInterludes.length === 0) return undefined; + + if (isSeek) { + let cursor = this.precalculatedInterludes.length; + let left = 0; + let right = this.precalculatedInterludes.length - 1; + + while (left <= right) { + const mid = (left + right) >> 1; + const inter = this.precalculatedInterludes[mid]; + + if (inter.endTime > time) { + cursor = mid; + right = mid - 1; + } else { + left = mid + 1; } + } - this.interludeCursor = cursor; + this.interludeCursor = cursor; - if (cursor < this.precalculatedInterludes.length) { - const inter = this.precalculatedInterludes[cursor]; - if (time >= inter.startTime && time < inter.endTime) { - activeInterlude = inter; - } - } - } else { - while (this.interludeCursor < this.precalculatedInterludes.length) { - const inter = this.precalculatedInterludes[this.interludeCursor]; - if (time >= inter.startTime && time < inter.endTime) { - activeInterlude = inter; - break; - } else if (time >= inter.endTime) { - this.interludeCursor++; - } else { - break; - } + if (cursor < this.precalculatedInterludes.length) { + const inter = this.precalculatedInterludes[cursor]; + if (time >= inter.startTime && time < inter.endTime) { + return inter; } } - } - this.snapshot.activeInterlude = activeInterlude; + return undefined; + } - if (activeInterlude && this.highlightedGroupsSet.size === 0) { - // 处于间奏区域且未高亮任何歌词时,聚焦在间奏区域 - this.isFocusOnInterludeState = true; - } else if (this.highlightedGroupsSet.size > 0 || !activeInterlude) { - // 有新歌词高亮或离开间奏区域时,解除聚焦 - this.isFocusOnInterludeState = false; + while (this.interludeCursor < this.precalculatedInterludes.length) { + const inter = this.precalculatedInterludes[this.interludeCursor]; + if (time >= inter.startTime && time < inter.endTime) { + return inter; + } + if (time >= inter.endTime) { + this.interludeCursor++; + } else { + break; + } } - this.snapshot.isFocusOnInterlude = this.isFocusOnInterludeState; + return undefined; + } + + /** + * 根据间奏命中情况和当前高亮状态推导是否应当聚焦间奏点 + * + * 处于间奏区域且没有任何歌词高亮时聚焦间奏点,否则交还给歌词行 + * + * @param activeInterlude 当前命中的间奏区间 + */ + private updateInterludeFocus(activeInterlude?: PlayerInterlude): void { + this.snapshot.isFocusOnInterlude = + !!activeInterlude && this.highlightedGroupsSet.size === 0; } //#endregion //#region 重置 + /** + * 清空全部推导状态,回到时间原点 + */ private reset(): void { this.playbackCursor = 0; this.interludeCursor = 0; - this.isFocusOnInterludeState = false; this.playingGroupsSet.clear(); this.highlightedGroupsSet.clear(); + this.nextPlayingSet.clear(); + this.nextHighlightedSet.clear(); this.snapshot.currentTime = MediaTime.ZERO; this.snapshot.isSeeking = false; this.snapshot.scrollToIndex = 0; this.snapshot.latestHighlightedIndex = undefined; - this.snapshot.isTimelineEmpty = true; this.snapshot.isEndOfSong = false; this.snapshot.activeInterlude = undefined; this.snapshot.isFocusOnInterlude = false; diff --git a/packages/core/test/focus-controller.test.ts b/packages/core/test/focus-controller.test.ts index 2f1fafc75b..1dc8678cdf 100644 --- a/packages/core/test/focus-controller.test.ts +++ b/packages/core/test/focus-controller.test.ts @@ -13,7 +13,6 @@ function makeSnapshot(over: Partial = {}): TimelineSnapshot { playingGroups: new Set(), highlightedGroups: new Set(), scrollToIndex: 0, - isTimelineEmpty: false, isEndOfSong: false, isFocusOnInterlude: false, ...over, diff --git a/packages/core/test/timeline-controller.test.ts b/packages/core/test/timeline-controller.test.ts new file mode 100644 index 0000000000..d7bd9f8083 --- /dev/null +++ b/packages/core/test/timeline-controller.test.ts @@ -0,0 +1,828 @@ +import { describe, expect, it } from "vitest"; +import type { TimeBounds } from "#lyric/base/timeline.ts"; +import { TimelineController } from "#lyric/base/timeline.ts"; +import { MediaTime } from "#utils/time.ts"; + +function bounds(...ranges: [number, number][]): TimeBounds[] { + return ranges.map(([startTime, endTime]) => ({ + startTime: MediaTime.fromMillis(startTime), + endTime: MediaTime.fromMillis(endTime), + })); +} + +function makeController(...ranges: [number, number][]): TimelineController { + const controller = new TimelineController(); + controller.setTimeBounds(bounds(...ranges)); + return controller; +} + +function tick(controller: TimelineController, ms: number, forceSeek = false) { + return controller.sync(MediaTime.fromMillis(ms), forceSeek); +} + +function highlighted(controller: TimelineController): number[] { + return [...controller.getSnapshot().highlightedGroups].sort((a, b) => a - b); +} + +function playing(controller: TimelineController): number[] { + return [...controller.getSnapshot().playingGroups].sort((a, b) => a - b); +} + +function millis(time: MediaTime | undefined): number | undefined { + return time === undefined ? undefined : MediaTime.asMillis(time); +} + +describe("TimelineController highlight lifecycle", () => { + it("highlights lyrics when time is within range", () => { + const c = makeController([0, 1000], [1000, 2000]); + + tick(c, 500); + expect(highlighted(c)).toEqual([0]); + expect(playing(c)).toEqual([0]); + + tick(c, 1000); + expect(highlighted(c)).toEqual([1]); + expect(playing(c)).toEqual([1]); + }); + + it("keeps finished line highlighted until next line starts", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + expect(highlighted(c)).toEqual([0]); + + tick(c, 1000); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0]); + + tick(c, 2500); + expect(highlighted(c)).toEqual([0]); + + const diff = tick(c, 3000); + expect(highlighted(c)).toEqual([1]); + expect([...diff.removedHighlighted]).toEqual([0]); + expect([...diff.addedHighlighted]).toEqual([1]); + }); + + it("keeps focus on the last lit group during gaps without jumping ahead to next line", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + expect(c.getSnapshot().scrollToIndex).toBe(0); + + tick(c, 2500); + expect(c.getSnapshot().scrollToIndex).toBe(0); + + tick(c, 3000); + expect(c.getSnapshot().scrollToIndex).toBe(1); + }); + + it("retains finished overlapping lines until next line appears, extinguishing them together", () => { + const c = makeController( + [153305, 157180], + [154743, 156466], + [156154, 160218], + [160268, 162974], + ); + + tick(c, 153305); + expect(highlighted(c)).toEqual([0]); + + tick(c, 154743); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 156154); + expect(highlighted(c)).toEqual([0, 1, 2]); + + tick(c, 156466); + expect(playing(c)).toEqual([0, 2]); + expect(highlighted(c)).toEqual([0, 1, 2]); + + tick(c, 157180); + expect(playing(c)).toEqual([2]); + expect(highlighted(c)).toEqual([0, 1, 2]); + + tick(c, 160218); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0, 1, 2]); + + const diff = tick(c, 160268); + expect(highlighted(c)).toEqual([3]); + expect([...diff.removedHighlighted].sort((a, b) => a - b)).toEqual([ + 0, 1, 2, + ]); + }); + + it("keeps overlapping lines highlighted until next line starts when subsequent line ends later", () => { + const c = makeController([86358, 90910], [89693, 90968], [90978, 92366]); + + tick(c, 86358); + expect(highlighted(c)).toEqual([0]); + + tick(c, 89693); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 90910); + expect(playing(c)).toEqual([1]); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 90968); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 90978); + expect(highlighted(c)).toEqual([2]); + }); +}); + +describe("TimelineController interludes", () => { + it("clears all highlights and focuses on interlude point when entering interlude range", () => { + const c = makeController([0, 1000], [6000, 7000]); + + tick(c, 999); + expect(highlighted(c)).toEqual([0]); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + + const diff = tick(c, 1000); + expect(highlighted(c)).toEqual([]); + expect([...diff.removedHighlighted]).toEqual([0]); + expect([...diff.addedHighlighted]).toEqual([]); + + const snapshot = c.getSnapshot(); + expect(snapshot.activeInterlude?.anchorLineIndex).toBe(0); + expect(snapshot.isFocusOnInterlude).toBe(true); + expect(snapshot.latestHighlightedIndex).toBeUndefined(); + }); + + it("calculates interlude start time from the latest end time of all preceding lines", () => { + const c = makeController([0, 10000], [1000, 2000], [15000, 16000]); + + tick(c, 1000); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 5000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(playing(c)).toEqual([0]); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + + const diff = tick(c, 10000); + const interlude = c.getSnapshot().activeInterlude; + expect(interlude).toBeDefined(); + expect(millis(interlude?.startTime)).toBe(10000); + expect(millis(interlude?.endTime)).toBe(15000); + expect(interlude?.anchorLineIndex).toBe(1); + expect(highlighted(c)).toEqual([]); + expect([...diff.removedHighlighted].sort((a, b) => a - b)).toEqual([0, 1]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + + tick(c, 15000); + expect(highlighted(c)).toEqual([2]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + }); + + it("does not produce interlude when overlapping lines fill the gap", () => { + const c = makeController([0, 10000], [1000, 2000], [11000, 12000]); + + tick(c, 1500); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 3000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(playing(c)).toEqual([0]); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 10000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + + tick(c, 11000); + expect(highlighted(c)).toEqual([2]); + }); + + it("does not trigger interlude early when preceding enclosing line is still playing", () => { + const c = makeController([0, 60000], [10000, 12000], [65000, 70000]); + + tick(c, 10000); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 30000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + + tick(c, 59999); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + + tick(c, 60000); + const interlude = c.getSnapshot().activeInterlude; + expect(millis(interlude?.startTime)).toBe(60000); + expect(millis(interlude?.endTime)).toBe(65000); + expect(interlude?.anchorLineIndex).toBe(1); + expect(highlighted(c)).toEqual([]); + }); +}); + +describe("TimelineController end of song handling", () => { + it("clears highlights and sets isEndOfSong when passing all lyrics", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 3500); + expect(highlighted(c)).toEqual([1]); + expect(c.getSnapshot().isEndOfSong).toBe(false); + + const diff = tick(c, 4000); + expect(highlighted(c)).toEqual([]); + expect([...diff.removedHighlighted]).toEqual([1]); + expect(c.getSnapshot().isEndOfSong).toBe(true); + expect(c.getSnapshot().latestHighlightedIndex).toBeUndefined(); + }); + + it("determines end of song based on global latest end time for nested overlapping lines", () => { + const c = makeController([5000, 20000], [6000, 7000]); + + tick(c, 6500); + expect(highlighted(c)).toEqual([0, 1]); + + tick(c, 7000); + expect(playing(c)).toEqual([0]); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().isEndOfSong).toBe(false); + + tick(c, 19999); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().isEndOfSong).toBe(false); + + tick(c, 20000); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isEndOfSong).toBe(true); + }); + + it("does not flag end of song when there are no lyrics", () => { + const c = makeController(); + + tick(c, 10000); + expect(c.getSnapshot().isEndOfSong).toBe(false); + }); + + it("reports hasChanged on end-of-song transition frame even if no lines were ever highlighted", () => { + const c = makeController([1000, 1000], [2000, 2000]); + + tick(c, 1000); + expect(c.getSnapshot().isEndOfSong).toBe(false); + + const diff = tick(c, 2000); + expect(c.getSnapshot().isEndOfSong).toBe(true); + expect(diff.hasChanged).toBe(true); + }); +}); + +describe("TimelineController seek", () => { + it("only highlights matched line when seeking into a lyric range", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 3500, true); + expect(playing(c)).toEqual([1]); + expect(highlighted(c)).toEqual([1]); + expect(c.getSnapshot().scrollToIndex).toBe(1); + }); + + it("backfills previous line and focuses it when seeking into a normal gap", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 2000, true); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + expect(c.getSnapshot().isEndOfSong).toBe(false); + }); + + it("backfills entire group of overlapping lyrics when seeking into a normal gap", () => { + const c = makeController([0, 5000], [1000, 4500], [6000, 7000]); + + tick(c, 5500, true); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("matches normal playback result when backfilling highlight in a gap", () => { + const ranges: [number, number][] = [ + [0, 5000], + [1000, 4500], + [6000, 7000], + ]; + + const played = makeController(...ranges); + tick(played, 1000); + tick(played, 4500); + tick(played, 5000); + tick(played, 5500); + + const seeked = makeController(...ranges); + tick(seeked, 5500, true); + + expect(highlighted(seeked)).toEqual(highlighted(played)); + expect(seeked.getSnapshot().scrollToIndex).toBe( + played.getSnapshot().scrollToIndex, + ); + }); + + it("preserves finished lines from the same group when seeking into a still playing line", () => { + const ranges: [number, number][] = [ + [0, 30000], + [1000, 2000], + [35000, 36000], + ]; + + const played = makeController(...ranges); + tick(played, 1000); + tick(played, 2000); + tick(played, 20000); + expect(highlighted(played)).toEqual([0, 1]); + + const seeked = makeController(...ranges); + tick(seeked, 20000, true); + + expect(highlighted(seeked)).toEqual([0, 1]); + expect(playing(seeked)).toEqual([0]); + expect(seeked.getSnapshot().scrollToIndex).toBe( + played.getSnapshot().scrollToIndex, + ); + }); + + it("does not backfill zero-duration lyric lines", () => { + const ranges: [number, number][] = [ + [10000, 12000], + [12000, 12000], + [15000, 16000], + ]; + + const played = makeController(...ranges); + tick(played, 10000); + tick(played, 13000); + expect(highlighted(played)).toEqual([0]); + + const seeked = makeController(...ranges); + tick(seeked, 13000, true); + expect(highlighted(seeked)).toEqual([0]); + }); + + it("matches all lines sharing the same start time when seeking", () => { + const c = makeController([1000, 5000], [1000, 3000], [8000, 9000]); + + tick(c, 1000, true); + expect(playing(c)).toEqual([0, 1]); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("does not backfill and focuses on interlude point when seeking into interlude range", () => { + const c = makeController([0, 1000], [6000, 7000]); + + tick(c, 3000, true); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().activeInterlude?.anchorLineIndex).toBe(0); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("keeps scrollToIndex consistent with normal playback when seeking into interlude", () => { + const ranges: [number, number][] = [ + [0, 1000], + [6000, 7000], + ]; + + const played = makeController(...ranges); + tick(played, 500); + tick(played, 3000); + expect(played.getSnapshot().scrollToIndex).toBe(0); + + const seeked = makeController(...ranges); + tick(seeked, 3000, true); + expect(seeked.getSnapshot().scrollToIndex).toBe(0); + expect(seeked.getSnapshot().isFocusOnInterlude).toBe(true); + expect(highlighted(seeked)).toEqual([]); + }); + + it("keeps scrollToIndex at 0 when seeking into leading silence interlude", () => { + const c = makeController([5000, 6000]); + + tick(c, 1000, true); + expect(c.getSnapshot().activeInterlude?.anchorLineIndex).toBe(-1); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("does not backfill and marks end of song when seeking past the end", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 5000, true); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isEndOfSong).toBe(true); + }); + + it("does not highlight anything when seeking before first line", () => { + const c = makeController([1000, 2000], [3000, 4000]); + + tick(c, 500, true); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("transitions to next line normally when playback resumes after seek backfill", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 2000, true); + expect(highlighted(c)).toEqual([0]); + + const diff = tick(c, 3000); + expect(highlighted(c)).toEqual([1]); + expect([...diff.removedHighlighted]).toEqual([0]); + expect(c.getSnapshot().scrollToIndex).toBe(1); + }); + + it("treats time regression as a seek jump", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 3500); + expect(highlighted(c)).toEqual([1]); + + const diff = tick(c, 500); + expect(diff.isTimeJumped).toBe(true); + expect(highlighted(c)).toEqual([0]); + }); +}); + +describe("TimelineController seek diff", () => { + it("produces true diffs where lines continuing to play across seeks are not re-added", () => { + const c = makeController([0, 10000], [1000, 2000], [15000, 16000]); + + tick(c, 1500); + expect(playing(c)).toEqual([0, 1]); + + const diff = tick(c, 5000, true); + expect(playing(c)).toEqual([0]); + expect(highlighted(c)).toEqual([0, 1]); + + expect([...diff.addedPlaying]).toEqual([]); + expect([...diff.removedPlaying]).toEqual([1]); + expect([...diff.addedHighlighted]).toEqual([]); + expect([...diff.removedHighlighted]).toEqual([]); + }); + + it("produces no diff on unchanged frames during continuous seeking", () => { + const c = makeController([0, 1000], [3000, 8000]); + + c.setSeekingState(true); + + const first = tick(c, 4000); + expect([...first.addedHighlighted]).toEqual([1]); + expect(first.hasChanged).toBe(true); + + for (const ms of [4500, 5000, 5500]) { + const diff = tick(c, ms); + expect([...diff.addedPlaying]).toEqual([]); + expect([...diff.removedPlaying]).toEqual([]); + expect([...diff.addedHighlighted]).toEqual([]); + expect([...diff.removedHighlighted]).toEqual([]); + expect(diff.hasChanged).toBe(true); + } + + expect(playing(c)).toEqual([1]); + expect(highlighted(c)).toEqual([1]); + }); + + it("does not both remove and re-add the same line within the same frame", () => { + const c = makeController([0, 5000], [1000, 4500], [6000, 7000]); + + tick(c, 2000); + expect(highlighted(c)).toEqual([0, 1]); + + const diff = tick(c, 3000, true); + const removed = new Set(diff.removedHighlighted); + for (const id of diff.addedHighlighted) { + expect(removed.has(id)).toBe(false); + } + expect(highlighted(c)).toEqual([0, 1]); + }); + + it("does not cause scrollToIndex to go out of bounds when seeking past the last line", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 5000, true); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isEndOfSong).toBe(true); + expect(c.getSnapshot().scrollToIndex).toBe(1); + }); + + it("does not cause scrollToIndex to go out of bounds when all lines have zero duration", () => { + const c = makeController([1000, 1000], [2000, 2000]); + + tick(c, 3000, true); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); + + it("keeps scrollToIndex consistent between seek and normal playback with leading zero-duration lines", () => { + const ranges: [number, number][] = [ + [1000, 1000], + [3000, 4000], + ]; + + const played = makeController(...ranges); + tick(played, 0); + tick(played, 1000); + tick(played, 2000); + expect(played.getSnapshot().scrollToIndex).toBe(0); + expect(highlighted(played)).toEqual([]); + + const seeked = makeController(...ranges); + tick(seeked, 2000, true); + expect(seeked.getSnapshot().scrollToIndex).toBe(0); + expect(highlighted(seeked)).toEqual([]); + }); +}); + +describe("TimelineController diff flags", () => { + it("produces no diff when progressing within the same line", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + const diff = tick(c, 800); + expect(diff.hasChanged).toBe(false); + expect(diff.isTimeJumped).toBe(false); + expect(diff.isScrollToChanged).toBe(false); + expect(diff.isInterludeChanged).toBe(false); + expect([...diff.addedPlaying]).toEqual([]); + expect([...diff.removedPlaying]).toEqual([]); + expect([...diff.addedHighlighted]).toEqual([]); + expect([...diff.removedHighlighted]).toEqual([]); + }); + + it("reports transition diffs for playing and highlighted lines on line change", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + const diff = tick(c, 3000); + expect([...diff.addedPlaying]).toEqual([1]); + expect([...diff.removedPlaying]).toEqual([0]); + expect([...diff.addedHighlighted]).toEqual([1]); + expect([...diff.removedHighlighted]).toEqual([0]); + expect(diff.isScrollToChanged).toBe(true); + expect(diff.hasChanged).toBe(true); + }); + + it("only sets isScrollToChanged to true on the exact frame the line changes", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + expect(tick(c, 1000).isScrollToChanged).toBe(false); + expect(tick(c, 3000).isScrollToChanged).toBe(true); + expect(tick(c, 3500).isScrollToChanged).toBe(false); + }); + + it("reflects interlude state transitions in isInterludeChanged", () => { + const c = makeController([0, 1000], [6000, 7000]); + + tick(c, 500); + expect(tick(c, 1000).isInterludeChanged).toBe(true); + expect(tick(c, 3000).isInterludeChanged).toBe(false); + expect(tick(c, 6000).isInterludeChanged).toBe(true); + }); + + it("sets isTimeJumped and isSeeking on explicit seek, resetting on the next frame", () => { + const c = makeController([0, 1000], [3000, 4000]); + + tick(c, 500); + const diff = tick(c, 3500, 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); + }); +}); + +describe("TimelineController interlude boundaries and multiple interludes", () => { + it("forms an interlude when gap reaches exactly 4s", () => { + const c = makeController([0, 1000], [5000, 6000]); + + tick(c, 1000); + const interlude = c.getSnapshot().activeInterlude; + expect(millis(interlude?.startTime)).toBe(1000); + expect(millis(interlude?.endTime)).toBe(5000); + expect(interlude?.anchorLineIndex).toBe(0); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + }); + + it("does not form an interlude when gap is 1ms short of 4s, keeping previous line highlighted", () => { + const c = makeController([0, 1000], [4999, 5999]); + + tick(c, 500); + expect(highlighted(c)).toEqual([0]); + + tick(c, 1000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + + tick(c, 3000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(highlighted(c)).toEqual([0]); + }); + + it("produces an interlude for leading silence over 4s with anchor before first line", () => { + const c = makeController([5000, 6000]); + + tick(c, 1000); + const interlude = c.getSnapshot().activeInterlude; + expect(interlude).toBeDefined(); + expect(interlude?.anchorLineIndex).toBe(-1); + expect(millis(interlude?.startTime)).toBe(0); + expect(millis(interlude?.endTime)).toBe(5000); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + expect(c.getSnapshot().latestHighlightedIndex).toBeUndefined(); + + tick(c, 5000); + expect(highlighted(c)).toEqual([0]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + }); + + it("hits multiple interludes sequentially and transitions properly", () => { + const c = makeController([0, 1000], [6000, 7000], [13000, 14000]); + + tick(c, 500); + expect(tick(c, 1000).isInterludeChanged).toBe(true); + + tick(c, 3000); + expect(c.getSnapshot().activeInterlude?.anchorLineIndex).toBe(0); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + expect(highlighted(c)).toEqual([]); + + expect(tick(c, 6000).isInterludeChanged).toBe(true); + expect(highlighted(c)).toEqual([1]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + + const diff = tick(c, 7000); + expect(diff.isInterludeChanged).toBe(true); + expect(c.getSnapshot().activeInterlude?.anchorLineIndex).toBe(1); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + + tick(c, 10000); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + + tick(c, 13000); + expect(highlighted(c)).toEqual([2]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + }); + + it("resumes lyrics when playback reaches the end of an interlude entered via seek", () => { + const c = makeController([0, 1000], [6000, 7000]); + + tick(c, 3000, true); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + expect(highlighted(c)).toEqual([]); + + tick(c, 6000); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(highlighted(c)).toEqual([1]); + expect(c.getSnapshot().isFocusOnInterlude).toBe(false); + }); +}); + +describe("TimelineController snapshot details", () => { + it("always points latestHighlightedIndex to the maximum index of highlighted lines", () => { + const c = makeController([0, 5000], [1000, 4500], [6000, 7000]); + + tick(c, 500); + expect(c.getSnapshot().latestHighlightedIndex).toBe(0); + + tick(c, 1500); + expect(c.getSnapshot().latestHighlightedIndex).toBe(1); + + tick(c, 4500); + expect(c.getSnapshot().latestHighlightedIndex).toBe(1); + + tick(c, 6000); + expect(c.getSnapshot().latestHighlightedIndex).toBe(2); + }); + + it("keeps latestHighlightedIndex undefined before the first line starts", () => { + const c = makeController([1000, 2000], [3000, 4000]); + + tick(c, 500); + expect(highlighted(c)).toEqual([]); + 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(); + + tick(c, 500); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + expect(c.getSnapshot().isEndOfSong).toBe(false); + expect(c.getSnapshot().latestHighlightedIndex).toBeUndefined(); + + const diff = tick(c, 1000); + expect(diff.hasChanged).toBe(false); + }); +}); + +describe("TimelineController zero-duration and time boundaries", () => { + it("does not highlight zero-duration lines or block subsequent lines during normal playback", () => { + const c = makeController([1000, 2000], [2000, 2000], [3000, 4000]); + + tick(c, 1000); + expect(playing(c)).toEqual([0]); + expect(highlighted(c)).toEqual([0]); + + tick(c, 2000); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([0]); + + tick(c, 3000); + expect(highlighted(c)).toEqual([2]); + }); + + it("exits playback exactly at endTime and enters playback exactly at startTime", () => { + const c = makeController([0, 1000], [1000, 2000]); + + tick(c, 999); + expect(playing(c)).toEqual([0]); + + tick(c, 1000); + expect(playing(c)).toEqual([1]); + expect(highlighted(c)).toEqual([1]); + + tick(c, 1999); + expect(playing(c)).toEqual([1]); + + tick(c, 2000); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([]); + expect(c.getSnapshot().isEndOfSong).toBe(true); + }); + + it("enters playback simultaneously for multiple lines sharing the same start time", () => { + const c = makeController([1000, 5000], [1000, 3000], [8000, 9000]); + + tick(c, 1000); + expect(playing(c)).toEqual([0, 1]); + expect(highlighted(c)).toEqual([0, 1]); + expect(c.getSnapshot().scrollToIndex).toBe(0); + }); +}); + +describe("TimelineController lyrics reload", () => { + it("resets all timeline states to origin when time bounds are reset", () => { + const c = makeController([0, 1000], [6000, 7000]); + tick(c, 3000); + expect(c.getSnapshot().activeInterlude).toBeDefined(); + expect(c.getSnapshot().isFocusOnInterlude).toBe(true); + + c.setTimeBounds(bounds([0, 1000], [2000, 3000])); + const s = c.getSnapshot(); + expect(millis(s.currentTime)).toBe(0); + expect(s.isEndOfSong).toBe(false); + expect(s.activeInterlude).toBeUndefined(); + expect(s.isFocusOnInterlude).toBe(false); + expect(s.latestHighlightedIndex).toBeUndefined(); + expect(s.scrollToIndex).toBe(0); + expect(playing(c)).toEqual([]); + expect(highlighted(c)).toEqual([]); + + tick(c, 500); + expect(highlighted(c)).toEqual([0]); + tick(c, 1500); + expect(c.getSnapshot().activeInterlude).toBeUndefined(); + expect(highlighted(c)).toEqual([0]); + expect(c.getSnapshot().isEndOfSong).toBe(false); + }); +});