Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 594166f696 bug处理 2026-09-25 20:30:49 +08:00
truewhile e0b07492a5 优化网页播放跳转 2026-09-25 13:24:10 +08:00
truewhile 3be6978e70 优化 2026-09-25 13:10:23 +08:00
6 changed files with 532 additions and 55 deletions
+382 -54
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import type Hls from 'hls.js'
import toast from 'react-hot-toast'
@@ -36,7 +36,8 @@ 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 { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
@@ -58,6 +59,10 @@ import {
// ?mode=hls force HLS even when direct play would work
// ?mode=direct force direct play (default for browser-friendly codecs)
//
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
// 重跑,把刚切好的 HLS 打回直连(表现为「切了 HLS 还是直连播放」)。
//
// We pick a sensible default based on the source codec: H.264 + AAC in
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
@@ -96,7 +101,6 @@ function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const navigate = useNavigate()
const location = useLocation()
@@ -106,6 +110,17 @@ export function PlayerPage() {
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
const directSeekGraceUntilRef = useRef(0)
// STRM 直连跳转目标;seekable 未覆盖时会等到可跳再写 currentTime。
const pendingDirectSeekRef = useRef<number | null>(null)
const directSeekWaitCleanupRef = useRef<(() => void) | null>(null)
// 每个媒体加载时只读一次 URL 上的 ?mode=(媒体详情页「HLS 兼容转码播放」入口依赖它)。
// 播放模式本身不再写回 URL:写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
// 重跑,把刚切好的 HLS 打回直连(表现为「切了 HLS 还是直连播放」)。
const requestedModeRef = useRef<PlayerMode | null>(null)
// URL 明确要求 HLS 时用它把「选线路」推迟到播放能力返回之后(云 HLS 优先,其次本地)。
const initialHlsRequestRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
@@ -485,6 +500,16 @@ export function PlayerPage() {
setDanmakuSearching(true)
directRetryRef.current = false
retryingDirectRef.current = false
directSeekGraceUntilRef.current = 0
pendingDirectSeekRef.current = null
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
// 换片子时重新读一次 URL 上的强制模式;没有就按片源自动判定。
const urlMode = new URLSearchParams(window.location.search).get('mode')
requestedModeRef.current = urlMode === 'hls' || urlMode === 'direct' ? urlMode : null
// URL 明确要求 HLS 时,等播放能力拿到后还要选一次线路(云 HLS 优先)。
initialHlsRequestRef.current = urlMode === 'hls'
setMode('direct')
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
@@ -497,18 +522,11 @@ export function PlayerPage() {
}
}, [id])
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
const modeParam = params.get('mode') as PlayerMode | null
const setPlaybackMode = useCallback(
(next: PlayerMode) => {
setMode(next)
const nextParams = new URLSearchParams(window.location.search)
nextParams.set('mode', next)
setParams(nextParams, { replace: true })
},
[setParams],
)
const setPlaybackMode = useCallback((next: PlayerMode) => {
// 一旦显式决定过播放方式,URL 上的 ?mode= 就不再是权威(避免它把状态打回去)。
requestedModeRef.current = null
setMode(next)
}, [])
const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) {
@@ -517,6 +535,185 @@ export function PlayerPage() {
}
}, [])
const clearDirectSeekWait = useCallback(() => {
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
}, [])
/**
* STRM/115 直连跳转:等 seekable 覆盖目标再写 currentTime,避免被钳回 0;
* 同时打开误报宽限期,防止 seek 触发 video.load() 从头播。
*
* 超过 timeoutMs 仍未落点就直接放弃并回调 onStalled,绝不把播放器挂在
* 「正在定位」状态里(之前的表现就是「跳转等半天没反应」)。
*/
const seekStrmDirectTo = useCallback(
(
absoluteSec: number,
options?: {
onSettled?: () => void
onStalled?: (target: number) => void
timeoutMs?: number
},
) => {
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
const timeoutMs = options?.timeoutMs ?? DIRECT_SEEK_WAIT_MS
const wasPlaying = !video.paused
pendingDirectSeekRef.current = target
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
directRetryRef.current = false
clearFallbackTimer()
clearDirectSeekWait()
let intervalId: ReturnType<typeof setInterval> | null = null
let applied = false
let attempts = 0
let toastShown = false
const startedAt = Date.now()
const cleanup = () => {
video.removeEventListener('progress', tick)
video.removeEventListener('durationchange', tick)
video.removeEventListener('loadedmetadata', tick)
video.removeEventListener('seeked', onSeeked)
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
if (directSeekWaitCleanupRef.current === cleanup) {
directSeekWaitCleanupRef.current = null
}
}
const finish = (ok: boolean) => {
cleanup()
pendingDirectSeekRef.current = null
if (!ok) {
options?.onStalled?.(target)
return
}
// 定位完成后继续播:用户拖动时视频通常还在播,别让它停在暂停态。
if (wasPlaying) void video.play().catch(() => undefined)
options?.onSettled?.()
}
const settleIfMatched = () => {
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) return false
if (Math.abs(el.currentTime - target) <= 2.5) {
finish(true)
return true
}
return false
}
const writeCurrentTime = (want: number) => {
const el = ref.current
if (!el) return false
selfSkipSeekRef.current = true
try {
el.currentTime = want
} catch {
return false
}
applied = true
attempts += 1
// 续期宽限:Range 拉取关键帧期间仍可能误报 error。
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
return true
}
const onSeeked = () => {
if (settleIfMatched()) return
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) return
// 被钳回开头:允许 seekable 就绪后再写一次。
if (el.currentTime < 1.5 && target > 3) {
applied = false
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
}
}
const tick = () => {
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) {
cleanup()
return
}
if (settleIfMatched()) return
const elapsed = Date.now() - startedAt
if (elapsed >= timeoutMs + 2500) {
// 给过一次强制写入的机会仍然没落点:交给 HLS,别继续等。
finish(false)
return
}
const canSeek = canSeekDirectNow(el.seekable, target)
const overCap = elapsed >= timeoutMs
if (!applied && (canSeek || overCap)) {
writeCurrentTime(target)
return
}
if (applied && attempts < 3 && canSeek && el.currentTime < 1.5 && target > 3) {
writeCurrentTime(target)
return
}
if (!toastShown && elapsed > 700 && !applied) {
toastShown = true
toast('正在定位播放位置…', { duration: 2200 })
}
}
video.addEventListener('progress', tick)
video.addEventListener('durationchange', tick)
video.addEventListener('loadedmetadata', tick)
video.addEventListener('seeked', onSeeked)
intervalId = setInterval(tick, 250)
directSeekWaitCleanupRef.current = cleanup
tick()
return true
},
[clearDirectSeekWait, clearFallbackTimer],
)
/**
* 直连定位确实做不到时(例如 115 直链上 Chrome 无法跳到目标关键帧),
* 用 HLS 接管并跳到同一个位置,而不是把用户晾在原地。
*/
const handleDirectSeekStalled = useCallback(
(target: number) => {
const cloudQuality =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo?.default_quality ?? '')
if (cloudQuality?.source === 'cloud' && cloudQuality.available) {
setSelectedQuality(cloudQuality.id)
setHlsSource('cloud')
setCloudWaiting(false)
setCloudWaitMessage('')
pendingSeekRef.current = target > 2 ? target : null
setPlaybackMode('hls')
toast('直连定位失败,已切到 115 云 HLS 跳转')
return
}
const localQuality =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, defaultLocalQualityId(playbackInfo))
if (localQuality?.available) {
setSelectedQuality(localQuality.id)
setHlsSource('local')
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsStartSec(target)
setPlaybackMode('hls')
toast('直连定位失败,已切到本地 HLS 跳转')
return
}
toast.error('直连定位失败,且当前没有可用的 HLS 转码档位')
},
[playbackInfo, selectedQuality, setPlaybackMode],
)
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
@@ -526,12 +723,23 @@ 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:强制直连。
if (directOnly || isDirect) {
setMode('direct')
return
}
// URL 上带 ?mode= 时以它为准(详情页「HLS 兼容转码播放」入口);
// 否则按片源自动判定,但不要用滞后的 get 回调把用户手动切的 HLS 盖回直连。
const requested = requestedModeRef.current
if (requested) {
setMode(requested)
return
}
setMode((prev) => (prev === 'hls' ? prev : auto))
})
.catch((err: unknown) => {
if (cancelled) return
@@ -541,7 +749,7 @@ export function PlayerPage() {
return () => {
cancelled = true
}
}, [id, modeParam, directOnly])
}, [id, directOnly])
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
@@ -771,24 +979,29 @@ export function PlayerPage() {
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number) => {
setHlsStartSec(startSec)
(startSec: number, infoOverride?: PlaybackInfo | null) => {
const position = Math.max(0, startSec)
// 允许传入刚拿到的能力信息:从 URL 强制 HLS 时不想等到 state 更新后才选线路。
const info = infoOverride ?? playbackInfo
setHlsStartSec(position)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
info?.provider === 'cloud115'
? findPlaybackQualityById(info, selectedQuality) ??
findPlaybackQualityById(info, info.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
// 云 HLS 是整段时间轴:切过去后按当前位置跳一下,别让用户从头看。
pendingSeekRef.current = position > 2 ? position : null
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS(startSec)
switchToLocalHLS(position)
}
setPlaybackMode('hls')
},
@@ -840,6 +1053,15 @@ export function PlayerPage() {
}
}, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS])
// 从 URL 强制 HLS(媒体详情页「HLS 兼容转码播放」)时,等播放能力返回后再选线路:
// 115 有可用的云转码档位就走云 HLS,否则退回本地转码。只做一次。
useEffect(() => {
if (!playbackInfo || !initialHlsRequestRef.current) return
if (directOnly || !mediaId) return
initialHlsRequestRef.current = false
enterHlsPlayback(0, playbackInfo)
}, [directOnly, enterHlsPlayback, mediaId, playbackInfo])
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
useEffect(() => {
if (!mediaId) return
@@ -1083,32 +1305,92 @@ export function PlayerPage() {
}
const video = ref.current
if (!video) return
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 cancelled = false
let seekStarted = false
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
if (isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(resumePosition, {
timeoutMs: 6000,
onSettled: () => {
if (!cancelled) {
setInitialSeekDone(true)
notifyResume()
}
},
onStalled: () => {
// 定位不成就从头播,别把用户挂在「正在定位」上。
if (cancelled) return
setInitialSeekDone(true)
try {
video.currentTime = 0
} catch {
// ignore
}
void video.play().catch(() => undefined)
toast('续播定位超时,已从头播放', { duration: 2600 })
},
})
return
}
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
directRetryRef.current = false
clearFallbackTimer()
const 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。
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)
if (onSeeked) video.removeEventListener('seeked', onSeeked)
cancelled = true
if (onPlaying) video.removeEventListener('playing', onPlaying)
if (onCanPlay) video.removeEventListener('canplay', onCanPlay)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
@@ -1434,7 +1716,8 @@ export function PlayerPage() {
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
const qualityLabel =
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
// 播放方式标签:直接说清「这条片子现在是怎么在播」,避免用户猜线路
//(115 直链 / 115 云 HLS / 本地转码 / 客户端解码)。
const playbackModeLabel = isDirectStream
? isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
@@ -1442,8 +1725,12 @@ export function PlayerPage() {
: directOnly
? '客户端直连解码'
: mode === 'hls'
? 'HLS 转码'
: '直接播放'
? hlsSource === 'cloud'
? '115 云 HLS 转码'
: '本地 HLS 转码'
: playbackInfo?.provider === 'cloud115'
? '直连播放 · 115 直链'
: '直接播放'
const canTogglePlaybackMode = !isDirectStream && !directOnly
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
@@ -1496,7 +1783,8 @@ export function PlayerPage() {
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
enterHlsPlayback(0)
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
enterHlsPlayback(ref.current?.currentTime || 0)
} else {
setCloudWaiting(false)
}
@@ -1588,10 +1876,30 @@ export function PlayerPage() {
video.currentTime = Math.max(0, target - hlsStartSec)
return
}
if (isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(target, { onStalled: handleDirectSeekStalled })
return
}
selfSkipSeekRef.current = true
video.currentTime = target
},
[handleSeekAbsolute, hlsSource, hlsStartSec, mode],
[handleDirectSeekStalled, handleSeekAbsolute, hlsSource, hlsStartSec, mode, seekStrmDirectTo],
)
const handlePlayerSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode === 'hls' && hlsSource === 'local') {
return handleSeekAbsolute(absoluteSec)
}
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
return seekStrmDirectTo(absoluteSec, {
timeoutMs: 4500,
onStalled: handleDirectSeekStalled,
})
}
return false
},
[handleDirectSeekStalled, handleSeekAbsolute, hlsSource, mode, seekStrmDirectTo],
)
const showSkipNotice = useCallback(
@@ -1762,6 +2070,7 @@ export function PlayerPage() {
elementSrc: video?.src ?? '',
expectedSrc,
alreadyRetried: directRetryRef.current,
seekGraceActive: Date.now() < directSeekGraceUntilRef.current,
})
if (action === 'ignore') return
@@ -1769,8 +2078,17 @@ export function PlayerPage() {
directRetryRef.current = true
retryingDirectRef.current = true
try {
video.load()
void video.play().catch(() => undefined)
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
const pending = pendingDirectSeekRef.current
void video.play().catch(() => undefined)
if (pending !== null && pending > 1) {
seekStrmDirectTo(pending)
}
} else {
video.load()
void video.play().catch(() => undefined)
}
} finally {
retryingDirectRef.current = false
}
@@ -1779,6 +2097,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 +2133,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,
@@ -1821,6 +2143,7 @@ export function PlayerPage() {
mediaId,
mode,
playbackInfo,
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
@@ -1855,6 +2178,7 @@ export function PlayerPage() {
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
modeLabel={playbackModeLabel}
mode={mode}
onBack={goBack}
/>
@@ -1903,7 +2227,11 @@ export function PlayerPage() {
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
onSeekAbsolute={
(mode === 'hls' && hlsSource === 'local') || (mode === 'direct' && isStrmMedia(media))
? handlePlayerSeekAbsolute
: undefined
}
qualities={qualityOptions}
qualityLabel={qualityLabel}
selectedQuality={selectedQuality}
+5 -1
View File
@@ -18,6 +18,8 @@ type PlayerTopBarProps = {
directOnly: boolean
isDirectStream?: boolean
directStreamLabel?: string
/** 已算好的播放方式文字(如「直连播放 · 115 直链」「115 云 HLS 转码」);传入时优先展示。 */
modeLabel?: string
mode: PlayerMode
onBack: () => void
}
@@ -28,10 +30,11 @@ export function PlayerTopBar({
directOnly,
isDirectStream,
directStreamLabel,
modeLabel,
mode,
onBack,
}: PlayerTopBarProps) {
const status = isDirectStream
const derived = isDirectStream
? {
icon: <Sparkles size={12} />,
label: directStreamLabel || '直连播放',
@@ -56,6 +59,7 @@ export function PlayerTopBar({
label: '直接播放',
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
}
const status = modeLabel ? { ...derived, label: modeLabel } : derived
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
+9
View File
@@ -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'
}
+52
View File
@@ -0,0 +1,52 @@
import {
canSeekDirectNow,
seekableEndSec,
timeInSeekableRanges,
} from './directPlaySeek.ts'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`directPlaySeek: ${name}`)
}
function ranges(parts: Array<[number, number]>) {
return {
length: parts.length,
start: (i: number) => parts[i][0],
end: (i: number) => parts[i][1],
}
}
check('empty seekable rejects mid seek', !timeInSeekableRanges(null, 120))
check('empty seekable end is 0', seekableEndSec(undefined) === 0)
check(
'covered range accepts target',
timeInSeekableRanges(ranges([[0, 600]]), 120),
)
check(
'target beyond end is rejected',
!timeInSeekableRanges(ranges([[0, 30]]), 120),
)
check(
'multi-range uses any segment',
timeInSeekableRanges(
ranges([
[0, 10],
[100, 200],
]),
150,
),
)
check('seekableEndSec takes max end', seekableEndSec(ranges([[0, 10], [5, 80]])) === 80)
check('canSeekDirectNow allows near-zero without seekable', canSeekDirectNow(null, 0.2))
check('canSeekDirectNow blocks mid when not seekable', !canSeekDirectNow(null, 90))
check(
'canSeekDirectNow allows mid when covered',
canSeekDirectNow(ranges([[0, 900]]), 90),
)
console.log('directPlaySeek.test.ts ok')
+60
View File
@@ -0,0 +1,60 @@
/** STRM/115 直连跳转:等 seekable 覆盖目标后再写 currentTime,避免被钳回 0。 */
export const DIRECT_SEEK_WAIT_MS = 10_000
export const DIRECT_SEEK_MARGIN_SEC = 0.35
type SeekableLike = {
length: number
start: (index: number) => number
end: (index: number) => number
}
/** 目标时间是否落在任一 seekable 区间内(末端留一点边距)。 */
export function timeInSeekableRanges(
seekable: SeekableLike | null | undefined,
time: number,
margin = DIRECT_SEEK_MARGIN_SEC,
): boolean {
if (!seekable || seekable.length <= 0) return false
if (!Number.isFinite(time) || time < 0) return false
for (let i = 0; i < seekable.length; i += 1) {
try {
const start = seekable.start(i)
const end = seekable.end(i)
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
if (time + margin >= start && time <= end + margin) return true
} catch {
// TimeRanges 在部分浏览器上可能抛 InvalidStateError,跳过该段。
}
}
return false
}
/** seekable 已覆盖的最右端(秒);没有可用区间时返回 0。 */
export function seekableEndSec(seekable: SeekableLike | null | undefined): number {
if (!seekable || seekable.length <= 0) return 0
let max = 0
for (let i = 0; i < seekable.length; i += 1) {
try {
const end = seekable.end(i)
if (Number.isFinite(end) && end > max) max = end
} catch {
// ignore
}
}
return max
}
/**
* 直连是否可以立刻跳到 target。
* - seekable 已覆盖 → 可以
* - 目标接近 0 → 可以(不需要等)
* - 否则应等待 progress / durationchange
*/
export function canSeekDirectNow(
seekable: SeekableLike | null | undefined,
time: number,
): boolean {
if (!Number.isFinite(time) || time <= 1) return true
return timeInSeekableRanges(seekable, time)
}
+24
View File
@@ -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')