添加跳过片头片尾功能

This commit is contained in:
truewhile
2026-09-23 10:22:13 +08:00
parent 016c6687e9
commit 94ef0116b1
26 changed files with 2115 additions and 4 deletions
+107
View File
@@ -0,0 +1,107 @@
import { resolveActiveSkip, skippedNoticeText, skipLabel, toSkipSegments } from './skipSegments.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`skipSegments: ${name}`)
}
function equal<T>(name: string, actual: T, expected: T) {
if (actual !== expected) {
throw new Error(`skipSegments: ${name} — got ${String(actual)}, want ${String(expected)}`)
}
}
// ── toSkipSegments ──────────────────────────────────────────────────────────
check('空片段返回空数组', toSkipSegments(null, 600).length === 0)
check('空数组返回空数组', toSkipSegments([], 600).length === 0)
// end_ms: 0 表示延续到片尾,用媒体时长补齐。
const resolved = toSkipSegments(
[
{ kind: 'intro', start_ms: 228_664, end_ms: 246_143 },
{ kind: 'credits', start_ms: 3_431_000, end_ms: 0 },
],
3600,
)
equal('区间数量', resolved.length, 2)
equal('片头起点(秒)', resolved[0].startSec, 228.664)
equal('片头终点(秒)', resolved[0].endSec, 246.143)
equal('片尾终点补齐为总时长', resolved[1].endSec, 3600)
// 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。
check(
'时长未知时丢弃开放区间',
toSkipSegments([{ kind: 'credits', start_ms: 1000, end_ms: 0 }], 0).length === 0,
)
// 无效区间(end <= start)没有任何可跳过内容,不能生成按钮。
check(
'丢弃空区间',
toSkipSegments(
[
{ kind: 'intro', start_ms: 5000, end_ms: 5000 },
{ kind: 'recap', start_ms: 9000, end_ms: 8000 },
],
600,
).length === 0,
)
// 服务端已按类型排序,但客户端仍要按实际时间排,避免同类型多段时顺序错乱。
const ordered = toSkipSegments(
[
{ kind: 'credits', start_ms: 900_000, end_ms: 960_000 },
{ kind: 'intro', start_ms: 100_000, end_ms: 200_000 },
],
3600,
)
equal('按起点排序', ordered[0].kind, 'intro')
// ── resolveActiveSkip ───────────────────────────────────────────────────────
const segments = toSkipSegments(
[
{ kind: 'intro', start_ms: 100_000, end_ms: 200_000 },
{ kind: 'credits', start_ms: 3_400_000, end_ms: 3_500_000 },
],
3600,
)
check('区间之前不提示', resolveActiveSkip(50, segments) === null)
check('区间之中提示', resolveActiveSkip(150, segments)?.kind === 'intro')
check('区间结束后不提示', resolveActiveSkip(250, segments) === null)
check('起点即提示', resolveActiveSkip(100, segments)?.kind === 'intro')
check('终点视为已离开(右开区间)', resolveActiveSkip(200, segments) === null)
check('容差内提前提示', resolveActiveSkip(99.9, segments)?.kind === 'intro')
check('非有限值不提示', resolveActiveSkip(Number.NaN, segments) === null)
// 撤消过的类型不再提示;同一位置的其它类型不受影响。
check(
'排除已处理的类型',
resolveActiveSkip(150, segments, { excludedKinds: ['intro'] }) === null,
)
check(
'排除只影响指定类型',
resolveActiveSkip(3450, segments, { excludedKinds: ['intro'] })?.kind === 'credits',
)
// ── 文案 ────────────────────────────────────────────────────────────────────
equal('片头文案', skipLabel('intro', false), '跳过片头')
equal('回顾也归入片头', skipLabel('recap', false), '跳过片头')
equal('最后一集的片尾', skipLabel('credits', false), '跳过片尾')
equal('有下一集时片尾变成下一集', skipLabel('credits', true), '下一集')
equal('预告同理', skipLabel('preview', true), '下一集')
equal(
'提示里带上有下一集的文案',
resolveActiveSkip(3450, segments, { hasNextEpisode: true })?.label,
'下一集',
)
// ── 自动跳过后的提示文案 ────────────────────────────────────────────────────
equal('片头提示', skippedNoticeText('intro'), '已跳过片头')
equal('回顾提示', skippedNoticeText('recap'), '已跳过回顾')
equal('片尾提示', skippedNoticeText('credits'), '已跳过片尾')
equal('预告按片尾处理', skippedNoticeText('preview'), '已跳过片尾')
console.log('skipSegments.test.ts ok')
+87
View File
@@ -0,0 +1,87 @@
import type { PlaybackSegment, PlaybackSegmentKind } from '../types/playback'
/** 落在源时间轴上的可跳过区间(秒)。 */
export interface SkipSegment {
kind: PlaybackSegmentKind
startSec: number
endSec: number
}
/** 当前应当在浮层上提示的跳过动作。 */
export interface SkipPrompt extends SkipSegment {
label: string
}
/** 片头/回顾统一叫「跳过片头」;片尾在有下一集时直接叫「下一集」。 */
export function skipLabel(kind: PlaybackSegmentKind, hasNextEpisode: boolean): string {
if (kind === 'credits' || kind === 'preview') {
return hasNextEpisode ? '下一集' : '跳过片尾'
}
return '跳过片头'
}
/**
* 自动跳过后的提示文案。自动跳转很容易被当成「进度条自己动了」,所以必须告诉
* 用户发生了什么,并给一个撤销入口。
*/
export function skippedNoticeText(kind: PlaybackSegmentKind): string {
if (kind === 'credits' || kind === 'preview') return '已跳过片尾'
if (kind === 'recap') return '已跳过回顾'
return '已跳过片头'
}
/**
* 把服务端返回的片段换算成源时间轴上的秒区间。
*
* end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值)
* 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
*/
export function toSkipSegments(
segments: PlaybackSegment[] | null | undefined,
durationSec: number,
): SkipSegment[] {
if (!segments || segments.length === 0) return []
const out: SkipSegment[] = []
for (const segment of segments) {
const startSec = Math.max(0, segment.start_ms / 1000)
const endSec = segment.end_ms > 0 ? segment.end_ms / 1000 : durationSec
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
}
return out.sort((a, b) => a.startSec - b.startSec)
}
export interface ResolveActiveSkipOptions {
hasNextEpisode?: boolean
/** 本次播放内已经跳过 / 被用户撤销过的类型,不再提示。 */
excludedKinds?: PlaybackSegmentKind[]
/** 允许提前一点点出现按钮,避免恰好卡在时间轴上时迟到。 */
toleranceSec?: number
}
/**
* 找出当前播放位置命中的可跳过区间。同一位置有多个区间时取最早开始的那个。
*
* 注意 currentSec 必须是「源时间轴」上的绝对秒数:HLS 转码时 <video>.currentTime
* 是相对转码起点的,调用方要用 streamOffset 加回来,否则转码场景整段判断都会错位。
*/
export function resolveActiveSkip(
currentSec: number,
segments: SkipSegment[],
options: ResolveActiveSkipOptions = {},
): SkipPrompt | null {
const { hasNextEpisode = false, excludedKinds = [], toleranceSec = 0.25 } = options
if (!Number.isFinite(currentSec)) return null
for (const segment of segments) {
if (excludedKinds.includes(segment.kind)) continue
if (currentSec < segment.startSec - toleranceSec) continue
if (currentSec >= segment.endSec) continue
return {
kind: segment.kind,
startSec: segment.startSec,
endSec: segment.endSec,
label: skipLabel(segment.kind, hasNextEpisode),
}
}
return null
}