mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -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),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
'时长未知时丢弃开放区间',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user