Recover Chromium HLS audio at ad discontinuities
This commit is contained in:
@ -9,6 +9,7 @@ class CaptionTracks {
|
||||
this.hlsTracks = new Map();
|
||||
this.retired = new Set();
|
||||
this.generation = 0;
|
||||
this.selectionRevision = 0;
|
||||
this.video.textTracks?.addEventListener('change', onChange);
|
||||
this.video.textTracks?.addEventListener('addtrack', onChange);
|
||||
this.video.textTracks?.addEventListener('removetrack', onChange);
|
||||
@ -55,6 +56,20 @@ class CaptionTracks {
|
||||
this.probe = { subtitles: info?.subtitles || [], url };
|
||||
}
|
||||
|
||||
prepareMediaRecovery() {
|
||||
if (!this.remoteTracks.length) return;
|
||||
const selected = this.remoteTracks.findIndex(element => element.track.mode === 'showing');
|
||||
const generation = this.generation, revision = this.selectionRevision;
|
||||
return () => {
|
||||
if (this.generation !== generation || this.selectionRevision !== revision) return;
|
||||
// HLS detachment clears native cues, including extracted remote subtitles.
|
||||
this.removeRemoteTracks();
|
||||
this.addRemoteTracks();
|
||||
if (selected >= 0 && this.remoteTracks[selected]) this.remoteTracks[selected].track.mode = 'showing';
|
||||
this.onChange();
|
||||
};
|
||||
}
|
||||
|
||||
useNative({ allowProbe = true } = {}) {
|
||||
this.generation++;
|
||||
this.hls = null;
|
||||
@ -203,6 +218,7 @@ class CaptionTracks {
|
||||
active: hls.subtitleDisplay !== false && hls.subtitleTrack === index,
|
||||
select: () => {
|
||||
if (this.hls !== hls || hls.subtitleTracks[index] !== track) return;
|
||||
this.selectionRevision++;
|
||||
this.disableNativeTracks();
|
||||
hls.subtitleDisplay = true;
|
||||
hls.subtitleTrack = index;
|
||||
@ -238,6 +254,7 @@ class CaptionTracks {
|
||||
}
|
||||
|
||||
off() {
|
||||
this.selectionRevision++;
|
||||
if (this.hls) {
|
||||
this.hls.subtitleTrack = -1;
|
||||
this.hls.subtitleDisplay = false;
|
||||
|
||||
107
public/js/components/HlsAudioRecovery.js
Normal file
107
public/js/components/HlsAudioRecovery.js
Normal file
@ -0,0 +1,107 @@
|
||||
/** Chromium can retain an invalid audio decoder state across HLS ad discontinuities. */
|
||||
class HlsAudioRecovery {
|
||||
constructor(hls, video, events, isCurrent, options = {}) {
|
||||
this.hls = hls;
|
||||
this.video = video;
|
||||
this.events = events;
|
||||
this.isCurrent = isCurrent;
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
this.chromium = options.chromium ?? /(?:Chrome|Chromium|Edg)\//.test(userAgent);
|
||||
this.now = options.now || (() => Date.now());
|
||||
this.beforeRecovery = options.beforeRecovery;
|
||||
this.lastCC = null;
|
||||
this.lastRecovery = -Infinity;
|
||||
this.attempts = [];
|
||||
this.pendingBoundary = false;
|
||||
this.disposed = false;
|
||||
|
||||
this.onFragment = (event, data) => {
|
||||
if (!this.active() || !data?.frag || data.frag.sn === 'initSegment') return;
|
||||
const cc = data.frag.cc;
|
||||
if (!Number.isInteger(cc) || cc === this.lastCC) return;
|
||||
const previous = this.lastCC;
|
||||
this.lastCC = cc;
|
||||
if (previous === null || !this.chromium) return;
|
||||
this.pendingBoundary = true;
|
||||
this.onPlaying();
|
||||
};
|
||||
this.onPlaying = () => {
|
||||
if (!this.active() || !this.pendingBoundary || this.video.paused) return;
|
||||
this.pendingBoundary = false;
|
||||
this.requestRecovery('audio discontinuity');
|
||||
};
|
||||
this.onDestroy = () => this.dispose();
|
||||
this.onManifest = () => {
|
||||
if (!this.active()) return;
|
||||
this.lastCC = null;
|
||||
this.pendingBoundary = false;
|
||||
this.cancelRecovery();
|
||||
};
|
||||
hls.on(events.FRAG_CHANGED, this.onFragment);
|
||||
hls.on(events.DESTROYING, this.onDestroy);
|
||||
hls.on(events.MANIFEST_LOADING, this.onManifest);
|
||||
video.addEventListener?.('playing', this.onPlaying);
|
||||
}
|
||||
|
||||
active() {
|
||||
return !this.disposed && this.isCurrent();
|
||||
}
|
||||
|
||||
requestRecovery(reason) {
|
||||
if (!this.active()) return false;
|
||||
if (this.timer !== undefined || this.onAttached) return true;
|
||||
const now = this.now();
|
||||
this.attempts = this.attempts.filter(time => now - time < 30000);
|
||||
if (now - this.lastRecovery < 2000 || this.attempts.length >= 3) return false;
|
||||
|
||||
// Let HLS finish dispatching the fragment/error event before detaching its media.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.active()) return;
|
||||
const position = this.video.currentTime;
|
||||
const resume = !this.video.paused && !this.video.ended;
|
||||
const restoreCaptions = this.beforeRecovery?.();
|
||||
this.previousStartPosition = this.hls.config.startPosition;
|
||||
if (Number.isFinite(position) && position >= 0) this.hls.config.startPosition = position;
|
||||
this.lastRecovery = this.now();
|
||||
this.attempts.push(this.lastRecovery);
|
||||
this.onAttached = () => {
|
||||
if (!this.active() || !this.onAttached) return;
|
||||
this.hls.off(this.events.MEDIA_ATTACHED, this.onAttached);
|
||||
this.onAttached = null;
|
||||
this.hls.config.startPosition = this.previousStartPosition;
|
||||
if (!this.active()) return;
|
||||
restoreCaptions?.();
|
||||
if (resume) this.video.play().catch(() => {});
|
||||
else this.video.pause();
|
||||
};
|
||||
this.hls.on(this.events.MEDIA_ATTACHED, this.onAttached);
|
||||
console.info(`[HLS] Refreshing media decoder: ${reason}`);
|
||||
this.hls.recoverMediaError();
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelRecovery() {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
if (this.onAttached) {
|
||||
this.hls.off(this.events.MEDIA_ATTACHED, this.onAttached);
|
||||
this.hls.config.startPosition = this.previousStartPosition;
|
||||
this.onAttached = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.cancelRecovery();
|
||||
this.hls.off(this.events.FRAG_CHANGED, this.onFragment);
|
||||
this.hls.off(this.events.DESTROYING, this.onDestroy);
|
||||
this.hls.off(this.events.MANIFEST_LOADING, this.onManifest);
|
||||
this.video.removeEventListener?.('playing', this.onPlaying);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.HlsAudioRecovery = HlsAudioRecovery;
|
||||
if (typeof module !== 'undefined') module.exports = HlsAudioRecovery;
|
||||
@ -570,145 +570,6 @@ class VideoPlayer {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize HLS.js if supported
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
this.lastDiscontinuity = -1; // Track discontinuity changes
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS error:', data.type, data.details);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
// Track network retry attempts
|
||||
this.networkRetryCount = (this.networkRetryCount || 0) + 1;
|
||||
const now = Date.now();
|
||||
const timeSinceLastNetworkError = now - (this.lastNetworkErrorTime || 0);
|
||||
this.lastNetworkErrorTime = now;
|
||||
|
||||
// Reset retry count if it's been more than 30 seconds since last error
|
||||
if (timeSinceLastNetworkError > 30000) {
|
||||
this.networkRetryCount = 1;
|
||||
}
|
||||
|
||||
console.log(`Network error (attempt ${this.networkRetryCount}/3):`, data.details);
|
||||
|
||||
if (this.networkRetryCount <= 3 && !this.isUsingProxy) {
|
||||
// Retry with increasing delay (1s, 2s, 3s)
|
||||
const retryDelay = this.networkRetryCount * 1000;
|
||||
console.log(`[HLS] Retrying in ${retryDelay}ms...`);
|
||||
setTimeout(() => {
|
||||
if (this.hls) {
|
||||
this.hls.startLoad();
|
||||
}
|
||||
}, retryDelay);
|
||||
} else if (!this.isUsingProxy) {
|
||||
// After 3 retries, try proxy
|
||||
console.log('[HLS] Max retries reached, switching to proxy...');
|
||||
this.networkRetryCount = 0;
|
||||
this.isUsingProxy = true;
|
||||
const proxiedUrl = this.getProxiedUrl(this.currentUrl);
|
||||
this.hls.loadSource(proxiedUrl);
|
||||
this.hls.startLoad();
|
||||
} else {
|
||||
// Already using proxy, just retry
|
||||
console.log('[HLS] Network error on proxy, retrying...');
|
||||
this.hls.startLoad();
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log('Media error, attempting recovery...');
|
||||
this.hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
// Non-fatal media error - try to recover with cooldown to prevent loops
|
||||
const now = Date.now();
|
||||
const timeSinceLastRecovery = now - (this.lastRecoveryAttempt || 0);
|
||||
|
||||
// Track consecutive media errors for escalated recovery
|
||||
if (timeSinceLastRecovery < 5000) {
|
||||
this.mediaErrorCount = (this.mediaErrorCount || 0) + 1;
|
||||
} else {
|
||||
this.mediaErrorCount = 1;
|
||||
}
|
||||
|
||||
// Only attempt recovery if more than 2 seconds since last attempt
|
||||
if (timeSinceLastRecovery > 2000) {
|
||||
console.log(`Non-fatal media error (${this.mediaErrorCount}x):`, data.details, '- attempting recovery');
|
||||
this.lastRecoveryAttempt = now;
|
||||
|
||||
// If repeated errors, try swapAudioCodec which can fix audio glitches
|
||||
if (this.mediaErrorCount >= 3) {
|
||||
console.log('[HLS] Multiple errors detected, trying swapAudioCodec...');
|
||||
this.hls.swapAudioCodec();
|
||||
this.mediaErrorCount = 0;
|
||||
}
|
||||
|
||||
this.hls.recoverMediaError();
|
||||
|
||||
// If fragParsingError, also seek forward slightly to skip corrupted segment
|
||||
if (data.details === 'fragParsingError' && !this.video.paused && this.video.currentTime > 0) {
|
||||
console.log('[HLS] Seeking past corrupted segment...');
|
||||
setTimeout(() => {
|
||||
if (this.video && !this.video.paused) {
|
||||
this.video.currentTime += 1;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
} else {
|
||||
// Too many errors in quick succession - log but don't spam recovery
|
||||
console.log('Non-fatal media error (cooldown):', data.details);
|
||||
}
|
||||
} else if (data.details === 'bufferAppendError') {
|
||||
// Buffer errors during ad transitions - try recovery
|
||||
console.log('Buffer append error, recovering...');
|
||||
this.hls.recoverMediaError();
|
||||
}
|
||||
});
|
||||
|
||||
// Detect audio track switches (can cause audio glitches on some streams)
|
||||
this.hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (event, data) => {
|
||||
console.log('Audio track switched:', data);
|
||||
});
|
||||
|
||||
// Detect buffer stalls which may indicate codec issues
|
||||
this.hls.on(Hls.Events.BUFFER_STALLED_ERROR, () => {
|
||||
console.log('Buffer stalled, attempting recovery...');
|
||||
this.hls.recoverMediaError();
|
||||
});
|
||||
|
||||
// Detect discontinuity changes (ad transitions) and help decoder reset
|
||||
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
|
||||
const frag = data.frag;
|
||||
// Debug: log every fragment change
|
||||
console.log(`[HLS] FRAG_CHANGED: sn=${frag?.sn}, cc=${frag?.cc}, level=${frag?.level}`);
|
||||
|
||||
if (frag && frag.sn !== 'initSegment') {
|
||||
// Check if we crossed a discontinuity boundary using CC (Continuity Counter)
|
||||
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
|
||||
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
|
||||
this.lastDiscontinuity = frag.cc;
|
||||
|
||||
// Small nudge to help decoder sync (only if playing)
|
||||
if (!this.video.paused && this.video.currentTime > 0) {
|
||||
const nudgeAmount = 0.01;
|
||||
this.video.currentTime += nudgeAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.getCaptionTracks().bindHls(this.hls, Hls.Events);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
||||
|
||||
@ -988,66 +849,7 @@ class VideoPlayer {
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||
|
||||
// Use playHls helper logic here (or extract it)
|
||||
// For now, let's just use existing logic but wrapped/modularized if possible?
|
||||
// The HLS init logic is quite complex with error handling
|
||||
// I'll inline the Hls init here as per original but mindful of proxy vs local
|
||||
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
this.getCaptionTracks().bindHls(this.hls, Hls.Events);
|
||||
this.hls.loadSource(finalUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach error handler for the new Hls instance
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
|
||||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
|
||||
|
||||
// Don't proxy if it's already a local API URL
|
||||
const isLocalApi = this.currentUrl.startsWith('/api/');
|
||||
|
||||
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
|
||||
console.log('CORS/Network error detected, retrying via proxy...', data.details);
|
||||
this.isUsingProxy = true;
|
||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||
this.hls.startLoad();
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
// Fatal media error - try recovery with cooldown
|
||||
const now = Date.now();
|
||||
if (now - (this.lastRecoveryAttempt || 0) > 2000) {
|
||||
console.log('Fatal media error, attempting recovery...');
|
||||
this.lastRecoveryAttempt = now;
|
||||
this.hls.recoverMediaError();
|
||||
}
|
||||
} else {
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
// Non-fatal media error - already handled in init(), skip duplicate handling
|
||||
}
|
||||
});
|
||||
|
||||
// Detect discontinuity changes (ad transitions) for logging only
|
||||
this.lastDiscontinuity = -1;
|
||||
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
|
||||
const frag = data.frag;
|
||||
if (frag && frag.sn !== 'initSegment') {
|
||||
// Log discontinuity changes for debugging
|
||||
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
|
||||
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
|
||||
this.lastDiscontinuity = frag.cc;
|
||||
// Note: maxAudioFramesDrift: 4 handles audio sync naturally
|
||||
// No manual seeking needed - it can cause more issues than it solves
|
||||
}
|
||||
}
|
||||
});
|
||||
this.playHls(finalUrl);
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
||||
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
|
||||
@ -1103,6 +905,10 @@ class VideoPlayer {
|
||||
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
const hls = this.hls, generation = this.playbackGeneration;
|
||||
this.audioRecovery?.dispose();
|
||||
this.audioRecovery = new HlsAudioRecovery(hls, this.video, Hls.Events,
|
||||
() => this.hls === hls && this.playbackGeneration === generation,
|
||||
{ beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() });
|
||||
this.getCaptionTracks().bindHls(hls, Hls.Events);
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
@ -1114,14 +920,25 @@ class VideoPlayer {
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
if (data.fatal) {
|
||||
// Simple error handling for forced HLS/transcode modes
|
||||
console.error('Fatal HLS error in transcode mode:', data);
|
||||
this.hls.destroy();
|
||||
this.stopTranscodeSession();
|
||||
const mediaError = data.type === Hls.ErrorTypes.MEDIA_ERROR;
|
||||
const needsRecovery = mediaError && (data.fatal || ['bufferAppendError', 'bufferAppendingError', 'fragParsingError'].includes(data.details));
|
||||
if (needsRecovery && this.audioRecovery.requestRecovery('media error')) return;
|
||||
if (!data.fatal) return;
|
||||
const networkError = data.type === Hls.ErrorTypes.NETWORK_ERROR;
|
||||
if (networkError && !this.isUsingProxy && !url.startsWith('/api/')) {
|
||||
this.isUsingProxy = true;
|
||||
hls.loadSource(this.getProxiedUrl(url));
|
||||
hls.startLoad();
|
||||
return;
|
||||
}
|
||||
console.error('Fatal HLS error:', data.details);
|
||||
this.audioRecovery.dispose();
|
||||
hls.destroy();
|
||||
this.hls = null;
|
||||
this.stopTranscodeSession();
|
||||
this.showError('Playback failed. Try selecting the channel again.');
|
||||
});
|
||||
}
|
||||
|
||||
@ -1301,6 +1118,8 @@ class VideoPlayer {
|
||||
*/
|
||||
stop() {
|
||||
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
|
||||
this.audioRecovery?.dispose();
|
||||
this.audioRecovery = null;
|
||||
// Stop any running transcode session first
|
||||
this.stopTranscodeSession();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user