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