mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
添加跳过片头片尾功能
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { api } from './client'
|
||||
import type { Media, Playlist } from '../types'
|
||||
import type { Media, PlaybackSegmentsResponse, Playlist } from '../types'
|
||||
|
||||
// History rows arrive joined with their Media row; the backend returns null
|
||||
// for orphaned rows whose media has been removed.
|
||||
@@ -47,6 +47,13 @@ export const playbackAPI = {
|
||||
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
|
||||
.then((r) => r.data),
|
||||
|
||||
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
|
||||
// 因此绝不能让它挡在起播路径上。
|
||||
segments: (mediaId: string) =>
|
||||
api
|
||||
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
|
||||
.then((r) => r.data),
|
||||
|
||||
recordProgress: (payload: PlaybackProgressRequest) =>
|
||||
api.post('/history', payload).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
@@ -71,6 +72,9 @@ type PlaybackProgressSession = {
|
||||
sequence: number
|
||||
}
|
||||
|
||||
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
||||
const SKIP_NOTICE_MS = 6000
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -697,6 +701,63 @@ export function PlayerPage() {
|
||||
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
|
||||
}, [playbackInfo])
|
||||
|
||||
// ── 片头/片尾跳过 ──────────────────────────────────────────────────────────
|
||||
// 原始片段(服务端单位)与当前生效档案的「自动跳过片头」开关。
|
||||
const [rawSkipSegments, setRawSkipSegments] = useState<PlaybackSegment[]>([])
|
||||
const [autoSkipIntro, setAutoSkipIntro] = useState(false)
|
||||
// 当前落进的跳过提示;不在任何区间时为 null。
|
||||
const [activeSkip, setActiveSkip] = useState<SkipPrompt | null>(null)
|
||||
// 自动跳过后的撤销提示。
|
||||
const [skipNotice, setSkipNotice] = useState<{
|
||||
text: string
|
||||
startSec: number
|
||||
kind: PlaybackSegmentKind
|
||||
} | null>(null)
|
||||
// 用户已经处理过的区间类型:本次播放内不再重复提示。
|
||||
const [dismissedSkipKinds, setDismissedSkipKinds] = useState<PlaybackSegmentKind[]>([])
|
||||
// 用户主动跳进过片头/片尾区间(多半是想重看):本次播放不再自动跳过该类型,
|
||||
// 但按钮保留,想跳随时可以点。
|
||||
const [autoSuppressedKinds, setAutoSuppressedKinds] = useState<PlaybackSegmentKind[]>([])
|
||||
const skipNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// 标记「这次 seeked 是我们自己跳的」,避免把自己的跳转当成用户手动跳转。
|
||||
const selfSkipSeekRef = useRef(false)
|
||||
|
||||
// end_ms 为 0 的区间要用媒体总时长补齐,而时长可能晚于片段到达(STRM/HLS 起播
|
||||
// 后才回填 duration_sec),所以派生放在这里,时长更新后区间会自动重算。
|
||||
const skipSegments = useMemo(
|
||||
() => toSkipSegments(rawSkipSegments, media?.duration_sec || 0),
|
||||
[rawSkipSegments, media?.duration_sec],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
let cancelled = false
|
||||
setRawSkipSegments([])
|
||||
setAutoSkipIntro(false)
|
||||
setActiveSkip(null)
|
||||
setSkipNotice(null)
|
||||
setDismissedSkipKinds([])
|
||||
setAutoSuppressedKinds([])
|
||||
// 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。
|
||||
playbackAPI
|
||||
.segments(mediaId)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setAutoSkipIntro(Boolean(res.auto_skip))
|
||||
setRawSkipSegments(res.segments ?? [])
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [mediaId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const switchToLocalHLS = useCallback(
|
||||
(position = 0) => {
|
||||
setHlsSource('local')
|
||||
@@ -1217,6 +1278,58 @@ export function PlayerPage() {
|
||||
return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : ''
|
||||
}, [nextEpisode, playlistEpisodes])
|
||||
|
||||
// ── 片头/片尾跳过:位置判定 ────────────────────────────────────────────────
|
||||
// 只记录「提示是否变化」,避免 timeupdate(约每秒 4 次)每次都写 state 重渲染。
|
||||
const activeSkipKeyRef = useRef('')
|
||||
|
||||
// 监听播放位置,判断当前是否落在某个可跳过区间里。
|
||||
useEffect(() => {
|
||||
const video = ref.current
|
||||
if (!video || skipSegments.length === 0) return
|
||||
const sync = () => {
|
||||
// HLS 转码时 <video>.currentTime 是相对转码起点的,必须把起点加回来。这里与
|
||||
// 进度上报用的算法保持一致,否则转码场景下整段判断都会错位。
|
||||
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const absolute = offset + (video.currentTime || 0)
|
||||
const next = resolveActiveSkip(absolute, skipSegments, {
|
||||
hasNextEpisode: Boolean(nextEpisode),
|
||||
excludedKinds: dismissedSkipKinds,
|
||||
})
|
||||
const key = next ? `${next.kind}:${next.startSec}` : ''
|
||||
if (key === activeSkipKeyRef.current) return
|
||||
activeSkipKeyRef.current = key
|
||||
setActiveSkip(next)
|
||||
}
|
||||
sync()
|
||||
video.addEventListener('timeupdate', sync)
|
||||
return () => video.removeEventListener('timeupdate', sync)
|
||||
}, [skipSegments, nextEpisode, dismissedSkipKinds, mediaId])
|
||||
|
||||
// 用户主动跳进片头/回顾区间(多半是想重看):本次播放不再自动跳过该类型,
|
||||
// 但按钮保留。只关心会被自动跳过的两种类型。
|
||||
useEffect(() => {
|
||||
const video = ref.current
|
||||
if (!video || skipSegments.length === 0) return
|
||||
const onSeeked = () => {
|
||||
if (selfSkipSeekRef.current) {
|
||||
selfSkipSeekRef.current = false
|
||||
return
|
||||
}
|
||||
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const absolute = offset + (video.currentTime || 0)
|
||||
const hit = skipSegments.find(
|
||||
(segment) =>
|
||||
(segment.kind === 'intro' || segment.kind === 'recap') &&
|
||||
absolute >= segment.startSec &&
|
||||
absolute < segment.endSec,
|
||||
)
|
||||
if (!hit) return
|
||||
setAutoSuppressedKinds((prev) => (prev.includes(hit.kind) ? prev : [...prev, hit.kind]))
|
||||
}
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
return () => video.removeEventListener('seeked', onSeeked)
|
||||
}, [skipSegments, mediaId])
|
||||
|
||||
const playEpisode = useCallback(
|
||||
(target: Media) => {
|
||||
navigate(
|
||||
@@ -1486,6 +1599,88 @@ export function PlayerPage() {
|
||||
[hlsStartSec, mode],
|
||||
)
|
||||
|
||||
// ── 片头/片尾跳过:执行 ────────────────────────────────────────────────────
|
||||
// 跳到源时间轴上的绝对秒数。HLS 转码时不能直接写 currentTime:本地 HLS 交给
|
||||
// handleSeekAbsolute(超出缓冲窗口时会从头起一段新转码),云端 HLS 按转码起点换算。
|
||||
const seekPlaybackTo = useCallback(
|
||||
(absoluteSec: number) => {
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
const target = Math.max(0, absoluteSec)
|
||||
if (mode === 'hls') {
|
||||
if (hlsSource === 'local') {
|
||||
handleSeekAbsolute(target)
|
||||
return
|
||||
}
|
||||
selfSkipSeekRef.current = true
|
||||
video.currentTime = Math.max(0, target - hlsStartSec)
|
||||
return
|
||||
}
|
||||
selfSkipSeekRef.current = true
|
||||
video.currentTime = target
|
||||
},
|
||||
[handleSeekAbsolute, hlsSource, hlsStartSec, mode],
|
||||
)
|
||||
|
||||
const showSkipNotice = useCallback(
|
||||
(notice: { text: string; startSec: number; kind: PlaybackSegmentKind }) => {
|
||||
setSkipNotice(notice)
|
||||
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
|
||||
skipNoticeTimerRef.current = setTimeout(() => setSkipNotice(null), SKIP_NOTICE_MS)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// 执行一次跳过:片尾有下一集就直接进下一集,其余情况跳到区间终点。
|
||||
const performSkip = useCallback(
|
||||
(prompt: SkipPrompt) => {
|
||||
setDismissedSkipKinds((prev) => (prev.includes(prompt.kind) ? prev : [...prev, prompt.kind]))
|
||||
setActiveSkip(null)
|
||||
activeSkipKeyRef.current = ''
|
||||
const isOutro = prompt.kind === 'credits' || prompt.kind === 'preview'
|
||||
if (isOutro && nextEpisode) {
|
||||
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
|
||||
playEpisode(nextEpisode)
|
||||
return
|
||||
}
|
||||
seekPlaybackTo(prompt.endSec)
|
||||
},
|
||||
[nextEpisode, nextEpisodeTitle, playEpisode, seekPlaybackTo],
|
||||
)
|
||||
|
||||
const handleSkipClick = useCallback(() => {
|
||||
if (activeSkip) performSkip(activeSkip)
|
||||
}, [activeSkip, performSkip])
|
||||
|
||||
const handleUndoSkip = useCallback(() => {
|
||||
const notice = skipNotice
|
||||
setSkipNotice(null)
|
||||
if (skipNoticeTimerRef.current) {
|
||||
clearTimeout(skipNoticeTimerRef.current)
|
||||
skipNoticeTimerRef.current = null
|
||||
}
|
||||
if (!notice) return
|
||||
// 撤销后本次播放不再自动跳这一段,否则会被立刻再跳一次;同时恢复按钮,
|
||||
// 用户改主意时还能手动跳。
|
||||
setAutoSuppressedKinds((prev) => (prev.includes(notice.kind) ? prev : [...prev, notice.kind]))
|
||||
setDismissedSkipKinds((prev) => prev.filter((kind) => kind !== notice.kind))
|
||||
seekPlaybackTo(notice.startSec)
|
||||
}, [seekPlaybackTo, skipNotice])
|
||||
|
||||
// 自动跳过只针对片头(含回顾):档案里的开关本身就叫「自动跳过片头」,而且自动
|
||||
// 跳到结尾会让没开自动连播的用户莫名其妙。片尾始终只提供按钮。
|
||||
useEffect(() => {
|
||||
if (!autoSkipIntro || !activeSkip) return
|
||||
if (activeSkip.kind !== 'intro' && activeSkip.kind !== 'recap') return
|
||||
if (autoSuppressedKinds.includes(activeSkip.kind)) return
|
||||
performSkip(activeSkip)
|
||||
showSkipNotice({
|
||||
text: skippedNoticeText(activeSkip.kind),
|
||||
startSec: activeSkip.startSec,
|
||||
kind: activeSkip.kind,
|
||||
})
|
||||
}, [activeSkip, autoSkipIntro, autoSuppressedKinds, performSkip, showSkipNotice])
|
||||
|
||||
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
@@ -1754,6 +1949,10 @@ export function PlayerPage() {
|
||||
onVr360Error={handleVr360Error}
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
skipPrompt={activeSkip}
|
||||
onSkipPrompt={handleSkipClick}
|
||||
skipNotice={skipNotice ? { text: skipNotice.text } : null}
|
||||
onUndoSkip={handleUndoSkip}
|
||||
playlistPanel={
|
||||
isMobileTheater
|
||||
? undefined
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
LockOpen,
|
||||
MousePointerClick,
|
||||
Rotate3d,
|
||||
SkipForward,
|
||||
Smartphone,
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||
import type { SkipPrompt } from '../utils/skipSegments'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleRenderGroup = {
|
||||
@@ -233,6 +236,13 @@ type PlayerVideoStageProps = {
|
||||
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||
showVr360Guide?: boolean
|
||||
onDismissVr360Guide?: () => void
|
||||
/** 当前落进的片头/片尾区间;null 表示不显示跳过按钮。 */
|
||||
skipPrompt?: SkipPrompt | null
|
||||
/** 点击跳过按钮。 */
|
||||
onSkipPrompt?: () => void
|
||||
/** 自动跳过后的撤销提示;null 表示不显示。 */
|
||||
skipNotice?: { text: string } | null
|
||||
onUndoSkip?: () => void
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
@@ -300,6 +310,10 @@ export function PlayerVideoStage({
|
||||
onVr360Error,
|
||||
showVr360Guide = false,
|
||||
onDismissVr360Guide,
|
||||
skipPrompt = null,
|
||||
onSkipPrompt,
|
||||
skipNotice = null,
|
||||
onUndoSkip,
|
||||
waiting = false,
|
||||
waitingMessage = '',
|
||||
}: PlayerVideoStageProps) {
|
||||
@@ -919,6 +933,40 @@ export function PlayerVideoStage({
|
||||
vr360Detected={vr360Detected}
|
||||
onToggleVr360={onToggleVr360}
|
||||
/>
|
||||
{/* 跳过片头/片尾:贴在操作栏上方右侧。刻意不随操作栏一起自动隐藏——
|
||||
用户想在「干净画面」下也能跳过;锁定画面时才一并藏起来。 */}
|
||||
{!locked && skipPrompt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSkipPrompt?.()
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="absolute bottom-16 right-3 z-30 inline-flex items-center gap-2 rounded-xl border border-white/15 bg-black/70 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md transition hover:bg-black/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-400 sm:bottom-20 sm:right-5"
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
<span>{skipPrompt.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{/* 自动跳过的撤销入口:进度条自己动过之后必须给用户一条退路。 */}
|
||||
{!locked && skipNotice ? (
|
||||
<div className="absolute bottom-16 right-3 z-30 flex items-center gap-3 rounded-xl border border-white/15 bg-black/70 px-3.5 py-2 text-xs text-white/90 shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md sm:bottom-20 sm:right-5">
|
||||
<span>{skipNotice.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onUndoSkip?.()
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white/15 px-2.5 py-1 font-semibold text-white transition hover:bg-white/25"
|
||||
>
|
||||
<Undo2 size={13} />
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
</>
|
||||
|
||||
@@ -29,3 +29,19 @@ export interface PlaybackInfo {
|
||||
transcode: PlaybackTranscodeState
|
||||
}
|
||||
|
||||
/** 可跳过的区间类型,与后端 media_segments.kind 一一对应。 */
|
||||
export type PlaybackSegmentKind = 'intro' | 'recap' | 'credits' | 'preview'
|
||||
|
||||
export interface PlaybackSegment {
|
||||
kind: PlaybackSegmentKind
|
||||
start_ms: number
|
||||
/** 0 表示区间一直延续到片尾(后端把 end_ms: null 落成 0),需按媒体时长补齐。 */
|
||||
end_ms: number
|
||||
}
|
||||
|
||||
export interface PlaybackSegmentsResponse {
|
||||
segments: PlaybackSegment[]
|
||||
/** 当前生效播放档案的「自动跳过片头」开关。 */
|
||||
auto_skip: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user