/** * Video Player Component * Handles HLS video playback with custom controls */ class VideoPlayer { constructor() { this.video = document.getElementById('video-player'); this.container = document.querySelector('.video-container'); this.overlay = document.getElementById('player-overlay'); this.nowPlaying = document.getElementById('now-playing'); this.hls = null; this.currentChannel = null; this.overlayTimer = null; this.overlayDuration = 5000; // 5 seconds this.isUsingProxy = false; this.currentUrl = null; // Settings (loaded from localStorage) this.settings = this.loadSettings(); this.init(); } /** * Load settings from localStorage */ loadSettings() { const defaults = { arrowKeysChangeChannel: true, // Use arrow keys for channel navigation overlayDuration: 5, // Seconds defaultVolume: 80, // 0-100 rememberVolume: true, // Persist volume between sessions lastVolume: 80, // Last used volume (if rememberVolume is true) autoPlayNextEpisode: false, // Auto-play next episode in series forceProxy: false, // Force all streams through backend proxy forceTranscode: false, // Force audio transcoding (fixes Dolby/AC3 audio) forceRemux: false // Force remux to MP4 (fixes raw .ts streams, lightweight) }; try { const saved = localStorage.getItem('nodecast_tv_player_settings'); if (saved) { return { ...defaults, ...JSON.parse(saved) }; } } catch (err) { console.error('Error loading settings:', err); } return defaults; } /** * Save settings to localStorage */ saveSettings() { try { localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings)); } catch (err) { console.error('Error saving settings:', err); } } /** * Get HLS.js configuration with buffer settings optimized for stable playback */ getHlsConfig() { return { enableWorker: true, // Buffer settings to prevent underruns during background tab throttling maxBufferLength: 30, // Buffer up to 30 seconds of content maxMaxBufferLength: 60, // Absolute max buffer 60 seconds maxBufferSize: 60 * 1000 * 1000, // 60MB max buffer size maxBufferHole: 1.0, // Allow 1s holes in buffer (helps with discontinuities) // Live stream settings - stay further from live edge for stability liveSyncDurationCount: 3, // Stay 3 segments behind live liveMaxLatencyDurationCount: 10, // Allow up to 10 segments behind before catching up liveBackBufferLength: 30, // Keep 30s of back buffer for seeking // Audio discontinuity handling (fixes garbled audio during ad transitions) stretchShortVideoTrack: true, // Stretch short segments to avoid gaps forceKeyFrameOnDiscontinuity: true, // Force keyframe sync on discontinuity // Audio drift tolerance - higher = less aggressive correction = fewer glitches // 1 frame = ~23ms at 44.1kHz, so 4 frames = ~92ms tolerance maxAudioFramesDrift: 4, // Allow ~92ms audio drift before correction // Disable progressive/streaming mode for stability with discontinuities progressive: false, // Stall recovery settings nudgeOffset: 0.2, // Larger nudge steps for recovery (default 0.1) nudgeMaxRetry: 6, // More retry attempts (default 3) // Faster recovery from errors levelLoadingMaxRetry: 4, manifestLoadingMaxRetry: 4, fragLoadingMaxRetry: 6, // Caption/Subtitle settings enableCEA708Captions: true, // Enable CEA-708 closed captions enableWebVTT: true, // Enable WebVTT subtitles renderTextTracksNatively: true // Use native browser rendering for text tracks }; } init() { // Apply default/remembered volume const volume = this.settings.rememberVolume ? this.settings.lastVolume : this.settings.defaultVolume; this.video.volume = volume / 100; // Save volume changes this.video.addEventListener('volumechange', () => { if (this.settings.rememberVolume) { this.settings.lastVolume = Math.round(this.video.volume * 100); this.saveSettings(); } }); // 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: // If it's a network error and we haven't tried the proxy yet, try it if (this.currentChannel && !this.isUsingProxy) { console.log('Network error detected, trying via proxy...'); const proxiedUrl = this.getProxiedUrl(this.currentUrl); this.isUsingProxy = true; this.hls.loadSource(proxiedUrl); this.hls.startLoad(); } else { console.log('Network error, attempting recovery...'); 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); // Only attempt recovery if more than 2 seconds since last attempt if (timeSinceLastRecovery > 2000) { console.log('Non-fatal media error:', data.details, '- attempting recovery'); this.lastRecoveryAttempt = now; 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.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)); // Click on video shows overlay this.video.addEventListener('click', () => this.showNowPlayingOverlay()); } /** * Show the now playing overlay briefly */ showNowPlayingOverlay() { if (!this.currentChannel) return; // Clear existing timer if (this.overlayTimer) { clearTimeout(this.overlayTimer); } // Show overlay this.nowPlaying.classList.remove('hidden'); // Hide after duration this.overlayTimer = setTimeout(() => { this.nowPlaying.classList.add('hidden'); }, this.settings.overlayDuration * 1000); } /** * Hide the now playing overlay */ hideNowPlayingOverlay() { if (this.overlayTimer) { clearTimeout(this.overlayTimer); } this.nowPlaying.classList.add('hidden'); } /** * Play a channel */ async play(channel, streamUrl) { this.currentChannel = channel; try { // Stop current playback this.stop(); // Hide "select a channel" overlay this.overlay.classList.add('hidden'); // Determine if HLS or direct stream this.currentUrl = streamUrl; // CHECK: Force Transcode Priority - transcoded streams bypass HLS.js if (this.settings.forceTranscode) { console.log('[Player] Force Transcode enabled. Routing through ffmpeg...'); const transcodeUrl = this.getTranscodeUrl(streamUrl); this.currentUrl = transcodeUrl; // Transcoded streams are fragmented MP4 - play directly with