Recover Chromium HLS audio at ad discontinuities

This commit is contained in:
Technomancer702
2026-10-13 17:23:00 -07:00
parent 035667cfba
commit b9dad30f06
14 changed files with 454 additions and 216 deletions

View 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;