/** * 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 }; 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 // 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 // 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.hls.on(Hls.Events.ERROR, (event, data) => { console.error('HLS error:', data); 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; } } }); 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; // Proactively use proxy for known CORS-restricted domains (like Pluto TV) const proxyRequiredDomains = ['pluto.tv']; const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain)); this.isUsingProxy = needsProxy; const finalUrl = needsProxy ? this.getProxiedUrl(streamUrl) : streamUrl; // Priority 1: Use HLS.js for all browsers that support it (Chrome, Firefox, Edge, etc.) if (Hls.isSupported()) { this.hls = new Hls(this.getHlsConfig()); this.hls.loadSource(finalUrl); this.hls.attachMedia(this.video); this.hls.on(Hls.Events.MANIFEST_PARSED, () => { this.video.play().catch(e => 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) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) { console.log('CORS/Network error detected, retrying via proxy...'); this.isUsingProxy = true; this.hls.loadSource(this.getProxiedUrl(this.currentUrl)); this.hls.startLoad(); } else { console.error('Fatal HLS error:', data); } } }); } 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) this.video.src = finalUrl; this.video.play().catch(e => { console.log('Autoplay prevented, trying proxy if CORS error:', e); if (!this.isUsingProxy) { this.isUsingProxy = true; this.video.src = this.getProxiedUrl(streamUrl); this.video.play().catch(err => console.error('Proxy play failed:', err)); } }); } else { // Priority 3: Try direct playback for non-HLS streams this.video.src = finalUrl; this.video.play().catch(e => console.log('Autoplay prevented:', e)); } // Update now playing info this.updateNowPlaying(channel); // Show the now playing overlay this.showNowPlayingOverlay(); // Fetch EPG data for this channel this.fetchEpgData(channel); // Dispatch event window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); } catch (err) { console.error('Error playing channel:', err); this.showError('Failed to play channel'); } } /** * Fetch EPG data for current channel */ async fetchEpgData(channel) { try { // First, try to use the centralized EpgGuide data (already loaded) if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) { const epgGuide = window.app.epgGuide; // Get current program from EpgGuide const currentProgram = epgGuide.getCurrentProgram(channel.tvgId, channel.name); if (currentProgram) { // Find upcoming programs from the guide's data const epgChannel = epgGuide.channelMap?.get(channel.tvgId) || epgGuide.channelMap?.get(channel.name?.toLowerCase()); let upcoming = []; if (epgChannel) { const now = Date.now(); upcoming = epgGuide.programmes .filter(p => p.channelId === epgChannel.id && new Date(p.start).getTime() > now) .slice(0, 5) .map(p => ({ title: p.title, start: new Date(p.start), stop: new Date(p.stop), description: p.desc || '' })); } this.updateNowPlaying(channel, { current: { title: currentProgram.title, start: new Date(currentProgram.start), stop: new Date(currentProgram.stop), description: currentProgram.desc || '' }, upcoming }); return; // Success, exit early } } // Fallback: Try to get EPG from Xtream API if available if (channel.sourceType === 'xtream' && channel.streamId) { const epgData = await API.proxy.xtream.shortEpg(channel.sourceId, channel.streamId); if (epgData && epgData.epg_listings && epgData.epg_listings.length > 0) { const listings = epgData.epg_listings; const now = Math.floor(Date.now() / 1000); // Find current program const current = listings.find(p => { const start = parseInt(p.start_timestamp); const end = parseInt(p.stop_timestamp); return start <= now && end > now; }); // Get upcoming programs const upcoming = listings .filter(p => parseInt(p.start_timestamp) > now) .slice(0, 5) .map(p => ({ title: this.decodeBase64(p.title), start: new Date(parseInt(p.start_timestamp) * 1000), stop: new Date(parseInt(p.stop_timestamp) * 1000), description: this.decodeBase64(p.description) })); if (current) { this.updateNowPlaying(channel, { current: { title: this.decodeBase64(current.title), start: new Date(parseInt(current.start_timestamp) * 1000), stop: new Date(parseInt(current.stop_timestamp) * 1000), description: this.decodeBase64(current.description) }, upcoming }); } } } } catch (err) { console.log('EPG data not available:', err.message); } } /** * Get proxied URL for a stream */ getProxiedUrl(url) { return `/api/proxy/stream?url=${encodeURIComponent(url)}`; } /** * Decode base64 EPG data */ decodeBase64(str) { if (!str) return ''; try { return decodeURIComponent(escape(atob(str))); } catch { return str; } } /** * Stop playback */ stop() { if (this.hls) { this.hls.destroy(); this.hls = null; } this.video.pause(); this.video.src = ''; this.video.load(); } /** * Update now playing display */ updateNowPlaying(channel, epgData = null) { const channelName = this.nowPlaying.querySelector('.channel-name'); const programTitle = this.nowPlaying.querySelector('.program-title'); const programTime = this.nowPlaying.querySelector('.program-time'); const upNextList = document.getElementById('up-next-list'); channelName.textContent = channel.name || channel.tvgName || 'Unknown Channel'; if (epgData && epgData.current) { programTitle.textContent = epgData.current.title; const start = new Date(epgData.current.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const end = new Date(epgData.current.stop).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); programTime.textContent = `${start} - ${end}`; } else { programTitle.textContent = ''; programTime.textContent = ''; } // Update up next upNextList.innerHTML = ''; if (epgData && epgData.upcoming) { epgData.upcoming.slice(0, 3).forEach(prog => { const li = document.createElement('li'); const time = new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); li.textContent = `${time} - ${prog.title}`; upNextList.appendChild(li); }); } } /** * Show error overlay */ showError(message) { this.overlay.classList.remove('hidden'); this.overlay.querySelector('.overlay-content').innerHTML = `
${message}
`; } /** * Handle keyboard shortcuts */ handleKeyboard(e) { if (document.activeElement.tagName === 'INPUT') return; switch (e.key) { case ' ': case 'k': e.preventDefault(); this.video.paused ? this.video.play() : this.video.pause(); break; case 'f': e.preventDefault(); this.toggleFullscreen(); break; case 'm': e.preventDefault(); this.video.muted = !this.video.muted; break; case 'ArrowUp': e.preventDefault(); if (this.settings.arrowKeysChangeChannel) { this.channelUp(); } else { this.video.volume = Math.min(1, this.video.volume + 0.1); } break; case 'ArrowDown': e.preventDefault(); if (this.settings.arrowKeysChangeChannel) { this.channelDown(); } else { this.video.volume = Math.max(0, this.video.volume - 0.1); } break; case 'ArrowLeft': e.preventDefault(); // Volume down when arrow keys are for channels if (this.settings.arrowKeysChangeChannel) { this.video.volume = Math.max(0, this.video.volume - 0.1); } break; case 'ArrowRight': e.preventDefault(); // Volume up when arrow keys are for channels if (this.settings.arrowKeysChangeChannel) { this.video.volume = Math.min(1, this.video.volume + 0.1); } break; case 'PageUp': case 'ChannelUp': e.preventDefault(); this.channelUp(); break; case 'PageDown': case 'ChannelDown': e.preventDefault(); this.channelDown(); break; case 'i': // Show/hide info overlay e.preventDefault(); if (this.nowPlaying.classList.contains('hidden')) { this.showNowPlayingOverlay(); } else { this.hideNowPlayingOverlay(); } break; } } /** * Go to previous channel */ channelUp() { if (!window.app?.channelList) return; const channels = window.app.channelList.getVisibleChannels(); if (channels.length === 0) return; const currentIdx = this.currentChannel ? channels.findIndex(c => c.id === this.currentChannel.id) : -1; const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1; window.app.channelList.selectChannel({ channelId: channels[prevIdx].id }); } /** * Go to next channel */ channelDown() { if (!window.app?.channelList) return; const channels = window.app.channelList.getVisibleChannels(); if (channels.length === 0) return; const currentIdx = this.currentChannel ? channels.findIndex(c => c.id === this.currentChannel.id) : -1; const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1; window.app.channelList.selectChannel({ channelId: channels[nextIdx].id }); } /** * Toggle fullscreen */ toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } else if (this.container) { this.container.requestFullscreen(); } } } // Export window.VideoPlayer = VideoPlayer;