mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 15:56:37 +08:00
优化
This commit is contained in:
+103
-25
@@ -36,7 +36,7 @@ import {
|
|||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
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 { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||||
@@ -106,6 +106,10 @@ export function PlayerPage() {
|
|||||||
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
|
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
|
||||||
const directRetryRef = useRef(false)
|
const directRetryRef = useRef(false)
|
||||||
const retryingDirectRef = useRef(false)
|
const retryingDirectRef = useRef(false)
|
||||||
|
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
|
||||||
|
const directSeekGraceUntilRef = useRef(0)
|
||||||
|
// 切集时清掉旧 ?mode= 的同一轮渲染里,先挡住 modeParam effect 把 hls 写回来。
|
||||||
|
const ignoreModeParamRef = useRef(false)
|
||||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
@@ -485,6 +489,15 @@ export function PlayerPage() {
|
|||||||
setDanmakuSearching(true)
|
setDanmakuSearching(true)
|
||||||
directRetryRef.current = false
|
directRetryRef.current = false
|
||||||
retryingDirectRef.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) {
|
if (fallbackTimerRef.current) {
|
||||||
clearTimeout(fallbackTimerRef.current)
|
clearTimeout(fallbackTimerRef.current)
|
||||||
fallbackTimerRef.current = null
|
fallbackTimerRef.current = null
|
||||||
@@ -495,7 +508,7 @@ export function PlayerPage() {
|
|||||||
fallbackTimerRef.current = null
|
fallbackTimerRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id, setParams])
|
||||||
|
|
||||||
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
||||||
const modeParam = params.get('mode') as PlayerMode | null
|
const modeParam = params.get('mode') as PlayerMode | null
|
||||||
@@ -526,12 +539,18 @@ export function PlayerPage() {
|
|||||||
.then((m) => {
|
.then((m) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setMedia(m)
|
setMedia(m)
|
||||||
const isDirect = isDirectStreamMedia(m)
|
|
||||||
const auto = pickPlayerMode(m)
|
|
||||||
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
|
|
||||||
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
|
|
||||||
setPlayerError('')
|
setPlayerError('')
|
||||||
setLoadError('')
|
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) => {
|
.catch((err: unknown) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -541,7 +560,21 @@ export function PlayerPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
|
||||||
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
|
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
|
||||||
@@ -1083,32 +1116,72 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let seekStarted = false
|
||||||
let onSeeked: (() => void) | undefined
|
let onSeeked: (() => void) | undefined
|
||||||
const applyResume = () => {
|
let onPlaying: (() => void) | undefined
|
||||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
let onCanPlay: (() => void) | undefined
|
||||||
onSeeked = () => {
|
|
||||||
setInitialSeekDone(true)
|
const notifyResume = () => {
|
||||||
const m = Math.floor(resumePosition / 60)
|
const m = Math.floor(resumePosition / 60)
|
||||||
const s = Math.floor(resumePosition % 60)
|
const s = Math.floor(resumePosition % 60)
|
||||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||||
}
|
}
|
||||||
video.addEventListener('seeked', onSeeked, { once: true })
|
|
||||||
video.currentTime = resumePosition
|
const performResumeSeek = () => {
|
||||||
|
if (cancelled || seekStarted) return
|
||||||
|
if (Math.abs(video.currentTime - resumePosition) <= 2) {
|
||||||
|
seekStarted = true
|
||||||
|
setInitialSeekDone(true)
|
||||||
return
|
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 {
|
} else {
|
||||||
video.addEventListener('canplay', applyResume, { once: true })
|
onCanPlay = () => {
|
||||||
|
performResumeSeek()
|
||||||
|
}
|
||||||
|
video.addEventListener('canplay', onCanPlay, { once: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
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)
|
if (onSeeked) video.removeEventListener('seeked', onSeeked)
|
||||||
}
|
}
|
||||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer])
|
||||||
|
|
||||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||||
const hlsStartSecRef = useRef(hlsStartSec)
|
const hlsStartSecRef = useRef(hlsStartSec)
|
||||||
@@ -1762,6 +1835,7 @@ export function PlayerPage() {
|
|||||||
elementSrc: video?.src ?? '',
|
elementSrc: video?.src ?? '',
|
||||||
expectedSrc,
|
expectedSrc,
|
||||||
alreadyRetried: directRetryRef.current,
|
alreadyRetried: directRetryRef.current,
|
||||||
|
seekGraceActive: Date.now() < directSeekGraceUntilRef.current,
|
||||||
})
|
})
|
||||||
if (action === 'ignore') return
|
if (action === 'ignore') return
|
||||||
|
|
||||||
@@ -1779,6 +1853,8 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
const fallbackDirectPlay = () => {
|
const fallbackDirectPlay = () => {
|
||||||
if (modeRef.current !== 'direct') return
|
if (modeRef.current !== 'direct') return
|
||||||
|
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
|
||||||
|
if (Date.now() < directSeekGraceUntilRef.current) return
|
||||||
const current = ref.current
|
const current = ref.current
|
||||||
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
|
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||||
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
|
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
|
||||||
@@ -1813,7 +1889,9 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearFallbackTimer()
|
clearFallbackTimer()
|
||||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
// STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。
|
||||||
|
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
|
||||||
|
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
|
||||||
}, [
|
}, [
|
||||||
clearFallbackTimer,
|
clearFallbackTimer,
|
||||||
directOnly,
|
directOnly,
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback'
|
|||||||
const MEDIA_ERR_ABORTED = 1
|
const MEDIA_ERR_ABORTED = 1
|
||||||
const HAVE_CURRENT_DATA = 2
|
const HAVE_CURRENT_DATA = 2
|
||||||
|
|
||||||
|
/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */
|
||||||
|
export const DIRECT_SEEK_GRACE_MS = 12_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
|
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
|
||||||
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
|
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
|
||||||
|
*
|
||||||
|
* seekGraceActive:程序化续播/跳转后的宽限期。115 STRM 在 302 直链上
|
||||||
|
* seek 时 Chromium 经常误报 decode/network error,此时绝不能升到 HLS。
|
||||||
*/
|
*/
|
||||||
export function classifyDirectPlayError(input: {
|
export function classifyDirectPlayError(input: {
|
||||||
errorCode: number | undefined | null
|
errorCode: number | undefined | null
|
||||||
@@ -13,12 +19,15 @@ export function classifyDirectPlayError(input: {
|
|||||||
elementSrc: string
|
elementSrc: string
|
||||||
expectedSrc: string
|
expectedSrc: string
|
||||||
alreadyRetried: boolean
|
alreadyRetried: boolean
|
||||||
|
seekGraceActive?: boolean
|
||||||
}): DirectPlayErrorAction {
|
}): DirectPlayErrorAction {
|
||||||
const code = input.errorCode ?? 0
|
const code = input.errorCode ?? 0
|
||||||
if (code === MEDIA_ERR_ABORTED) return 'ignore'
|
if (code === MEDIA_ERR_ABORTED) return 'ignore'
|
||||||
if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore'
|
if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore'
|
||||||
if (!input.elementSrc) return 'ignore'
|
if (!input.elementSrc) return 'ignore'
|
||||||
if (input.expectedSrc && input.elementSrc !== input.expectedSrc) 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'
|
if (!input.alreadyRetried) return 'retry'
|
||||||
return 'fallback'
|
return 'fallback'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,28 @@ check(
|
|||||||
}) === 'fallback',
|
}) === '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')
|
console.log('playerPageModel.test.ts ok')
|
||||||
|
|||||||
Reference in New Issue
Block a user