This commit is contained in:
truewhile
2026-10-22 15:14:00 +08:00
parent 54b0e33968
commit 9ccf60427a
30 changed files with 1909 additions and 68 deletions
+10
View File
@@ -54,6 +54,16 @@ export const playbackAPI = {
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
.then((r) => r.data),
// 手动标记:把当前位置记为片头结束 / 片尾开始(clear 则清除)。服务端会按季
// 传播到同季其它尚未标记的集,并返回合并后的完整片段列表。
markSegment: (
mediaId: string,
payload: { kind: 'intro' | 'credits'; position_ms?: number; clear?: boolean },
) =>
api
.put<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`, payload)
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data),
+83
View File
@@ -88,6 +88,16 @@ type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message:
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
// 手动标记的提示里展示的时间点(mm:ss 或 h:mm:ss)。
function formatMarkTime(totalSec: number): string {
const safe = Math.max(0, Math.floor(totalSec))
const hours = Math.floor(safe / 3600)
const minutes = Math.floor((safe % 3600) / 60)
const seconds = safe % 60
const pad = (n: number) => String(n).padStart(2, '0')
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
@@ -2245,6 +2255,73 @@ export function PlayerPage() {
if (activeSkip) performSkip(activeSkip)
}, [activeSkip, performSkip])
// ── 手动标记片头/片尾 ──────────────────────────────────────────────────────
// 服务端把手动标记的优先级排在最前,并按季传播到还没标记的集,所以这里只需
// 把「当前播放位置」发过去,再用返回的合并结果替换本地片段。
const manualMarkBusyRef = useRef(false)
const submitManualMark = useCallback(
(kind: 'intro' | 'credits', clear: boolean) => {
const current = mediaRef.current
if (!current?.id || manualMarkBusyRef.current) return
// 位置必须是源时间轴上的绝对秒数:本地 HLS 的 t=0 对应 hlsStartSec。
const video = ref.current
const positionSec = playbackPositionSec(
modeRef.current,
hlsSourceRef.current,
hlsStartSecRef.current,
video?.currentTime || 0,
)
const positionMs = Math.max(0, Math.round(positionSec * 1000))
if (!clear && positionMs <= 0) {
toast.error('当前位置为 0,请播放到片头结束后再标记')
return
}
manualMarkBusyRef.current = true
const markLabel = kind === 'intro' ? '片头' : '片尾'
const markTime = formatMarkTime(positionSec)
playbackAPI
.markSegment(current.id, {
kind,
position_ms: clear ? undefined : positionMs,
clear,
})
.then((res) => {
setRawSkipSegments(res.segments ?? [])
setAutoSkipIntro(Boolean(res.auto_skip))
setActiveSkip(null)
activeSkipKeyRef.current = ''
setDismissedSkipKinds([])
setAutoSuppressedKinds([])
if (clear) {
toast.success(`已清除${markLabel}标记`)
} else {
toast.success(`已标记${markLabel}:${markTime}(同季其它集已同步)`)
}
})
.catch(() => toast.error('标记保存失败,请稍后重试'))
.finally(() => {
manualMarkBusyRef.current = false
})
},
[],
)
const handleMarkIntro = useCallback(() => submitManualMark('intro', false), [submitManualMark])
const handleMarkOutro = useCallback(() => submitManualMark('credits', false), [submitManualMark])
const handleClearIntroMark = useCallback(() => submitManualMark('intro', true), [submitManualMark])
const handleClearOutroMark = useCallback(() => submitManualMark('credits', true), [submitManualMark])
// 已有手动标记的类型:用于按钮提示文案。只有手动标记可以清除,外部源的不算。
const markPresence = useMemo(() => {
let intro = false
let outro = false
for (const segment of rawSkipSegments) {
if (segment.kind === 'intro') intro = true
if (segment.kind === 'credits') outro = true
}
return { intro, outro }
}, [rawSkipSegments])
const handleUndoSkip = useCallback(() => {
const notice = skipNotice
setSkipNotice(null)
@@ -2547,6 +2624,12 @@ export function PlayerPage() {
onSkipPrompt={handleSkipClick}
skipNotice={skipNotice ? { text: skipNotice.text } : null}
onUndoSkip={handleUndoSkip}
onMarkIntro={handleMarkIntro}
onMarkOutro={handleMarkOutro}
onClearIntroMark={handleClearIntroMark}
onClearOutroMark={handleClearOutroMark}
hasIntroMark={markPresence.intro}
hasOutroMark={markPresence.outro}
playlistPanel={
isMobileTheater
? undefined
+57
View File
@@ -242,6 +242,15 @@ type PlayerVideoStageProps = {
/** 自动跳过后的撤销提示;null 表示不显示。 */
skipNotice?: { text: string } | null
onUndoSkip?: () => void
/** 手动标记:把当前位置记为片头结束 / 片尾开始。 */
onMarkIntro?: () => void
onMarkOutro?: () => void
/** 长按清除已有的片头 / 片尾标记。 */
onClearIntroMark?: () => void
onClearOutroMark?: () => void
/** 当前是否已有对应类型的标记(决定按钮文案与长按是否可用)。 */
hasIntroMark?: boolean
hasOutroMark?: boolean
waiting?: boolean
waitingMessage?: string
}
@@ -312,6 +321,12 @@ export function PlayerVideoStage({
onSkipPrompt,
skipNotice = null,
onUndoSkip,
onMarkIntro,
onMarkOutro,
onClearIntroMark,
onClearOutroMark,
hasIntroMark = false,
hasOutroMark = false,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -966,6 +981,48 @@ export function PlayerVideoStage({
) : null}
{danmakuPanel}
{playlistPanel}
{/* 手动标记片头/片尾:数据源查不到时用户自己标一次,服务端会按季传播。
放在左下角,与右侧的「跳过」按钮分开,避免误触。 */}
{!locked && (onMarkIntro || onMarkOutro) ? (
<div
className="absolute bottom-16 left-3 z-30 flex items-center gap-1.5 rounded-xl border border-white/15 bg-black/70 px-1.5 py-1.5 text-xs text-white shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md sm:bottom-20 sm:left-5"
onPointerDown={(event) => event.stopPropagation()}
>
<span className="px-1.5 text-white/55">标记</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkIntro?.()
}}
onContextMenu={(event) => {
if (!hasIntroMark) return
event.preventDefault()
onClearIntroMark?.()
}}
title={hasIntroMark ? '点击重新标记片头,右键清除' : '把当前位置标为片头结束'}
className="rounded-lg bg-white/10 px-2.5 py-1 font-medium transition hover:bg-white/20"
>
片头
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkOutro?.()
}}
onContextMenu={(event) => {
if (!hasOutroMark) return
event.preventDefault()
onClearOutroMark?.()
}}
title={hasOutroMark ? '点击重新标记片尾,右键清除' : '把当前位置标为片尾开始'}
className="rounded-lg bg-white/10 px-2.5 py-1 font-medium transition hover:bg-white/20"
>
片尾
</button>
</div>
) : null}
</>
) : loadError ? (
<p className="max-w-[92%] text-center text-sm text-rose-400">{loadError}</p>
+15 -2
View File
@@ -23,11 +23,24 @@ export const generalSettingsGroup: SettingGroup = {
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
defaultValue: 'false',
},
{
key: 'segment.source',
label: '片头/片尾数据源',
type: 'select',
hint: '选择查询哪个社区库来补全片头/片尾时间点。TheIntroDB 数据最全(按 TMDb id);IntroDB 只认 IMDb id,需要 TMDb 已配置 API key 才能解析;TODb 偏华语片源、按平台版本记录。选「关闭」后只使用本地已有数据(含手动标记),不再请求外网。',
defaultValue: 'theintrodb',
options: [
{ value: 'theintrodb', label: 'TheIntroDB' },
{ value: 'introdb', label: 'IntroDB' },
{ value: 'todb', label: 'TODb(TheOtherDB)' },
{ value: 'off', label: '关闭' },
],
},
{
key: 'segment.prewarm_enabled',
label: '后台预热 IntroDB 片头片段',
label: '后台预热片头片段',
type: 'toggle',
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
hint: '默认关闭。开启后每 6 小时在后台向上面选择的数据源拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
defaultValue: 'false',
},
{
+12 -1
View File
@@ -15,7 +15,7 @@ function equal<T>(name: string, actual: T, expected: T) {
check('空片段返回空数组', toSkipSegments(null, 600).length === 0)
check('空数组返回空数组', toSkipSegments([], 600).length === 0)
// end_ms: 0 表示延续到片尾,用媒体时长补齐。
// end_ms: 0 只在片尾类(credits/preview)表示「延续到片尾」,用媒体时长补齐。
const resolved = toSkipSegments(
[
{ kind: 'intro', start_ms: 228_664, end_ms: 246_143 },
@@ -28,6 +28,17 @@ equal('片头起点(秒)', resolved[0].startSec, 228.664)
equal('片头终点(秒)', resolved[0].endSec, 246.143)
equal('片尾终点补齐为总时长', resolved[1].endSec, 3600)
// 片头 / 回顾的 end_ms: 0 是数据源的「这段不存在」占位(TheIntroDB 官方语义),
// 绝不能补齐成总时长——那会让整集都落在片头区间里,一开播就弹跳过按钮。
check(
'片头 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'intro', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
check(
'回顾 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'recap', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
// 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。
check(
'时长未知时丢弃开放区间',
+15 -5
View File
@@ -33,8 +33,11 @@ export function skippedNoticeText(kind: PlaybackSegmentKind): string {
/**
* 把服务端返回的片段换算成源时间轴上的秒区间。
*
* end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值)
* 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
* end_ms === 0 是「一直到片尾」的哨兵值,只有片尾类(credits / preview)会这么
* 返回,用媒体总时长补齐。片头 / 回顾出现 0 一律丢弃:那通常意味着数据源根本没
* 给出结束点(TheIntroDB 用 end_ms: 0 表示「这段不存在」),补成总时长会让整集
* 都落在「片头」区间里,一开播就弹跳过按钮。时长未知(0 / 非有限值)同样丢弃,
* 否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
*/
export function toSkipSegments(
segments: PlaybackSegment[] | null | undefined,
@@ -44,9 +47,16 @@ export function toSkipSegments(
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 })
const openEnded = segment.kind === 'credits' || segment.kind === 'preview'
if (segment.end_ms > 0) {
const endSec = segment.end_ms / 1000
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
continue
}
if (!openEnded) continue
if (!Number.isFinite(durationSec) || durationSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec: durationSec })
}
return out.sort((a, b) => a.startSec - b.startSec)
}