From e0b07492a59f4d38ed92e7ba17a7bc82f503265e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:24:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BD=91=E9=A1=B5=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/PlayerPage.tsx | 206 +++++++++++++++++++++++++-- web/src/pages/directPlaySeek.test.ts | 52 +++++++ web/src/pages/directPlaySeek.ts | 60 ++++++++ 3 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 web/src/pages/directPlaySeek.test.ts create mode 100644 web/src/pages/directPlaySeek.ts diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 2f2f0ca..7a026b7 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -37,6 +37,7 @@ import { } from '../utils/subtitleDisplay' import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel' 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' @@ -108,6 +109,9 @@ export function PlayerPage() { const retryingDirectRef = useRef(false) // 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。 const directSeekGraceUntilRef = useRef(0) + // STRM 直连跳转目标;seekable 未覆盖时会等到可跳再写 currentTime。 + const pendingDirectSeekRef = useRef(null) + const directSeekWaitCleanupRef = useRef<(() => void) | null>(null) // 切集时清掉旧 ?mode= 的同一轮渲染里,先挡住 modeParam effect 把 hls 写回来。 const ignoreModeParamRef = useRef(false) const fallbackTimerRef = useRef | null>(null) @@ -490,6 +494,9 @@ export function PlayerPage() { directRetryRef.current = false retryingDirectRef.current = false directSeekGraceUntilRef.current = 0 + pendingDirectSeekRef.current = null + directSeekWaitCleanupRef.current?.() + directSeekWaitCleanupRef.current = null // 新片子清掉上一部失败回退留下的 ?mode=hls,避免「一直 HLS」。 ignoreModeParamRef.current = true setMode('direct') @@ -530,6 +537,143 @@ export function PlayerPage() { } }, []) + const clearDirectSeekWait = useCallback(() => { + directSeekWaitCleanupRef.current?.() + directSeekWaitCleanupRef.current = null + }, []) + + /** + * STRM/115 直连跳转:等 seekable 覆盖目标再写 currentTime,避免被钳回 0; + * 同时打开误报宽限期,防止 seek 触发 video.load() 从头播。 + */ + const seekStrmDirectTo = useCallback( + (absoluteSec: number, options?: { onSettled?: () => void }) => { + const video = ref.current + if (!video) return false + const target = Math.max(0, absoluteSec) + pendingDirectSeekRef.current = target + directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS + directRetryRef.current = false + clearFallbackTimer() + clearDirectSeekWait() + + let pollTimer: ReturnType | null = null + let finishTimer: ReturnType | null = null + let forced = false + let forceAttempts = 0 + let applied = false + 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 (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + if (finishTimer) { + clearTimeout(finishTimer) + finishTimer = null + } + if (directSeekWaitCleanupRef.current === cleanup) { + directSeekWaitCleanupRef.current = null + } + } + + const settleIfMatched = () => { + const el = ref.current + if (!el || pendingDirectSeekRef.current === null) return false + const want = pendingDirectSeekRef.current + if (Math.abs(el.currentTime - want) <= 2.5) { + pendingDirectSeekRef.current = null + cleanup() + options?.onSettled?.() + 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 + 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 + const want = pendingDirectSeekRef.current + if (el.currentTime < 1.5 && want > 3) { + // 被钳回 0:允许在 seekable 就绪后再写一次。 + applied = false + forced = false + directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS + if (canSeekDirectNow(el.seekable, want)) { + writeCurrentTime(want) + } + } + } + + const tick = () => { + const el = ref.current + if (!el || pendingDirectSeekRef.current === null) { + cleanup() + return + } + const want = pendingDirectSeekRef.current + if (!applied && canSeekDirectNow(el.seekable, want)) { + writeCurrentTime(want) + return + } + if (!toastShown && Date.now() - startedAt > 600) { + toastShown = true + toast('正在定位播放位置…', { duration: 2200 }) + } + if (!applied && !forced && Date.now() - startedAt >= DIRECT_SEEK_WAIT_MS) { + if (forceAttempts >= 2) { + pendingDirectSeekRef.current = null + cleanup() + return + } + forced = true + forceAttempts += 1 + writeCurrentTime(want) + finishTimer = setTimeout(() => { + if (!settleIfMatched() && forceAttempts >= 2) { + pendingDirectSeekRef.current = null + cleanup() + } else if (pendingDirectSeekRef.current !== null) { + forced = false + applied = false + } + }, 2500) + } + } + + video.addEventListener('progress', tick) + video.addEventListener('durationchange', tick) + video.addEventListener('loadedmetadata', tick) + video.addEventListener('seeked', onSeeked) + pollTimer = setInterval(tick, 350) + directSeekWaitCleanupRef.current = cleanup + tick() + return true + }, + [clearDirectSeekWait, clearFallbackTimer], + ) + // Load metadata and pick a default mode. useEffect(() => { if (!id) return @@ -1119,7 +1263,6 @@ export function PlayerPage() { let cancelled = false let seekStarted = false - let onSeeked: (() => void) | undefined let onPlaying: (() => void) | undefined let onCanPlay: (() => void) | undefined @@ -1138,11 +1281,25 @@ export function PlayerPage() { return } seekStarted = true - // 115/STRM 302 直链上过早 seek 会让 Chromium 误报 error 并触发 HLS 回退。 + if (isStrmMedia(mediaRef.current)) { + seekStrmDirectTo(resumePosition, { + onSettled: () => { + if (!cancelled) { + setInitialSeekDone(true) + notifyResume() + } + }, + }) + // 即使定位较慢,也不要卡住「未完成续播」状态太久。 + window.setTimeout(() => { + if (!cancelled) setInitialSeekDone(true) + }, DIRECT_SEEK_WAIT_MS + 3000) + return + } directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS directRetryRef.current = false clearFallbackTimer() - onSeeked = () => { + const onSeeked = () => { setInitialSeekDone(true) notifyResume() } @@ -1155,7 +1312,6 @@ export function PlayerPage() { } // STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。 - // 本地文件仍可在 canplay 后立刻跳,体验更干净。 const deferForStrm = isStrmMedia(mediaRef.current) if (deferForStrm) { onPlaying = () => { @@ -1179,9 +1335,8 @@ export function PlayerPage() { cancelled = true if (onPlaying) video.removeEventListener('playing', onPlaying) if (onCanPlay) video.removeEventListener('canplay', onCanPlay) - if (onSeeked) video.removeEventListener('seeked', onSeeked) } - }, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer]) + }, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo]) // 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度 const hlsStartSecRef = useRef(hlsStartSec) @@ -1661,10 +1816,27 @@ export function PlayerPage() { video.currentTime = Math.max(0, target - hlsStartSec) return } + if (isStrmMedia(mediaRef.current)) { + seekStrmDirectTo(target) + return + } selfSkipSeekRef.current = true video.currentTime = target }, - [handleSeekAbsolute, hlsSource, hlsStartSec, mode], + [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) + } + return false + }, + [handleSeekAbsolute, hlsSource, mode, seekStrmDirectTo], ) const showSkipNotice = useCallback( @@ -1843,8 +2015,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 } @@ -1899,6 +2080,7 @@ export function PlayerPage() { mediaId, mode, playbackInfo, + seekStrmDirectTo, selectedQuality, setPlaybackMode, startCloudTranscode, @@ -1981,7 +2163,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} diff --git a/web/src/pages/directPlaySeek.test.ts b/web/src/pages/directPlaySeek.test.ts new file mode 100644 index 0000000..d61f610 --- /dev/null +++ b/web/src/pages/directPlaySeek.test.ts @@ -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') diff --git a/web/src/pages/directPlaySeek.ts b/web/src/pages/directPlaySeek.ts new file mode 100644 index 0000000..03ec70a --- /dev/null +++ b/web/src/pages/directPlaySeek.ts @@ -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) +}