/** * Video Player Component * Handles HLS video playback with custom controls */ // Check if device is mobile function isMobile() { return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } 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; this.settingsLoaded = false; // Settings - start with defaults, load from server async this.settings = this.getDefaultSettings(); // Load settings from server, then init this.loadSettingsFromServer().then(() => { this.init(); }); } /** * Default settings */ getDefaultSettings() { return { arrowKeysChangeChannel: true, overlayDuration: 5, defaultVolume: 80, rememberVolume: true, lastVolume: 80, autoPlayNextEpisode: false, forceProxy: false, forceTranscode: false, forceRemux: false, streamFormat: 'm3u8', epgRefreshInterval: '24' }; } /** * Load settings from server API */ async loadSettingsFromServer() { try { const serverSettings = await API.settings.get(); this.settings = { ...this.getDefaultSettings(), ...serverSettings }; this.settingsLoaded = true; console.log('[Player] Settings loaded from server'); } catch (err) { console.warn('[Player] Failed to load settings from server, using defaults:', err.message); // Fall back to localStorage for backwards compatibility try { const saved = localStorage.getItem('nodecast_tv_player_settings'); if (saved) { this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) }; console.log('[Player] Settings loaded from localStorage (fallback)'); } } catch (localErr) { console.error('[Player] Error loading localStorage settings:', localErr); } } } /** * Save settings to server API */ async saveSettings() { try { await API.settings.update(this.settings); console.log('[Player] Settings saved to server'); } catch (err) { console.error('[Player] Error saving settings to server:', err); // Also save to localStorage as backup try { localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings)); } catch (localErr) { console.error('[Player] Error saving to localStorage:', localErr); } } } /** * Legacy sync method for compatibility - calls async version */ loadSettings() { return this.settings; } /** * 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 settings - prevent glitches during stream transitions // Higher drift tolerance = less aggressive correction = fewer glitches maxAudioFramesDrift: 8, // Allow ~185ms audio drift before correction (was 4) // 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, // Low latency mode off for more stable audio lowLatencyMode: false, // Caption/Subtitle settings enableCEA708Captions: true, // Enable CEA-708 closed captions enableWebVTT: true, // Enable WebVTT subtitles renderTextTracksNatively: true // Use native browser rendering for text tracks }; } /** * Initialize custom video controls for mobile */ /** * Initialize custom video controls */ initCustomControls() { // Elements this.controlsOverlay = document.getElementById('player-controls-overlay'); this.loadingSpinner = document.getElementById('player-loading'); const btnPlay = document.getElementById('btn-play'); const btnMute = document.getElementById('btn-mute'); const btnFullscreen = document.getElementById('btn-fullscreen'); const volumeSlider = document.getElementById('player-volume'); const channelNameEl = document.getElementById('player-channel-name'); if (!this.controlsOverlay) return; // Disable native controls this.video.controls = false; // Initial State: Hide all overlay elements until content is loaded this.loadingSpinner?.classList.remove('show'); this.controlsOverlay?.classList.add('hidden'); // Play/Pause toggle const togglePlay = () => { if (this.video.paused) { this.video.play(); } else { this.video.pause(); } }; btnPlay?.addEventListener('click', (e) => { e.stopPropagation(); togglePlay(); }); // Update play/pause UI const updatePlayUI = () => { const isPaused = this.video.paused; // Bottom bar button const iconPlay = btnPlay?.querySelector('.icon-play'); const iconPause = btnPlay?.querySelector('.icon-pause'); if (iconPlay && iconPause) { iconPlay.classList.toggle('hidden', !isPaused); iconPause.classList.toggle('hidden', isPaused); } }; this.video.addEventListener('play', updatePlayUI); this.video.addEventListener('pause', updatePlayUI); // Loading spinner this.video.addEventListener('waiting', () => { this.loadingSpinner?.classList.add('show'); }); this.video.addEventListener('canplay', () => { this.loadingSpinner?.classList.remove('show'); }); // Mute/Volume const updateVolumeUI = () => { const isMuted = this.video.muted || this.video.volume === 0; const iconVol = btnMute?.querySelector('.icon-vol'); const iconMuted = btnMute?.querySelector('.icon-muted'); if (iconVol && iconMuted) { iconVol.classList.toggle('hidden', isMuted); iconMuted.classList.toggle('hidden', !isMuted); } if (volumeSlider) { volumeSlider.value = this.video.muted ? 0 : Math.round(this.video.volume * 100); } }; btnMute?.addEventListener('click', (e) => { e.stopPropagation(); if (this.video.muted) { this.video.muted = false; this.video.volume = (parseInt(volumeSlider?.value || 80) / 100) || 0.8; } else { this.video.muted = true; } updateVolumeUI(); }); volumeSlider?.addEventListener('input', (e) => { e.stopPropagation(); const val = parseInt(e.target.value); this.video.volume = val / 100; this.video.muted = val === 0; updateVolumeUI(); }); this.video.addEventListener('volumechange', updateVolumeUI); // Captions this.captionsBtn = document.getElementById('player-captions-btn'); this.captionsMenu = document.getElementById('player-captions-menu'); this.captionsList = document.getElementById('player-captions-list'); this.captionsMenuOpen = false; this.captionsBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.toggleCaptionsMenu(); }); // Close captions menu when clicking outside document.addEventListener('click', (e) => { if (this.captionsMenuOpen && !this.captionsMenu.contains(e.target) && !this.captionsBtn.contains(e.target)) { this.closeCaptionsMenu(); } }); // Fullscreen btnFullscreen?.addEventListener('click', (e) => { e.stopPropagation(); this.toggleFullscreen(); }); this.container.addEventListener('dblclick', () => this.toggleFullscreen()); // Overlay Auto-hide Logic let overlayTimeout; const showOverlay = () => { this.controlsOverlay.classList.remove('hidden'); this.container.style.cursor = 'default'; resetOverlayTimer(); }; const hideOverlay = () => { if (!this.video.paused) { this.controlsOverlay.classList.add('hidden'); this.container.style.cursor = 'none'; } }; const resetOverlayTimer = () => { clearTimeout(overlayTimeout); if (!this.video.paused) { overlayTimeout = setTimeout(hideOverlay, 3000); } }; this.container.addEventListener('mousemove', showOverlay); this.container.addEventListener('click', showOverlay); this.container.addEventListener('touchstart', showOverlay); this.video.addEventListener('play', resetOverlayTimer); this.video.addEventListener('pause', showOverlay); // Update Title when channel changes window.addEventListener('channelChanged', (e) => { if (channelNameEl && e.detail) { channelNameEl.textContent = e.detail.name || e.detail.tvgName || 'Live TV'; } showOverlay(); }); // Initial state updatePlayUI(); updateVolumeUI(); } /** * Toggle fullscreen mode */ toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } else { this.container.requestFullscreen().catch(err => { console.error('Fullscreen error:', err); }); } } /** * Toggle captions menu visibility */ toggleCaptionsMenu() { if (!this.captionsMenu) return; this.captionsMenuOpen = !this.captionsMenuOpen; if (this.captionsMenuOpen) { this.updateCaptionsTracks(); this.captionsMenu.classList.remove('hidden'); } else { this.captionsMenu.classList.add('hidden'); } } /** * Close captions menu */ closeCaptionsMenu() { if (!this.captionsMenu) return; this.captionsMenuOpen = false; this.captionsMenu.classList.add('hidden'); } /** * Update available caption tracks in the menu */ updateCaptionsTracks() { if (!this.captionsList) return; // Clear existing list (keep only Off option) this.captionsList.innerHTML = ''; // Add tracks if (this.video.textTracks && this.video.textTracks.length > 0) { let hasActiveTrack = false; for (let i = 0; i < this.video.textTracks.length; i++) { const track = this.video.textTracks[i]; const btn = document.createElement('button'); btn.className = 'captions-option'; btn.textContent = track.label || `Track ${i + 1} (${track.language || 'unknown'})`; btn.dataset.index = i; if (track.mode === 'showing') { btn.classList.add('active'); // Add checkmark btn.innerHTML += ' ✓'; hasActiveTrack = true; } btn.onclick = (e) => { e.stopPropagation(); this.selectCaptionTrack(i); }; this.captionsList.appendChild(btn); } // Handle "Off" button state const offBtn = this.captionsList.querySelector('[data-index="-1"]'); if (offBtn) { if (!hasActiveTrack) { offBtn.classList.add('active'); offBtn.innerHTML += ' ✓'; } offBtn.onclick = (e) => { e.stopPropagation(); this.selectCaptionTrack(-1); }; } } } /** * Select a caption track */ selectCaptionTrack(index) { if (!this.video.textTracks) return; // Turn off all tracks for (let i = 0; i < this.video.textTracks.length; i++) { this.video.textTracks[i].mode = 'hidden'; // or 'disabled' } // Turn on selected track if (index >= 0 && index < this.video.textTracks.length) { this.video.textTracks[index].mode = 'showing'; } this.closeCaptionsMenu(); } 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(); } }); // Setup custom video controls this.initCustomControls(); // 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; } } } }); // Listen for subtitle track updates this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { console.log('Subtitle tracks updated:', data.subtitleTracks); // Wait a moment for native text tracks to populate setTimeout(() => this.updateCaptionsTracks(), 100); }); this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => { console.log('Subtitle track switched:', data); }); 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'); // Show custom controls overlay this.controlsOverlay?.classList.remove('hidden'); this.loadingSpinner?.classList.add('show'); // 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