diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 99901df..2f2f0ca 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -36,7 +36,7 @@ import { type SubtitleStylePreset, } from '../utils/subtitleDisplay' import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel' -import { classifyDirectPlayError } from './directPlayError' +import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError' import { apiErrorMessage } from './StrmManagePage' import { PlayerTopBar } from './PlayerTopBar' import { PlayerVideoStage } from './PlayerVideoStage' @@ -106,6 +106,10 @@ export function PlayerPage() { const progressSessionRef = useRef(null) const directRetryRef = useRef(false) const retryingDirectRef = useRef(false) + // 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。 + const directSeekGraceUntilRef = useRef(0) + // 切集时清掉旧 ?mode= 的同一轮渲染里,先挡住 modeParam effect 把 hls 写回来。 + const ignoreModeParamRef = useRef(false) const fallbackTimerRef = useRef | null>(null) const [media, setMedia] = useState(null) @@ -485,6 +489,15 @@ export function PlayerPage() { setDanmakuSearching(true) directRetryRef.current = false retryingDirectRef.current = false + directSeekGraceUntilRef.current = 0 + // 新片子清掉上一部失败回退留下的 ?mode=hls,避免「一直 HLS」。 + ignoreModeParamRef.current = true + setMode('direct') + const nextParams = new URLSearchParams(window.location.search) + if (nextParams.has('mode')) { + nextParams.delete('mode') + setParams(nextParams, { replace: true }) + } if (fallbackTimerRef.current) { clearTimeout(fallbackTimerRef.current) fallbackTimerRef.current = null @@ -495,7 +508,7 @@ export function PlayerPage() { fallbackTimerRef.current = null } } - }, [id]) + }, [id, setParams]) // 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据 const modeParam = params.get('mode') as PlayerMode | null @@ -526,12 +539,18 @@ export function PlayerPage() { .then((m) => { if (cancelled) return setMedia(m) - const isDirect = isDirectStreamMedia(m) - const auto = pickPlayerMode(m) - // 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。 - setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto)) setPlayerError('') setLoadError('') + const isDirect = isDirectStreamMedia(m) + const auto = pickPlayerMode(m) + // 直连解码 / 远程 Emby:强制直连。 + // 不把 modeParam 放进依赖:失败回退写 ?mode=hls 时不应重拉 metadata, + // 也不要用滞后的 get 回调把 hls 盖回 direct。 + if (directOnly || isDirect) { + setMode('direct') + return + } + setMode((prev) => (prev === 'hls' ? prev : auto)) }) .catch((err: unknown) => { if (cancelled) return @@ -541,7 +560,21 @@ export function PlayerPage() { return () => { cancelled = true } - }, [id, modeParam, directOnly]) + }, [id, directOnly]) + + // 仅在用户手动切换 / 回退写入 URL 后,把 ?mode= 同步回 state(切集时 id effect 已清掉旧 mode)。 + useEffect(() => { + if (ignoreModeParamRef.current) { + ignoreModeParamRef.current = false + return + } + if (directOnly) { + setMode('direct') + return + } + if (modeParam !== 'direct' && modeParam !== 'hls') return + setMode(modeParam) + }, [modeParam, directOnly, id]) // VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。 // 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。 @@ -1083,32 +1116,72 @@ export function PlayerPage() { } const video = ref.current if (!video) return + + let cancelled = false + let seekStarted = false let onSeeked: (() => void) | undefined - const applyResume = () => { - if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) { - onSeeked = () => { - setInitialSeekDone(true) - const m = Math.floor(resumePosition / 60) - const s = Math.floor(resumePosition % 60) - const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` - toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 }) - } - video.addEventListener('seeked', onSeeked, { once: true }) - video.currentTime = resumePosition + let onPlaying: (() => void) | undefined + let onCanPlay: (() => void) | undefined + + const notifyResume = () => { + const m = Math.floor(resumePosition / 60) + const s = Math.floor(resumePosition % 60) + const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 }) + } + + const performResumeSeek = () => { + if (cancelled || seekStarted) return + if (Math.abs(video.currentTime - resumePosition) <= 2) { + seekStarted = true + setInitialSeekDone(true) return } - setInitialSeekDone(true) + seekStarted = true + // 115/STRM 302 直链上过早 seek 会让 Chromium 误报 error 并触发 HLS 回退。 + directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS + directRetryRef.current = false + clearFallbackTimer() + onSeeked = () => { + setInitialSeekDone(true) + notifyResume() + } + video.addEventListener('seeked', onSeeked, { once: true }) + try { + video.currentTime = resumePosition + } catch { + setInitialSeekDone(true) + } } - if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { - applyResume() + + // STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。 + // 本地文件仍可在 canplay 后立刻跳,体验更干净。 + const deferForStrm = isStrmMedia(mediaRef.current) + if (deferForStrm) { + onPlaying = () => { + performResumeSeek() + } + if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { + performResumeSeek() + } else { + video.addEventListener('playing', onPlaying, { once: true }) + } + } else if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { + performResumeSeek() } else { - video.addEventListener('canplay', applyResume, { once: true }) + onCanPlay = () => { + performResumeSeek() + } + video.addEventListener('canplay', onCanPlay, { once: true }) } + return () => { - video.removeEventListener('canplay', applyResume) + cancelled = true + if (onPlaying) video.removeEventListener('playing', onPlaying) + if (onCanPlay) video.removeEventListener('canplay', onCanPlay) if (onSeeked) video.removeEventListener('seeked', onSeeked) } - }, [resumePosition, initialSeekDone, mode, hlsStartSec]) + }, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer]) // 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度 const hlsStartSecRef = useRef(hlsStartSec) @@ -1762,6 +1835,7 @@ export function PlayerPage() { elementSrc: video?.src ?? '', expectedSrc, alreadyRetried: directRetryRef.current, + seekGraceActive: Date.now() < directSeekGraceUntilRef.current, }) if (action === 'ignore') return @@ -1779,6 +1853,8 @@ export function PlayerPage() { const fallbackDirectPlay = () => { if (modeRef.current !== 'direct') return + // 宽限期内又触发的迟到 fallback 定时器:直接丢掉。 + if (Date.now() < directSeekGraceUntilRef.current) return const current = ref.current if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) { @@ -1813,7 +1889,9 @@ export function PlayerPage() { } clearFallbackTimer() - fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500) + // STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。 + const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500 + fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs) }, [ clearFallbackTimer, directOnly, diff --git a/web/src/pages/directPlayError.ts b/web/src/pages/directPlayError.ts index 714294f..e5f3cd5 100644 --- a/web/src/pages/directPlayError.ts +++ b/web/src/pages/directPlayError.ts @@ -3,9 +3,15 @@ export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback' const MEDIA_ERR_ABORTED = 1 const HAVE_CURRENT_DATA = 2 +/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */ +export const DIRECT_SEEK_GRACE_MS = 12_000 + /** * Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek) * 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。 + * + * seekGraceActive:程序化续播/跳转后的宽限期。115 STRM 在 302 直链上 + * seek 时 Chromium 经常误报 decode/network error,此时绝不能升到 HLS。 */ export function classifyDirectPlayError(input: { errorCode: number | undefined | null @@ -13,12 +19,15 @@ export function classifyDirectPlayError(input: { elementSrc: string expectedSrc: string alreadyRetried: boolean + seekGraceActive?: boolean }): DirectPlayErrorAction { const code = input.errorCode ?? 0 if (code === MEDIA_ERR_ABORTED) return 'ignore' if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore' if (!input.elementSrc) return 'ignore' if (input.expectedSrc && input.elementSrc !== input.expectedSrc) return 'ignore' + // 续播 seek 宽限期内:误报很常见,且 retry 会 video.load() 把进度清零,直接忽略。 + if (input.seekGraceActive) return 'ignore' if (!input.alreadyRetried) return 'retry' return 'fallback' } diff --git a/web/src/pages/playerPageModel.test.ts b/web/src/pages/playerPageModel.test.ts index e263aa0..caef6bb 100644 --- a/web/src/pages/playerPageModel.test.ts +++ b/web/src/pages/playerPageModel.test.ts @@ -72,4 +72,28 @@ check( }) === 'fallback', ) +check( + 'seek grace ignores decode miss without retrying', + classifyDirectPlayError({ + errorCode: 4, + readyState: 0, + elementSrc: src, + expectedSrc: src, + alreadyRetried: false, + seekGraceActive: true, + }) === 'ignore', +) + +check( + 'seek grace never falls back to HLS', + classifyDirectPlayError({ + errorCode: 4, + readyState: 0, + elementSrc: src, + expectedSrc: src, + alreadyRetried: true, + seekGraceActive: true, + }) === 'ignore', +) + console.log('playerPageModel.test.ts ok')