diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 2859eac..f67c762 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -37,7 +37,8 @@ import { type SubtitleStylePreset, } from '../utils/subtitleDisplay' import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel' -import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError' +import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError' +import type { DirectPlayFailureReason } from './directPlayError' import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek' import { apiErrorMessage } from './StrmManagePage' import { PlayerTopBar } from './PlayerTopBar' @@ -200,6 +201,13 @@ export function PlayerPage() { // 必须在切换前先记下来,等直连 loadedmetadata 后再跳。 const pendingDirectStartRef = useRef(0) const hlsSourceRef = useRef<'cloud' | 'local'>('local') + // 起播看门狗要等到 20 秒后才判定,判定时用 ref 读最新的「直连解码」开关,避免 + // 把回调塞进播放 effect 的依赖里反复重建。 + const directOnlyRef = useRef(false) + // 当前挂在画面上的「播放失败」横幅文案。这类提示描述的是「刚才那一下没起来」, + // 画面真正播起来之后必须撤掉:上游冷启动慢 10 秒就会先误报一次,而播放随后 + // 是正常的,留在画面上就成了「编码不兼容」的假结论。 + const transientPlayErrorRef = useRef('') // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 const [danmakuOpen, setDanmakuOpen] = useState(false) @@ -519,6 +527,7 @@ export function PlayerPage() { directRetryRef.current = false retryingDirectRef.current = false directSeekGraceUntilRef.current = 0 + transientPlayErrorRef.current = '' pendingDirectSeekRef.current = null directSeekWaitCleanupRef.current?.() directSeekWaitCleanupRef.current = null @@ -553,6 +562,24 @@ export function PlayerPage() { } }, []) + /** + * 挂一条「播放起不来」的横幅。走这个入口的提示都会记下来,一旦画面真的开始 + * 播放(playing)就被撤掉;切媒体、切线路时也会清空。信息类提示(例如「可能 + * 只有画面没有声音」)不走这里,它们播起来之后依然成立。 + */ + const showPlayFailure = useCallback((message: string) => { + transientPlayErrorRef.current = message + setPlayerError(message) + }, []) + + const clearPlayFailure = useCallback(() => { + const stale = transientPlayErrorRef.current + if (!stale) return + transientPlayErrorRef.current = '' + // 只撤掉自己挂的那条:期间可能已经换了别的提示。 + setPlayerError((prev) => (prev === stale ? '' : prev)) + }, []) + const clearDirectSeekWait = useCallback(() => { directSeekWaitCleanupRef.current?.() directSeekWaitCleanupRef.current = null @@ -742,6 +769,7 @@ export function PlayerPage() { if (cancelled) return setMedia(m) setPlayerError('') + transientPlayErrorRef.current = '' setLoadError('') const isDirect = isDirectStreamMedia(m) const auto = pickPlayerMode(m) @@ -1145,24 +1173,30 @@ export function PlayerPage() { const mediaRef = useRef(media) useEffect(() => { mediaRef.current = media - }, [media]) + directOnlyRef.current = directOnly + }, [directOnly, media]) /** * 直连失败后的统一回退:按当前源与设置决定切 115 云转码 / 本地转码 / 明确报错。 * error 分支与起播看门狗(armDirectStallWatchdog)共用同一套判定。 + * + * reason:'stall' = 看门狗等到期还没数据;'error' = 媒体元素报了 error(重试过 + * 一次仍然失败)。两者文案必须分开——超时的真实原因多半是上游慢,不是编码不兼容。 */ - const fallbackDirectPlay = useCallback(() => { + const fallbackDirectPlay = useCallback((reason: DirectPlayFailureReason = 'error') => { 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)) { - setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。') - toast.error('直接播放失败,建议使用外部播放器') + const copy = directPlayFailureCopy('remote-emby', reason) + showPlayFailure(copy.banner) + toast.error(copy.toast) } else if (directOnly) { - setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。') - toast.error('直接播放失败(客户端直连解码模式)') + const copy = directPlayFailureCopy('direct-only', reason) + showPlayFailure(copy.banner) + toast.error(copy.toast) } else if (playbackInfo?.provider === 'cloud115') { const preferred = findPlaybackQualityById(playbackInfo, selectedQuality) ?? @@ -1180,7 +1214,7 @@ export function PlayerPage() { switchToLocalHLS(current?.currentTime || 0) } } else if (hlsUnavailable) { - setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。') + showPlayFailure('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。') toast.error('直接播放失败,HLS 转码不可用') } else { toast.error('直接播放失败,切换到 HLS 转码') @@ -1192,23 +1226,25 @@ export function PlayerPage() { playbackInfo, selectedQuality, setPlaybackMode, + showPlayFailure, startCloudTranscode, switchToLocalHLS, ]) /** * 安排一次直连回退判定。STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判失败。 + * 只由 handleVideoError(媒体元素真的报了 error)触发,所以原因按 'error' 记。 */ const scheduleDirectFallback = useCallback(() => { clearFallbackTimer() const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500 - fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs) + fallbackTimerRef.current = setTimeout(() => fallbackDirectPlay('error'), fallbackDelayMs) }, [clearFallbackTimer, fallbackDirectPlay]) // 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用 // 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走 // 115 云转码的条目误判成本地转码。 - const fallbackDirectPlayRef = useRef<() => void>(() => {}) + const fallbackDirectPlayRef = useRef<(reason?: DirectPlayFailureReason) => void>(() => {}) useEffect(() => { fallbackDirectPlayRef.current = fallbackDirectPlay }, [fallbackDirectPlay]) @@ -1222,17 +1258,27 @@ export function PlayerPage() { * 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器: * 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到 * metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。 + * + * 期限本身要按「失败之后还有没有补救动作」来定:能切 HLS 的源早点换线路对 + * 用户有利;远程 Emby / 直连解码没有可退的线路,等不到数据时唯一的产出是 + * 一条提示,所以必须容忍跨网上游的冷启动(见 directStallTimeoutMs)。 */ const armDirectStallWatchdog = useCallback(() => { clearFallbackTimer() - const timeoutMs = isStrmMedia(mediaRef.current) ? 12000 : 6000 + const currentMedia = mediaRef.current + const canFallback = !isDirectStreamMedia(currentMedia) && !directOnlyRef.current + const timeoutMs = directStallTimeoutMs({ + isStrm: isStrmMedia(currentMedia), + canFallback, + }) fallbackTimerRef.current = setTimeout(() => { fallbackTimerRef.current = null if (modeRef.current !== 'direct') return if (Date.now() < directSeekGraceUntilRef.current) return const current = ref.current if (current && current.readyState >= HTMLMediaElement.HAVE_METADATA) return - fallbackDirectPlayRef.current() + // 这里只可能是「一直没数据」,按超时文案报,不要说成编码不兼容。 + fallbackDirectPlayRef.current('stall') }, timeoutMs) }, [clearFallbackTimer]) @@ -1313,10 +1359,12 @@ export function PlayerPage() { } setHlsUnavailable(true) if (playbackProvider === 'cloud115') { - setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。') + showPlayFailure('115 云端和本地转码均不可用,请检查账号授权或稍后重试。') toast.error('115 云端和本地转码均不可用') return } + // 这条同时是「为什么可能没声音」的说明,播起来之后依然成立, + // 所以不走 showPlayFailure(不随 playing 撤掉)。 setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。') toast.error('HLS 转码失败,尝试切换到直接播放') setPlaybackMode('direct') @@ -1337,14 +1385,14 @@ export function PlayerPage() { void video.play().catch(() => undefined) } else { setHlsUnavailable(true) - setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。') + showPlayFailure('当前浏览器不支持 HLS,正在尝试直接播放。') toast.error('当前浏览器不支持 HLS,降级到直接播放') setPlaybackMode('direct') } }).catch(() => { if (cancelled) return setHlsUnavailable(true) - setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。') + showPlayFailure('HLS 播放组件加载失败,正在尝试直接播放。') setPlaybackMode('direct') }) } else { @@ -1393,7 +1441,11 @@ export function PlayerPage() { setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。') } } - const onPlaying = () => clearFallbackTimer() + const onPlaying = () => { + clearFallbackTimer() + // 画面真的动了:之前挂的「起不来」提示一律作废。 + clearPlayFailure() + } video.addEventListener('playing', onPlaying) return () => { cancelled = true @@ -1404,6 +1456,7 @@ export function PlayerPage() { activeBurnedSubtitleStream, armDirectStallWatchdog, clearFallbackTimer, + clearPlayFailure, cloudReloadNonce, cloudWaiting, hlsSource, @@ -1416,6 +1469,7 @@ export function PlayerPage() { seekStrmDirectTo, selectedQuality, setPlaybackMode, + showPlayFailure, switchToLocalHLS, teardownHls, vr360DirectProxy, @@ -2253,7 +2307,7 @@ export function PlayerPage() { const handleVideoError = useCallback(() => { const video = ref.current if (mode !== 'direct') { - setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。') + showPlayFailure('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。') toast.error('视频播放失败,请检查文件是否存在') return } @@ -2300,7 +2354,7 @@ export function PlayerPage() { } scheduleDirectFallback() - }, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo]) + }, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure]) const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => { setSubtitlePosition(nextPosition) diff --git a/web/src/pages/directPlayError.ts b/web/src/pages/directPlayError.ts index e5f3cd5..f523916 100644 --- a/web/src/pages/directPlayError.ts +++ b/web/src/pages/directPlayError.ts @@ -6,6 +6,65 @@ const HAVE_CURRENT_DATA = 2 /** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */ export const DIRECT_SEEK_GRACE_MS = 12_000 +/** 直连起播看门狗的等待上限(毫秒)。 */ +export const DIRECT_STALL_TIMEOUT_MS = 6_000 +/** STRM / 云盘直链要等 302 换链,冷启动比本地文件慢,给长一点的期限。 */ +export const DIRECT_STALL_TIMEOUT_STRM_MS = 12_000 +/** + * 没有可退线路(远程 Emby 挂载、客户端直连解码)时的等待上限。 + * + * 这两种源都不在宿主机转码,等不到数据时没有任何补救动作——唯一的输出是给用户 + * 一条提示,所以宁可多等:跨网上游的冷启动实测要 8–11 秒才吐第一段数据,6 秒 + * 就判失败只会把「上游慢」写成「浏览器不支持这个编码」,而画面其实几秒后就播上了。 + */ +export const DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS = 20_000 + +/** + * 起播看门狗该等多久才把「还没起来」当成「起不来」。 + * + * canFallback=false:直连失败没有可切的线路(远程 Emby / 直连解码),失败提示 + * 是唯一产出,所以要容忍慢启动。canFallback=true:还能切 HLS,早点换线路对用户 + * 更有利,保持较短期限。 + */ +export function directStallTimeoutMs(input: { isStrm?: boolean; canFallback: boolean }): number { + if (!input.canFallback) return DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS + return input.isStrm ? DIRECT_STALL_TIMEOUT_STRM_MS : DIRECT_STALL_TIMEOUT_MS +} + +/** 直连起播失败的两类起因:看门狗等超时(上游一直没数据)/ 媒体元素真的报了 error。 */ +export type DirectPlayFailureReason = 'stall' | 'error' + +/** 直连失败时用户看到的来源:远程 Emby 挂载 / 客户端直连解码。 */ +export type DirectPlayFailureKind = 'remote-emby' | 'direct-only' + +/** + * 直连失败的横幅与 toast 文案。 + * + * 超时和真报错的文案必须分开:超时的真实原因是上游慢,不该断言「浏览器不支持这个 + * 编码」(实测最常见的情形就是慢,画面随后自己就播上了)。 + */ +export function directPlayFailureCopy( + kind: DirectPlayFailureKind, + reason: DirectPlayFailureReason = 'error', +): { banner: string; toast: string } { + if (reason === 'stall') { + return { + banner: + kind === 'remote-emby' + ? '直接播放失败:远程 Emby 挂载的流长时间没有返回数据(该线路不转码,只能直连)。可稍后重试,或用外部播放器(如 PotPlayer / VLC / IINA)播放。' + : '直接播放失败:客户端直连解码模式下宿主机不转码,流长时间没有返回数据。可稍后重试,或关闭直连解码模式改用转码播放。', + toast: '直连流长时间没有响应', + } + } + return { + banner: + kind === 'remote-emby' + ? '直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。' + : '直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。', + toast: '直接播放失败,建议使用外部播放器', + } +} + /** * Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek) * 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。 diff --git a/web/src/pages/playerPageModel.test.ts b/web/src/pages/playerPageModel.test.ts index caef6bb..443d70d 100644 --- a/web/src/pages/playerPageModel.test.ts +++ b/web/src/pages/playerPageModel.test.ts @@ -1,4 +1,11 @@ -import { classifyDirectPlayError } from './directPlayError.ts' +import { + classifyDirectPlayError, + directPlayFailureCopy, + directStallTimeoutMs, + DIRECT_STALL_TIMEOUT_MS, + DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS, + DIRECT_STALL_TIMEOUT_STRM_MS, +} from './directPlayError.ts' function check(name: string, cond: boolean) { if (!cond) throw new Error(`classifyDirectPlayError: ${name}`) @@ -96,4 +103,36 @@ check( }) === 'ignore', ) +// 起播看门狗的期限:没有可退线路(远程 Emby / 直连解码)时必须容忍慢启动, +// 否则上游冷启动的 8–11 秒会被误报成「编码不兼容」。 +check('local direct stall waits 6s', directStallTimeoutMs({ canFallback: true }) === DIRECT_STALL_TIMEOUT_MS) +check( + 'strm direct stall waits 12s', + directStallTimeoutMs({ isStrm: true, canFallback: true }) === DIRECT_STALL_TIMEOUT_STRM_MS, +) +check( + 'no fallback line waits 20s', + directStallTimeoutMs({ canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS, +) +check( + 'no fallback line ignores strm tuning', + directStallTimeoutMs({ isStrm: true, canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS, +) + +const stallCopy = directPlayFailureCopy('remote-emby', 'stall') +check('stall copy blames the upstream, not the codec', !stallCopy.banner.includes('不支持')) +check('stall copy keeps the failure prefix', stallCopy.banner.startsWith('直接播放失败')) +const decodeCopy = directPlayFailureCopy('remote-emby', 'error') +check('decode copy still mentions unsupported codec', decodeCopy.banner.includes('不支持')) +check( + 'copy defaults to the decode wording', + directPlayFailureCopy('remote-emby').banner === decodeCopy.banner, +) +check( + 'direct-only copy names the client-decode mode', + directPlayFailureCopy('direct-only', 'stall').banner.includes('客户端直连解码') && + directPlayFailureCopy('direct-only', 'error').banner.includes('客户端直连解码'), +) +check('stall toast is not the external-player advice', stallCopy.toast !== decodeCopy.toast) + console.log('playerPageModel.test.ts ok')