Browse Source

fix: retry HLS 404 during live startup window

Treat first-manifest 404 as recoverable: backoff-reload the same signed
play URL for 15s instead of failing or refreshing /play-url. Keep
heartbeat independent, and only fatalize 403/TLS/CORS or exhausted 404.
main
xiaosi 2 weeks ago
parent
commit
af381838a3
  1. 160
      src/components/LivePlayer.vue
  2. 30
      src/composables/useMonitorLive.js
  3. 2
      src/views/MediaView/MediaView.vue

160
src/components/LivePlayer.vue

@ -44,6 +44,9 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000]
const STARTUP_WINDOW_MS = 15_000
const props = defineProps({
playUrl: { type: String, default: '' },
phase: { type: String, default: '' },
@ -57,15 +60,22 @@ const emit = defineEmits(['toggle', 'error'])
const frameEl = ref(null)
const videoEl = ref(null)
const needsUserPlay = ref(false)
const waitingForStream = ref(false)
let hls = null
let attachSeq = 0
let mediaCleanup = null
let startupRetryTimer = null
let startupRetryCount = 0
let startupStartedAt = 0
let activeUrl = ''
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://'))
const isLiveShell = computed(() => props.active && !playable.value)
const qualityText = computed(() => {
if (props.opening) return '打开中'
if (waitingForStream.value) return '启动中 · 等待流就绪'
if (needsUserPlay.value) return '已暂停 · 点击继续'
if (playable.value) return 'LIVE · 播放中'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证'
@ -76,6 +86,7 @@ const qualityText = computed(() => {
const statusMessage = computed(() => {
if (props.opening) return '正在打开直播'
if (waitingForStream.value) return '正在等待直播流就绪'
if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放'
if (playable.value) return ''
if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪'
@ -93,6 +104,18 @@ const controlLabel = computed(() => {
const controlIcon = computed(() => (needsUserPlay.value || !props.active ? '#i-play' : '#i-stop'))
function clearStartupRetry() {
window.clearTimeout(startupRetryTimer)
startupRetryTimer = null
}
function resetStartupState() {
clearStartupRetry()
startupRetryCount = 0
startupStartedAt = 0
waitingForStream.value = false
}
function clearMediaListeners() {
if (typeof mediaCleanup === 'function') mediaCleanup()
mediaCleanup = null
@ -121,11 +144,60 @@ function syncPausedState() {
needsUserPlay.value = !!video.paused
}
function extractHttpCode(data) {
const code = Number(data?.response?.code ?? data?.response?.status ?? data?.error?.code)
return Number.isFinite(code) && code > 0 ? code : 0
}
function isSecurityFailure(detail = '') {
return /ssl|tls|cipher|certificate|cors|mixed content|err_ssl/i.test(String(detail))
}
function buildPlayError({ message, name = 'HlsError', code = 0, recoverable = false }) {
const err = new Error(message || 'hls error')
err.name = name
if (code) err.code = code
err.recoverable = recoverable
return err
}
function emitFatal(err) {
resetStartupState()
destroyPlayer()
emit('error', err)
}
function canRetryStartup() {
if (!startupStartedAt) startupStartedAt = Date.now()
if (Date.now() - startupStartedAt > STARTUP_WINDOW_MS) return false
return startupRetryCount < STARTUP_RETRY_DELAYS_MS.length
}
function scheduleSameUrlRetry(url, seq, httpCode = 404) {
if (seq !== attachSeq || !url) return false
if (!canRetryStartup()) return false
const delay = STARTUP_RETRY_DELAYS_MS[startupRetryCount]
startupRetryCount += 1
waitingForStream.value = true
console.debug('[live-player] startup retry', { httpCode, delay, attempt: startupRetryCount })
// bump attachSeq
destroyPlayer()
clearStartupRetry()
startupRetryTimer = window.setTimeout(() => {
if (seq !== attachSeq || activeUrl !== url) return
attachPlayer(url, { seq }).catch(() => {})
}, delay)
return true
}
function bindMediaListeners(video, seq) {
clearMediaListeners()
const onPlay = () => {
if (seq !== attachSeq) return
needsUserPlay.value = false
resetStartupState()
}
const onPause = () => {
if (seq !== attachSeq) return
@ -174,8 +246,17 @@ function onControlClick() {
emit('toggle')
}
async function attachPlayer(url) {
const seq = ++attachSeq
async function attachPlayer(url, { seq: existingSeq } = {}) {
const seq = existingSeq ?? (++attachSeq)
if (existingSeq == null) {
clearStartupRetry()
// playUrl 退
startupRetryCount = 0
startupStartedAt = Date.now()
waitingForStream.value = false
}
activeUrl = url || ''
destroyPlayer()
if (!url || url.startsWith('fake://')) return
@ -190,9 +271,13 @@ async function attachPlayer(url) {
video.addEventListener('error', () => {
if (seq !== attachSeq) return
const err = new Error('media error')
err.name = 'HlsError'
emit('error', err)
// Safari HLS HTTP code 404
if (scheduleSameUrlRetry(url, seq, 404)) return
emitFatal(buildPlayError({
message: 'media error',
name: 'HlsError',
code: 404,
}))
}, { once: true })
async function tryPlay() {
@ -200,6 +285,7 @@ async function attachPlayer(url) {
try {
await video.play()
needsUserPlay.value = false
resetStartupState()
} catch (error) {
if (error?.name === 'AbortError' || error?.name === 'NotAllowedError') {
console.debug('[live-player] play interrupted', error)
@ -208,7 +294,7 @@ async function attachPlayer(url) {
return
}
console.warn('[live-player] play failed', error)
if (seq === attachSeq) emit('error', error)
if (seq === attachSeq) emitFatal(error)
}
}
@ -223,40 +309,76 @@ async function attachPlayer(url) {
const { default: Hls } = await import('hls.js')
if (seq !== attachSeq) return
if (!Hls.isSupported()) {
emit('error')
emitFatal(buildPlayError({ message: 'hls unsupported' }))
return
}
// 退 404
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
manifestLoadingMaxRetry: 1,
levelLoadingMaxRetry: 1,
manifestLoadingMaxRetry: 0,
levelLoadingMaxRetry: 0,
fragLoadingMaxRetry: 1,
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (seq !== attachSeq) return
if (!data?.fatal) return
const detail = [data.type, data.details, data.response?.code, data.error?.message]
const httpCode = extractHttpCode(data)
const detail = [data.type, data.details, httpCode || '', data.error?.message]
.filter(Boolean)
.join(' ')
const err = new Error(detail || 'hls fatal')
err.name = /ssl|tls|cipher|certificate|cors|mixed content|err_ssl/i.test(detail)
? 'SecurityError'
: 'HlsError'
if (isSecurityFailure(detail)) {
console.warn('[live-player] hls security fatal', data)
emitFatal(buildPlayError({
message: detail || 'hls security fatal',
name: 'SecurityError',
code: httpCode || 0,
}))
return
}
if (httpCode === 403) {
console.warn('[live-player] hls auth fatal', data)
emitFatal(buildPlayError({
message: detail || 'hls 403',
name: 'HlsError',
code: 403,
}))
return
}
// 线退 /play-url
if (httpCode === 404 || data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR) {
if (scheduleSameUrlRetry(url, seq, httpCode || 404)) return
console.warn('[live-player] hls startup 404 exhausted', data)
emitFatal(buildPlayError({
message: detail || 'hls 404',
name: 'HlsError',
code: 404,
}))
return
}
console.warn('[live-player] hls fatal', data)
destroyPlayer()
emit('error', err)
emitFatal(buildPlayError({
message: detail || 'hls fatal',
name: 'HlsError',
code: httpCode || 0,
}))
})
hls.loadSource(url)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (seq !== attachSeq) return
resetStartupState()
video.muted = true
tryPlay()
})
} catch (_) {
if (seq === attachSeq) emit('error')
if (seq === attachSeq) emitFatal(buildPlayError({ message: 'hls init failed' }))
}
}
@ -267,6 +389,8 @@ watch(
attachPlayer(url).catch(() => {})
} else {
attachSeq += 1
activeUrl = ''
resetStartupState()
destroyPlayer()
}
},
@ -275,6 +399,8 @@ watch(
onBeforeUnmount(() => {
attachSeq += 1
activeUrl = ''
resetStartupState()
destroyPlayer()
})

30
src/composables/useMonitorLive.js

@ -15,12 +15,13 @@ const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000
const LEASE_INVALID_CODE = 57006
function isFatalPlayMediaError(err) {
const msg = String(err?.message || err || '').toLowerCase()
const name = String(err?.name || '')
const code = Number(err?.code)
return (
name === 'SecurityError' ||
code === 403 ||
msg.includes('ssl') ||
msg.includes('tls') ||
msg.includes('cipher') ||
@ -30,6 +31,10 @@ function isFatalPlayMediaError(err) {
msg.includes('certificate')
)
}
function isRecoverablePlayError(err) {
// LivePlayer 启动窗口内的 404 会自行退避,不冒泡;仅忽略显式 recoverable,避免吞掉窗口耗尽后的 404
return err?.recoverable === true
}
function isLeaseInvalidError(err) {
return Number(err?.code) === LEASE_INVALID_CODE
@ -320,21 +325,30 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
// AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl.
if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return
// 启动期 404 由 LivePlayer 同地址退避处理;父层保持 heartbeat,不刷 /play-url
if (isRecoverablePlayError(err)) return
if (isLeaseInvalidError(err)) {
await rejoinAfterLeaseInvalid()
return
}
// 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url
if (isFatalPlayMediaError(err) || err?.name === 'HlsError') {
// TLS/CORS/403:换 auth_key 没用,禁止刷 play-url
if (isFatalPlayMediaError(err)) {
markPlayFatal(
isFatalPlayMediaError(err)
? '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书'
: (err?.message || '播放失败')
Number(err?.code) === 403
? '播放鉴权失败(403),请检查签名或播放域名配置'
: '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书'
)
return
}
// 启动窗口耗尽后的 HlsError(含持续 404)视为不可恢复
if (err?.name === 'HlsError') {
markPlayFatal(err?.message || '播放失败')
return
}
if (live.playRetryCount >= PLAY_RETRY_MAX) {
markPlayFatal(err?.message || '播放失败')
return
@ -352,7 +366,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
if (isFatalPlayMediaError(e) || e?.name === 'HlsError' || live.playRetryCount >= PLAY_RETRY_MAX) {
markPlayFatal(
isFatalPlayMediaError(e)
? '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书'
? (Number(e?.code) === 403
? '播放鉴权失败(403),请检查签名或播放域名配置'
: '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书')
: (e?.message || err?.message || '播放失败')
)
return

2
src/views/MediaView/MediaView.vue

@ -294,6 +294,8 @@ async function openLive(stream) {
function onActivePlayError(err) {
if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return
// 404 LivePlayer 退 recoverable
if (err?.recoverable === true) return
ui.toast(err?.message || '播放失败')
}

Loading…
Cancel
Save