feat: enhance HLS error handling and key URL rewriting in VideoPlayer
- Introduced recovery attempts for HLS errors to prevent infinite loops. - Added detailed logging for HLS errors, including fragment URLs and response statuses. - Implemented a custom loader to rewrite AES-128 key URLs to resolve against the app backend. - Improved user feedback for playback errors based on recovery attempts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -260,6 +260,8 @@ let controlsTimeout: ReturnType<typeof setTimeout> | null = null
|
|||||||
|
|
||||||
// ── HLS instance ────────────────────────────────────────────────────
|
// ── HLS instance ────────────────────────────────────────────────────
|
||||||
let hls: Hls | null = null
|
let hls: Hls | null = null
|
||||||
|
let hlsRecoveryAttempts = 0
|
||||||
|
const MAX_HLS_RECOVERY_ATTEMPTS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the YouTube embed URL for partner/YouTube content.
|
* Build the YouTube embed URL for partner/YouTube content.
|
||||||
@@ -363,6 +365,7 @@ async function fetchStream() {
|
|||||||
|
|
||||||
function initPlayer(url: string) {
|
function initPlayer(url: string) {
|
||||||
destroyHls()
|
destroyHls()
|
||||||
|
hlsRecoveryAttempts = 0
|
||||||
const video = videoEl.value
|
const video = videoEl.value
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
@@ -370,9 +373,29 @@ function initPlayer(url: string) {
|
|||||||
console.log(`[VideoPlayer] initPlayer — isHls=${isHls}, url=${url.substring(0, 120)}...`)
|
console.log(`[VideoPlayer] initPlayer — isHls=${isHls}, url=${url.substring(0, 120)}...`)
|
||||||
|
|
||||||
if (isHls && Hls.isSupported()) {
|
if (isHls && Hls.isSupported()) {
|
||||||
|
// Custom loader: rewrite AES-128 key URLs so they resolve
|
||||||
|
// against the app backend, not the CDN/S3 origin.
|
||||||
|
// The manifest references keys as "/api/contents/:id/key" which
|
||||||
|
// resolves against the manifest host (CDN). We redirect to the app.
|
||||||
|
const BaseLoader = Hls.DefaultConfig.loader as any
|
||||||
|
class KeyRewriteLoader extends BaseLoader {
|
||||||
|
load(context: any, config: any, callbacks: any) {
|
||||||
|
if (context.url?.includes('/contents/') && context.url?.includes('/key')) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(context.url)
|
||||||
|
const correctedUrl = `${window.location.origin}${parsed.pathname}`
|
||||||
|
console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl)
|
||||||
|
context.url = correctedUrl
|
||||||
|
} catch { /* keep original URL */ }
|
||||||
|
}
|
||||||
|
super.load(context, config, callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hls = new Hls({
|
hls = new Hls({
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: false,
|
lowLatencyMode: false,
|
||||||
|
loader: KeyRewriteLoader,
|
||||||
// Attach auth tokens to key requests so paid content can be decrypted
|
// Attach auth tokens to key requests so paid content can be decrypted
|
||||||
xhrSetup(xhr: XMLHttpRequest, xhrUrl: string) {
|
xhrSetup(xhr: XMLHttpRequest, xhrUrl: string) {
|
||||||
if (xhrUrl.includes('/key')) {
|
if (xhrUrl.includes('/key')) {
|
||||||
@@ -392,19 +415,44 @@ function initPlayer(url: string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
console.error('[VideoPlayer] HLS error:', data.type, data.details, data.fatal ? '(FATAL)' : '')
|
// Log detailed error info including the fragment URL when available
|
||||||
if (data.fatal) {
|
const fragUrl = (data as any).frag?.url || (data as any).url || ''
|
||||||
|
console.error(
|
||||||
|
'[VideoPlayer] HLS error:', data.type, data.details,
|
||||||
|
data.fatal ? '(FATAL)' : '',
|
||||||
|
fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '',
|
||||||
|
(data as any).response ? `status=${(data as any).response.code}` : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.fatal) return
|
||||||
|
|
||||||
|
// Track recovery attempts to avoid infinite loops
|
||||||
|
hlsRecoveryAttempts++
|
||||||
|
|
||||||
|
if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) {
|
||||||
|
console.error(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded, giving up.`)
|
||||||
|
|
||||||
|
// fragParsingError usually means the segments are encrypted or
|
||||||
|
// the file is corrupt / served incorrectly
|
||||||
|
if (data.details === 'fragParsingError') {
|
||||||
|
streamError.value = 'Unable to play this video. The stream segments could not be decoded — they may be encrypted or the file may still be processing.'
|
||||||
|
} else {
|
||||||
|
streamError.value = 'Playback failed after multiple attempts. Please try again later.'
|
||||||
|
}
|
||||||
|
destroyHls()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
console.warn('[VideoPlayer] HLS network error, retrying...')
|
console.warn(`[VideoPlayer] HLS network error, retrying... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
|
||||||
hls?.startLoad()
|
hls?.startLoad()
|
||||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
console.warn('[VideoPlayer] HLS media error, recovering...')
|
console.warn(`[VideoPlayer] HLS media error, recovering... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
|
||||||
hls?.recoverMediaError()
|
hls?.recoverMediaError()
|
||||||
} else {
|
} else {
|
||||||
streamError.value = 'Playback error. Please try again.'
|
streamError.value = 'Playback error. Please try again.'
|
||||||
destroyHls()
|
destroyHls()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Safari native HLS
|
// Safari native HLS
|
||||||
|
|||||||
Reference in New Issue
Block a user