优化播放逻辑

This commit is contained in:
truewhile
2026-10-21 12:02:11 +08:00
parent 121a0a3cc5
commit 55854a0adb
25 changed files with 943 additions and 129 deletions
+6 -4
View File
@@ -175,7 +175,7 @@ export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): s
// hlsURL returns the m3u8 playlist URL fed into hls.js.
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string): string {
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string, sessionID?: string): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
@@ -188,7 +188,8 @@ export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, q
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}`
const session = sessionID ? `&session_id=${encodeURIComponent(sessionID)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}${session}`
}
// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL.
@@ -202,8 +203,9 @@ export function cloudHlsURL(mediaId: string, definition: string): string {
// Stop an on-demand HLS job. keepalive makes the request survive page
// navigation/tab close, where an axios promise can be discarded by browsers.
export function stopHLSJob(mediaId: string): void {
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
export function stopHLSJob(mediaId: string, sessionID?: string): void {
const session = sessionID ? `&session_id=${encodeURIComponent(sessionID)}` : ''
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${session}`
void fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
+54 -51
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import type Hls from 'hls.js'
import toast from 'react-hot-toast'
@@ -41,7 +41,9 @@ import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMe
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import type { DirectPlayFailureReason } from './directPlayError'
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec, playbackTimeSec } from './hlsPlayback'
import { availableLocalQuality, defaultLocalQualityId, findPlaybackQualityById } from './playbackQuality'
import { createPlaybackRequestGuard } from './playbackRequestGuard'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
@@ -118,6 +120,14 @@ export function PlayerPage() {
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
const cloudRequestGuardRef = useRef(createPlaybackRequestGuard())
const currentMediaIdRef = useRef(id)
useLayoutEffect(() => {
const guard = cloudRequestGuardRef.current
currentMediaIdRef.current = id
guard.invalidate()
return () => guard.invalidate()
}, [id])
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
@@ -563,6 +573,7 @@ export function PlayerPage() {
const setPlaybackMode = useCallback((next: PlayerMode) => {
// 一旦显式决定过播放方式,URL 上的 ?mode= 就不再是权威(避免它把状态打回去)。
requestedModeRef.current = null
if (next === 'direct') cloudRequestGuardRef.current.invalidate()
setMode(next)
}, [])
@@ -767,6 +778,11 @@ export function PlayerPage() {
*/
const handleDirectSeekStalled = useCallback(
(target: number) => {
cloudRequestGuardRef.current.invalidate()
if (directOnly || isDirectStreamMedia(mediaRef.current)) {
toast.error('当前播放方式不提供 HLS 转码,请使用外部播放器定位')
return
}
const cloudQuality =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo?.default_quality ?? '')
@@ -780,9 +796,7 @@ export function PlayerPage() {
toast('直连定位失败,已切到 115 云 HLS 跳转')
return
}
const localQuality =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, defaultLocalQualityId(playbackInfo))
const localQuality = availableLocalQuality(playbackInfo, selectedQuality)
if (localQuality?.available) {
setSelectedQuality(localQuality.id)
setHlsSource('local')
@@ -795,7 +809,7 @@ export function PlayerPage() {
}
toast.error('直连定位失败,且当前没有可用的 HLS 转码档位')
},
[playbackInfo, selectedQuality, setPlaybackMode],
[directOnly, playbackInfo, selectedQuality, setPlaybackMode],
)
// Load metadata and pick a default mode.
@@ -1032,17 +1046,22 @@ export function PlayerPage() {
const switchToLocalHLS = useCallback(
(position?: number) => {
cloudRequestGuardRef.current.invalidate()
pendingSeekRef.current = null
setHlsSource('local')
setCloudWaiting(false)
setSelectedQuality((prev) => {
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
return defaultLocalQualityId(playbackInfo)
})
const quality = availableLocalQuality(playbackInfo, selectedQuality)
if (playbackInfo && !quality) {
setHlsUnavailable(true)
showPlayFailure('本地 HLS 转码不可用,请配置 ffmpeg 或使用外部播放器。')
setPlaybackMode('direct')
return
}
setSelectedQuality(quality?.id ?? defaultLocalQualityId(playbackInfo))
if (position !== undefined) setHlsStartSec(Math.max(0, position))
setPlaybackMode('hls')
},
[playbackInfo, setPlaybackMode],
[playbackInfo, selectedQuality, setPlaybackMode, showPlayFailure],
)
/**
@@ -1080,8 +1099,11 @@ export function PlayerPage() {
const startCloudTranscode = useCallback(
async (definition: number) => {
if (!mediaId) return
const isCurrentRequest = cloudRequestGuardRef.current.begin()
const isCurrent = () => isCurrentRequest() && currentMediaIdRef.current === mediaId && !directOnlyRef.current
try {
const info = await mediaAPI.startCloudTranscode(mediaId, definition)
if (!isCurrent() || info.media_id !== mediaId) return
setPlaybackInfo(info)
if (info.transcode.state === 'ready') {
setCloudWaiting(false)
@@ -1094,6 +1116,7 @@ export function PlayerPage() {
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
setCloudWaitStartedAt(Date.now())
} catch {
if (!isCurrent()) return
switchToLocalHLS()
toast.error('115 云端转码触发失败,已切换本地转码')
}
@@ -1105,6 +1128,7 @@ export function PlayerPage() {
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number, infoOverride?: PlaybackInfo | null) => {
cloudRequestGuardRef.current.invalidate()
const position = Math.max(0, startSec)
// 允许传入刚拿到的能力信息:从 URL 强制 HLS 时不想等到 state 更新后才选线路。
const info = infoOverride ?? playbackInfo
@@ -1127,6 +1151,7 @@ export function PlayerPage() {
}
} else {
switchToLocalHLS(position)
return
}
setPlaybackMode('hls')
},
@@ -1165,7 +1190,7 @@ export function PlayerPage() {
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
} catch {
cloudPollTimerRef.current = setTimeout(poll, 5000)
if (!cancelled) cloudPollTimerRef.current = setTimeout(poll, 5000)
}
}
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
@@ -1338,6 +1363,7 @@ export function PlayerPage() {
if (!currentMedia) return
let cancelled = false
let cleanupHlsResume: (() => void) | undefined
let stopLocalPlayback: (() => void) | undefined
let onDirectMetadata: (() => void) | undefined
teardownHls()
@@ -1348,10 +1374,17 @@ export function PlayerPage() {
teardownHls()
return
}
// Each wired local source owns its job, including seek/quality changes.
// Capture the ID in cleanup so a delayed DELETE cannot stop a newer run.
const localSessionID = hlsSource === 'local' ? newPlaybackProgressSession(mediaId).id : undefined
if (localSessionID) {
stopLocalPlayback = () => stopHLSJob(mediaId, localSessionID)
window.addEventListener('pagehide', stopLocalPlayback)
}
const url =
hlsSource === 'cloud'
? cloudHlsURL(mediaId, selectedQuality)
: hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality)
: hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality, localSessionID)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
@@ -1494,6 +1527,10 @@ export function PlayerPage() {
if (onDirectMetadata) video.removeEventListener('loadedmetadata', onDirectMetadata)
cleanupHlsResume?.()
teardownHls()
if (stopLocalPlayback) {
window.removeEventListener('pagehide', stopLocalPlayback)
stopLocalPlayback()
}
}
}, [
activeBurnedSubtitleStream,
@@ -1518,24 +1555,6 @@ export function PlayerPage() {
vr360DirectProxy,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
return () => {
stopHLSJob(mediaId)
}
}, [hlsSource, mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [hlsSource, mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
if (!id) return
@@ -1828,8 +1847,7 @@ export function PlayerPage() {
const sync = () => {
// HLS 转码时 <video>.currentTime 是相对转码起点的,必须把起点加回来。这里与
// 进度上报用的算法保持一致,否则转码场景下整段判断都会错位。
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const absolute = playbackPositionSec(modeRef.current, hlsSourceRef.current, hlsStartSecRef.current, video.currentTime || 0)
const next = resolveActiveSkip(absolute, skipSegments, {
hasNextEpisode: Boolean(nextEpisode),
excludedKinds: dismissedSkipKinds,
@@ -1854,8 +1872,7 @@ export function PlayerPage() {
selfSkipSeekRef.current = false
return
}
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const absolute = playbackPositionSec(modeRef.current, hlsSourceRef.current, hlsStartSecRef.current, video.currentTime || 0)
const hit = skipSegments.find(
(segment) =>
(segment.kind === 'intro' || segment.kind === 'recap') &&
@@ -2169,7 +2186,7 @@ export function PlayerPage() {
return
}
selfSkipSeekRef.current = true
video.currentTime = Math.max(0, target - hlsStartSec)
video.currentTime = playbackTimeSec(mode, hlsSource, hlsStartSec, target)
return
}
if (isStrmMedia(mediaRef.current)) {
@@ -2277,6 +2294,7 @@ export function PlayerPage() {
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
cloudRequestGuardRef.current.invalidate()
const video = ref.current
const localTime = video?.currentTime || 0
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
@@ -2659,21 +2677,6 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
return episodeLabel || mediaTitle || title || '未命名'
}
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
if (!info || !id) return undefined
return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find(
(quality) => quality.id === id,
)
}
function defaultLocalQualityId(info: PlaybackInfo | null): string {
const qualities = info?.local_qualities ?? []
for (const id of ['1080', '720', '480', 'source']) {
if (qualities.some((quality) => quality.id === id)) return id
}
return qualities[0]?.id ?? '720'
}
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
const normalized = normalizeEpisodeTitle(title)
if (!normalized) return true
+5 -1
View File
@@ -1,5 +1,5 @@
import { strict as assert } from 'node:assert'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback.ts'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec, playbackTimeSec } from './hlsPlayback.ts'
class FakeVideo extends EventTarget {
src = ''
@@ -55,4 +55,8 @@ assert.equal(hlsManifestTimeoutMs('cloud', 1800), 60_000)
assert.equal(playbackPositionSec('hls', 'cloud', 420, 600), 600, 'cloud subtitle/quality switches must not add an old local offset')
assert.equal(playbackPositionSec('hls', 'local', 420, 60), 480, 'local subtitle switches preserve the source position')
assert.equal(playbackPositionSec('direct', 'local', 420, 60), 60)
assert.equal(playbackTimeSec('hls', 'cloud', 600, 640), 640, 'cloud skip/undo uses the absolute target, not target minus a former local offset')
assert.equal(playbackTimeSec('hls', 'local', 600, 640), 40)
assert.equal(playbackTimeSec('direct', 'local', 600, 640), 640)
assert.equal(playbackTimeSec('hls', 'local', 600, 100), 0, 'targets before a local start clamp to zero')
console.log('hlsPlayback.test.ts ok')
+5
View File
@@ -41,3 +41,8 @@ export function attachNativeHls(video: HlsVideo, url: string, pending: PendingSe
export function playbackPositionSec(mode: 'direct' | 'hls', source: 'cloud' | 'local', startSec: number, currentTime: number): number {
return mode === 'hls' && source === 'local' ? startSec + currentTime : currentTime
}
/** Convert an absolute source target to the current media element's timeline. */
export function playbackTimeSec(mode: 'direct' | 'hls', source: 'cloud' | 'local', startSec: number, absoluteSec: number): number {
return Math.max(0, mode === 'hls' && source === 'local' ? absoluteSec - startSec : absoluteSec)
}
+19
View File
@@ -0,0 +1,19 @@
import { strict as assert } from 'node:assert'
import { availableLocalQuality, findPlaybackQualityById } from './playbackQuality.ts'
import type { PlaybackInfo } from '../types/playback.ts'
const info: PlaybackInfo = {
media_id: 'm', provider: 'cloud115', default_quality: '4', fallback: [],
transcode: { state: 'idle' },
cloud_qualities: [{ id: '4', label: '1080P', source: 'cloud', available: false }],
local_qualities: [
{ id: 'source', label: '原画', source: 'local', available: false },
{ id: '1080', label: '1080P', source: 'local', available: true },
{ id: '720', label: '720P', source: 'local', available: true },
],
}
assert.equal(availableLocalQuality(info, '4')?.id, '1080', 'pending cloud quality must not mask the available local fallback')
assert.equal(availableLocalQuality(info, '720')?.id, '720', 'preserve an available local preference')
assert.equal(availableLocalQuality({ ...info, local_qualities: info.local_qualities.map(q => ({ ...q, available: false })) }, '4'), undefined)
assert.equal(findPlaybackQualityById(info, '4')?.source, 'cloud')
console.log('playbackQuality.test.ts ok')
+24
View File
@@ -0,0 +1,24 @@
import type { PlaybackInfo, PlaybackQuality } from '../types/playback'
export function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
if (!info || !id) return undefined
return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find(
(quality) => quality.id === id,
)
}
export function defaultLocalQualityId(info: PlaybackInfo | null): string {
const qualities = info?.local_qualities ?? []
for (const id of ['1080', '720', '480', 'source']) {
if (qualities.some((quality) => quality.id === id)) return id
}
return qualities[0]?.id ?? '720'
}
/** A cloud option may exist without being ready; it is never a local fallback. */
export function availableLocalQuality(info: PlaybackInfo | null, preferred = ''): PlaybackQuality | undefined {
const available = info?.local_qualities.filter((quality) => quality.source === 'local' && quality.available) ?? []
return available.find((quality) => quality.id === preferred) ??
['1080', '720', '480', 'source'].map((id) => available.find((quality) => quality.id === id)).find(Boolean) ??
available[0]
}
@@ -0,0 +1,30 @@
import { strict as assert } from 'node:assert'
import { createPlaybackRequestGuard } from './playbackRequestGuard.ts'
const guard = createPlaybackRequestGuard()
const first = guard.begin()
assert.equal(first(), true)
const second = guard.begin()
assert.equal(first(), false, 'a newer quality request supersedes the earlier request')
assert.equal(second(), true)
guard.invalidate()
assert.equal(second(), false, 'media changes, unmount and direct/local switches cancel a pending cloud result')
let resolveRequest: (value: string) => void = () => {}
const request = new Promise<string>(resolve => { resolveRequest = resolve })
const current = guard.begin()
let applied = 'episode-B'
const settled = request.then(value => { if (current()) applied = value })
guard.invalidate()
resolveRequest('episode-A')
await settled
assert.equal(applied, 'episode-B', 'a delayed response cannot overwrite the next media')
let rejected: (error: Error) => void = () => {}
const failed = new Promise<never>((_, reject) => { rejected = reject })
const activeFailure = guard.begin()
let fallbackCount = 0
const caught = failed.catch(() => { if (activeFailure()) fallbackCount += 1 })
guard.invalidate()
rejected(new Error('old request failed'))
await caught
assert.equal(fallbackCount, 0, 'a stale rejection cannot force a local fallback')
console.log('playbackRequestGuard.test.ts ok')
+13
View File
@@ -0,0 +1,13 @@
/** Invalidates both successful and failed responses after a newer playback intent. */
export function createPlaybackRequestGuard() {
let generation = 0
return {
begin(): () => boolean {
const requested = ++generation
return () => requested === generation
},
invalidate(): void {
generation += 1
},
}
}