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

@ -1099,8 +1099,9 @@
<script src="/js/api.js?v=auth-2"></script>
<script src="/js/components/PlaybackSessions.js?v=1"></script>
<script src="/js/components/VodDuration.js?v=1"></script>
<script src="/js/components/CaptionTracks.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=4"></script>
<script src="/js/components/CaptionTracks.js?v=2"></script>
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
@ -1110,7 +1111,7 @@
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=4"></script>
<script src="/js/pages/WatchPage.js?v=5"></script>
<script src="/js/app.js?v=5"></script>
</body>

View File

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

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;

View File

@ -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();

View File

@ -606,6 +606,10 @@ class WatchPage {
});
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);
@ -619,6 +623,9 @@ class WatchPage {
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (this.hls !== hls || this.playbackGeneration !== generation) return;
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) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error (only if not already proxied/transcoded)
@ -646,6 +653,8 @@ class WatchPage {
stop() {
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
this.audioRecovery?.dispose();
this.audioRecovery = null;
// Stop history tracking and save final progress
this.stopHistoryTracking();
this.saveProgress();