/** * Live Video Player Module * Provides live streaming functionality with Video.js integration * Maintains server synchronization and handles playback optimization */ (function(global) { 'use strict'; // Constants for configuration const CONFIG = { UPDATE_INTERVAL: 10000, // Stream update interval in milliseconds FADE_IN_THRESHOLD: 30, // Minimum duration for fade-in effect SYNC_THRESHOLD: 60, // Minimum duration for speed adjustment TIME_SYNC_THRESHOLD: 10, // Time threshold for currentTime updates MAX_PLAYBACK_SPEED: 1.2, // Maximum playback speed multiplier SPEED_ADJUSTMENT_FACTOR: 20, // Factor for speed calculation LARGE_OFFSET_THRESHOLD: -10, // Threshold for large time offset correction // Radio mode configuration RADIO: { QUERY_PARAM: 'radio', INDICATOR_TEXT: 'Radio Mode Active', INDICATOR_ID: 'radio-mode-indicator' }, // API endpoints API: { STREAM_URL: 'https://admin.satellitetvfeed.net/CurrentStreamURL', POSTER_URL: 'https://admin.satellitetvfeed.net', LIVE_CSS: 'https://admin.satellitetvfeed.net/css/live.css' }, // Video.js CDN VIDEOJS: { CSS: '//vjs.zencdn.net/7.10.2/video-js.min.css', JS: '//vjs.zencdn.net/7.10.2/video.min.js' }, // DOM element IDs ELEMENTS: { CONTAINER: 'liveembedcont', VIDEO: 'liveembedvid', POSTER: 'liveposter', OVERLAY: 'black-overlay', RADIO_INDICATOR: 'radio-mode-indicator', RADIO_OVERLAY: 'radio-mode-overlay' } }; // Module state - exposed globally for backwards compatibility let moduleState = { time_offset: 0, current_content: null, player: null, status: '', serverTime: null, updateInterval: null, radioMode: false, radioPlayState: 'paused', // 'playing', 'paused', 'loading', 'error' radioControlsCreated: false, // Audio loading retry state audioRetryAttempts: 0, maxRetryAttempts: 1, retryDelayMs: 2000, isLoadingStream: false, streamValidated: false, playerReady: false }; // Expose global variables for backwards compatibility global.time_offset = moduleState.time_offset; global.current_content = moduleState.current_content; global.player = moduleState.player; global.status = moduleState.status; /** * Parses URL query parameters * @returns {Object} Object containing query parameters as key-value pairs */ function getQueryParams() { try { const params = new URLSearchParams(window.location.search); const result = {}; for (const [key, value] of params) { result[key] = value; } return result; } catch (error) { console.error('Error parsing query parameters:', error); return {}; } } /** * Checks if radio mode is enabled via query parameter * @returns {boolean} True if radio mode is enabled */ function isRadioModeEnabled() { try { const params = getQueryParams(); return params.hasOwnProperty(CONFIG.RADIO.QUERY_PARAM) || params[CONFIG.RADIO.QUERY_PARAM] === 'true' || params[CONFIG.RADIO.QUERY_PARAM] === '1'; } catch (error) { console.error('Error checking radio mode:', error); return false; } } /** * Creates radio mode overlay with visual indicator and controls * @returns {HTMLElement} Radio mode overlay element */ function createRadioModeOverlay() { try { const container = document.getElementById(CONFIG.ELEMENTS.CONTAINER); if (!container) { console.error('Container element not found for radio overlay'); return null; } let overlay = document.getElementById(CONFIG.ELEMENTS.RADIO_OVERLAY); if (overlay) { return overlay; } overlay = document.createElement('div'); overlay.id = CONFIG.ELEMENTS.RADIO_OVERLAY; Object.assign(overlay.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'linear-gradient(135deg, #1a1a2e, #16213e)', background: 'linear-gradient(135deg, #1a1a2e, #16213e)', zIndex: '10', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: 'white', fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif" }); // Station-specific title (replaces "Audio Only Mode") const stationTitle = document.createElement('div'); stationTitle.textContent = 'hmongusatv Radio'.replace('hmongusatv', 'hmongusatv'); stationTitle.id = 'radio-station-title'; Object.assign(stationTitle.style, { fontSize: '2.2em', fontWeight: '300', marginBottom: '40px', textAlign: 'center', letterSpacing: '2px', textTransform: 'uppercase' }); // Interactive radio icon (replaces separate play/pause button) const radioIcon = document.createElement('div'); radioIcon.innerHTML = '📻'; radioIcon.id = 'radio-mode-icon'; radioIcon.className = 'interactive-radio-icon'; radioIcon.title = 'Click to play/pause'; Object.assign(radioIcon.style, { fontSize: '6em', marginBottom: '30px', cursor: 'pointer', transition: 'all 0.3s ease', animation: 'radiomode-pulse 2s ease-in-out infinite', userSelect: 'none', position: 'relative' }); // Status indicator (only essential messages) const statusIndicator = document.createElement('div'); statusIndicator.className = 'radio-status-indicator'; statusIndicator.id = 'radio-status-indicator'; statusIndicator.textContent = 'Click play to start audio'; Object.assign(statusIndicator.style, { fontSize: '1.1em', opacity: '0.8', marginTop: '20px', textAlign: 'center' }); // Add click handler to radio icon radioIcon.addEventListener('click', handleRadioPlayPause); overlay.appendChild(stationTitle); overlay.appendChild(radioIcon); overlay.appendChild(statusIndicator); // Add CSS animation addRadioModeCSS(); container.appendChild(overlay); return overlay; } catch (error) { console.error('Error creating radio mode overlay:', error); return null; } } /** * Adds CSS animations for radio mode */ function addRadioModeCSS() { try { const styleId = 'radio-mode-styles'; if (document.getElementById(styleId)) { return; } const style = document.createElement('style'); style.id = styleId; style.textContent = ` @keyframes radiomode-pulse { 0%, 100% { transform: scale(1); opacity: 0.9; } 50% { transform: scale(1.05); opacity: 1; } } @keyframes radio-playing-pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.9; } } @keyframes radio-loading-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes radio-glow { 0%, 100% { box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); } 50% { box-shadow: 0 0 30px rgba(255, 255, 255, 0.6), 0 0 40px rgba(255, 255, 255, 0.4); } } .radio-mode-controls { background: rgba(0, 0, 0, 0.8) !important; } .radio-mode-controls .vjs-volume-panel, .radio-mode-controls .vjs-fullscreen-toggle { display: block !important; } .interactive-radio-icon { cursor: pointer; } .interactive-radio-icon:hover { transform: scale(1.05); } .interactive-radio-icon:active { transform: scale(0.95); } .interactive-radio-icon.playing { animation: radio-playing-pulse 2s ease-in-out infinite; } .interactive-radio-icon.playing::after { content: '⏸️'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 0.4em; opacity: 0; transition: opacity 0.3s ease; } .interactive-radio-icon.playing:hover::after { opacity: 0.9; } .interactive-radio-icon.paused::after { content: '▶️'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 0.4em; opacity: 0; transition: opacity 0.3s ease; } .interactive-radio-icon.paused:hover::after { opacity: 0.9; } .interactive-radio-icon.loading { animation: radio-loading-spin 2s linear infinite; } .interactive-radio-icon.loading::after { content: '⏳'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 0.4em; opacity: 0.9; } .interactive-radio-icon.error { animation: radiomode-pulse 1s ease-in-out infinite; } .interactive-radio-icon.error::after { content: '❌'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 0.4em; opacity: 0.9; } .radio-status-indicator { font-size: 1.1em; opacity: 0.8; margin-top: 20px; text-align: center; } #radio-station-title { background: linear-gradient(45deg, #fff, #e0e0e0); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } `; document.head.appendChild(style); } catch (error) { console.error('Error adding radio mode CSS:', error); } } /** * Creates radio control buttons for play/pause functionality * @returns {HTMLElement} Controls container element * @deprecated This function is no longer used as controls are integrated into the radio icon */ function createRadioControls() { try { // Legacy function kept for backwards compatibility // Radio controls are now integrated into the radio icon itself const emptyContainer = document.createElement('div'); emptyContainer.style.display = 'none'; moduleState.radioControlsCreated = true; return emptyContainer; } catch (error) { console.error('Error creating radio controls:', error); return document.createElement('div'); } } /** * Handles radio play/pause button clicks with retry support */ function handleRadioPlayPause() { try { if (!moduleState.player) { console.warn('Player not available for radio control'); return; } // Handle retry when in error state if (moduleState.radioPlayState === 'error') { console.log('Retrying after error state'); // Reset validation state to force re-validation moduleState.streamValidated = false; moduleState.audioRetryAttempts = 0; playRadio(); } else if (moduleState.radioPlayState === 'playing') { pauseRadio(); } else if (moduleState.radioPlayState === 'loading') { console.log('Audio is loading, please wait...'); // Don't start another load attempt while one is in progress return; } else { playRadio(); } } catch (error) { console.error('Error handling radio play/pause:', error); } } /** * Validates stream URL before attempting to load * @param {string} streamUrl - Stream URL to validate * @returns {Promise} True if stream is valid */ async function validateStreamUrl(streamUrl) { try { const response = await fetch(`${CONFIG.API.STREAM_URL}?station=${encodeURIComponent(moduleState.current_station)}`); if (!response.ok) { console.warn('Stream validation failed - server response not OK:', response.status); return false; } const streamData = await response.json(); if (!streamData || !streamData.currentVideo || !streamData.currentVideo.url) { console.warn('Stream validation failed - invalid stream data'); return false; } console.log('Stream URL validated successfully'); return true; } catch (error) { console.error('Stream validation error:', error); return false; } } /** * Waits for Video.js player to be fully ready * @param {Object} player - Video.js player instance * @returns {Promise} True if player is ready */ function waitForPlayerReady(player) { return new Promise((resolve) => { if (!player) { resolve(false); return; } const checkReady = () => { // Check if player is ready and tech is available if (player.readyState() >= 1 && player.tech() && !player.error()) { console.log('Player is ready for audio loading'); moduleState.playerReady = true; resolve(true); } else if (player.error()) { console.warn('Player has error, cannot proceed with audio loading'); resolve(false); } else { // Player not ready yet, check again setTimeout(checkReady, 200); } }; // Start checking if (player.readyState() >= 1) { checkReady(); } else { player.ready(() => { checkReady(); }); } }); } /** * Attempts to load and play audio with retry logic * @param {Object} player - Video.js player instance * @param {boolean} isRetry - Whether this is a retry attempt * @returns {Promise} True if successful */ async function attemptAudioLoad(player, isRetry = false) { try { if (!player) { throw new Error('Player not available'); } if (moduleState.isLoadingStream) { console.log('Audio load already in progress, skipping'); return false; } moduleState.isLoadingStream = true; setRadioPlayState('loading'); if (isRetry) { console.log(`Attempting audio load retry ${moduleState.audioRetryAttempts + 1}/${moduleState.maxRetryAttempts + 1}`); } else { console.log('Attempting initial audio load'); } // Wait for player to be ready const playerReady = await waitForPlayerReady(player); if (!playerReady) { throw new Error('Player not ready for audio loading'); } // Validate stream before attempting to play if (!moduleState.streamValidated) { const streamValid = await validateStreamUrl(); if (!streamValid) { throw new Error('Stream validation failed'); } moduleState.streamValidated = true; } // Attempt to play const playPromise = player.play(); if (playPromise !== undefined) { await playPromise; setRadioPlayState('playing'); console.log('Audio loading successful'); // Reset retry state on success moduleState.audioRetryAttempts = 0; moduleState.isLoadingStream = false; return true; } else { throw new Error('Play promise not available'); } } catch (error) { moduleState.isLoadingStream = false; // Check if this is NotAllowedError (browser autoplay restriction) if (error.name === 'NotAllowedError' || error.message.includes('user didn\'t interact') || (error instanceof DOMException && error.name === 'NotAllowedError')) { console.log('Autoplay prevented by browser - user interaction required'); setRadioPlayState('paused'); // Update status to be more user-friendly const statusIndicator = document.getElementById('radio-status-indicator'); if (statusIndicator) { statusIndicator.textContent = 'Click play to start audio'; } return false; } // For other errors, continue with existing retry logic console.error('Audio load attempt failed:', error); // Determine if we should retry const shouldRetry = !isRetry && moduleState.audioRetryAttempts < moduleState.maxRetryAttempts; if (shouldRetry) { moduleState.audioRetryAttempts++; console.log(`Scheduling retry in ${moduleState.retryDelayMs}ms (attempt ${moduleState.audioRetryAttempts})`); setRadioPlayState('loading'); setTimeout(() => { attemptAudioLoad(player, true); }, moduleState.retryDelayMs); return false; } else { console.error('Audio loading failed after all retry attempts'); setRadioPlayState('error'); return false; } } } /** * Starts radio playback with enhanced error handling and retry logic */ function playRadio() { try { if (!moduleState.player) { console.warn('Player not available for radio playback'); return; } // Reset retry state for new play attempt moduleState.audioRetryAttempts = 0; moduleState.isLoadingStream = false; attemptAudioLoad(moduleState.player); } catch (error) { console.error('Error starting radio playback:', error); setRadioPlayState('error'); } } /** * Pauses radio playback */ function pauseRadio() { try { if (!moduleState.player) { console.warn('Player not available for radio pause'); return; } moduleState.player.pause(); setRadioPlayState('paused'); console.log('Radio playback paused'); } catch (error) { console.error('Error pausing radio playback:', error); } } /** * Sets the radio play state and updates UI * @param {string} state - Play state ('playing', 'paused', 'loading', 'error') */ function setRadioPlayState(state) { try { moduleState.radioPlayState = state; updateRadioControlsUI(state); updateRadioStatusIndicator(state); } catch (error) { console.error('Error setting radio play state:', error); } } /** * Updates radio controls UI based on play state * @param {string} state - Current play state */ function updateRadioControlsUI(state) { try { const radioIcon = document.getElementById('radio-mode-icon'); if (!radioIcon) return; // Remove existing state classes radioIcon.classList.remove('playing', 'paused', 'loading', 'error'); radioIcon.style.animation = 'radiomode-pulse 2s ease-in-out infinite'; switch (state) { case 'playing': radioIcon.classList.add('playing'); radioIcon.title = 'Click to pause'; radioIcon.style.animation = 'radio-playing-pulse 2s ease-in-out infinite'; break; case 'paused': radioIcon.classList.add('paused'); radioIcon.title = 'Click to play'; break; case 'loading': radioIcon.classList.add('loading'); radioIcon.title = 'Loading...'; break; case 'error': radioIcon.classList.add('error'); radioIcon.title = 'Connection error - Click to retry'; break; default: radioIcon.classList.add('paused'); radioIcon.title = 'Click to play'; } } catch (error) { console.error('Error updating radio controls UI:', error); } } /** * Updates radio status indicator text with retry information * @param {string} state - Current play state */ function updateRadioStatusIndicator(state) { try { const statusIndicator = document.getElementById('radio-status-indicator'); if (!statusIndicator) return; const statusTexts = { 'playing': 'Now Playing', 'paused': 'Click play to start audio', 'loading': moduleState.audioRetryAttempts > 0 ? `Retrying... (${moduleState.audioRetryAttempts}/${moduleState.maxRetryAttempts})` : 'Loading stream...', 'error': 'Connection error - Click to retry' }; statusIndicator.textContent = statusTexts[state] || 'Ready to play'; } catch (error) { console.error('Error updating radio status indicator:', error); } } /** * Enhanced stream URL update with better error handling for radio mode * @param {Object} player - Video.js player instance * @param {string} station - Station identifier */ async function updateStreamURLWithRetry(player, station) { if (!player || !station) { console.warn('updateStreamURLWithRetry: Missing required parameters'); return; } try { const response = await fetch(`${CONFIG.API.STREAM_URL}?station=${encodeURIComponent(station)}`); if (!response.ok) { console.warn('Stream not available, showing coming soon poster'); if (moduleState.radioMode) { setRadioPlayState('error'); } player.pause(); player.currentTime(0); player.src(''); showComingSoonPoster(station); return; } const newUrl = await response.json(); moduleState.serverTime = newUrl.serverTime; // Update global reference for backwards compatibility global.current_content = newUrl; moduleState.current_content = newUrl; if (newUrl.currentVideo && newUrl.currentVideo.url !== player.src() || player.error_ !== null) { const new_src = { src: newUrl.currentVideo.url, type: newUrl.currentVideo.url.endsWith('.m3u8') ? 'application/x-mpegURL' : 'video/mp4', }; player.src(new_src); if (newUrl.currentTimeInVideo && newUrl.currentTimeInVideo > CONFIG.TIME_SYNC_THRESHOLD) { player.currentTime(newUrl.currentTimeInVideo); } if (newUrl.currentVideo.url.endsWith('.mp4')) { player.load(); } try { const playPromise = player.play(); if (playPromise !== undefined) { await playPromise; if (moduleState.radioMode) { setRadioPlayState('playing'); // Reset validation state on successful play moduleState.streamValidated = true; } } } catch (playError) { if (moduleState.radioMode) { // Check if this is NotAllowedError (browser autoplay restriction) if (playError.name === 'NotAllowedError' || playError.message.includes('user didn\'t interact') || (playError instanceof DOMException && playError.name === 'NotAllowedError')) { console.log('Autoplay prevented by browser - user interaction required'); setRadioPlayState('paused'); // Update status to be more user-friendly const statusIndicator = document.getElementById('radio-status-indicator'); if (statusIndicator) { statusIndicator.textContent = 'Click play to start audio'; } } else { // For other play errors, attempt retry if available console.warn('Play error occurred:', playError); if (moduleState.audioRetryAttempts < moduleState.maxRetryAttempts) { setTimeout(() => { attemptAudioLoad(player, true); }, moduleState.retryDelayMs); } else { setRadioPlayState('error'); } } } else { console.warn('Autoplay prevented:', playError); } } } } catch (error) { console.error('Fetch error:', error); if (moduleState.radioMode && moduleState.audioRetryAttempts < moduleState.maxRetryAttempts) { console.log('Network error in stream update, will retry on next interval'); setRadioPlayState('loading'); } } } /** * Sets up keyboard controls for radio mode */ function setupRadioKeyboardControls() { try { if (!moduleState.radioMode) return; const handleKeyPress = (event) => { // Only handle spacebar when radio mode is active and focus is not in an input if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') { event.preventDefault(); handleRadioPlayPause(); } }; document.addEventListener('keydown', handleKeyPress); // Store reference for cleanup moduleState.keyboardHandler = handleKeyPress; } catch (error) { console.error('Error setting up radio keyboard controls:', error); } } /** * Applies radio mode styling to video element * @param {HTMLVideoElement} videoElement - Video element to style */ function applyRadioModeToVideo(videoElement) { if (!videoElement) return; try { // Hide video display but keep it functional for audio Object.assign(videoElement.style, { visibility: 'hidden', position: 'absolute', width: '1px', height: '1px', opacity: '0' }); // Ensure audio continues to work videoElement.muted = false; } catch (error) { console.error('Error applying radio mode to video:', error); } } /** * Dynamically loads CSS file if not already present * @param {string} id - Unique identifier for the CSS link * @param {string} href - URL of the CSS file */ function loadCSS(id, href) { if (!id || !href) { console.warn('loadCSS: Missing required parameters'); return; } if (!document.getElementById(id)) { try { const link = document.createElement('link'); link.id = id; link.rel = 'stylesheet'; link.type = 'text/css'; link.href = href; link.media = 'all'; document.head.appendChild(link); } catch (error) { console.error('Error loading CSS:', error); } } } /** * Dynamically loads JavaScript file if not already present * @param {string} id - Unique identifier for the script * @param {string} src - URL of the JavaScript file * @param {Function} callback - Callback function to execute after loading */ function loadJS(id, src, callback) { if (!id || !src) { console.warn('loadJS: Missing required parameters'); return; } if (!document.getElementById(id)) { try { const script = document.createElement('script'); script.id = id; script.src = src; script.onload = callback || null; script.onerror = function() { console.error('Failed to load script:', src); }; document.head.appendChild(script); } catch (error) { console.error('Error loading JavaScript:', error); if (callback) callback(); } } else if (callback) { callback(); } } /** * Creates and configures the video element with Video.js settings * @param {string} station - Station identifier for the stream * @returns {HTMLVideoElement} Configured video element */ function createVideoElement(station) { if (!station) { console.warn('createVideoElement: Station parameter is required'); return null; } try { // Check if radio mode is enabled moduleState.radioMode = isRadioModeEnabled(); const video = document.createElement('video'); video.id = CONFIG.ELEMENTS.VIDEO; video.className = 'video-js vjs-default-skin'; video.controls = true; // Keep this true to enable custom control bar video.preload = 'auto'; // Preload the video // Configure Video.js control bar settings const videoJsConfig = { controlBar: { playToggle: false, currentTimeDisplay: false, timeDivider: false, durationDisplay: false, remainingTimeDisplay: false, progressControl: false, liveDisplay: false, captionsButton: false, subtitlesButton: false, audioTrackButton: false, playbackRateMenuButton: false, chaptersButton: false, descriptionsButton: false, customControlSpacer: false, volumePanel: true, // Enable volume control fullscreenToggle: !moduleState.radioMode, // Disable fullscreen in radio mode loadingSpinner: false, children: { loadingSpinner: false, }, }, }; video.setAttribute('data-setup', JSON.stringify(videoJsConfig)); // Apply radio mode styling if enabled if (moduleState.radioMode) { applyRadioModeToVideo(video); console.log('Radio mode enabled - video element configured for audio-only playback'); } return video; } catch (error) { console.error('Error creating video element:', error); return null; } } /** * Updates the stream URL and synchronizes with server * @param {Object} player - Video.js player instance * @param {string} station - Station identifier */ async function updateStreamURL(player, station) { if (!player || !station) { console.warn('updateStreamURL: Missing required parameters'); return; } try { const response = await fetch(`${CONFIG.API.STREAM_URL}?station=${encodeURIComponent(station)}`); if (!response.ok) { console.warn('Stream not available, showing coming soon poster'); player.pause(); player.currentTime(0); player.src(''); showComingSoonPoster(station); return; } const newUrl = await response.json(); moduleState.serverTime = newUrl.serverTime; // Update global reference for backwards compatibility global.current_content = newUrl; moduleState.current_content = newUrl; if (newUrl.currentVideo && newUrl.currentVideo.url !== player.src() || player.error_ !== null) { const new_src = { src: newUrl.currentVideo.url, type: newUrl.currentVideo.url.endsWith('.m3u8') ? 'application/x-mpegURL' : 'video/mp4', }; player.src(new_src); if (newUrl.currentTimeInVideo && newUrl.currentTimeInVideo > CONFIG.TIME_SYNC_THRESHOLD) { player.currentTime(newUrl.currentTimeInVideo); } if (newUrl.currentVideo.url.endsWith('.mp4')) { player.load(); } try { const playPromise = player.play(); if (playPromise !== undefined) { await playPromise; if (moduleState.radioMode) { setRadioPlayState('playing'); } } } catch (playError) { if (moduleState.radioMode) { // Check if this is NotAllowedError (browser autoplay restriction) if (playError.name === 'NotAllowedError' || playError.message.includes('user didn\'t interact') || (playError instanceof DOMException && playError.name === 'NotAllowedError')) { console.log('Autoplay prevented by browser - user interaction required'); setRadioPlayState('paused'); // Update status to be more user-friendly const statusIndicator = document.getElementById('radio-status-indicator'); if (statusIndicator) { statusIndicator.textContent = 'Click play to start audio'; } } else { console.warn('Play error occurred:', playError); setRadioPlayState('error'); } } else { console.warn('Autoplay prevented:', playError); } } } } catch (error) { console.error('Fetch error:', error); // Don't show coming soon poster on network errors, just log } } /** * Sets up the video player with event handlers and configuration * @param {string} station - Station identifier for the stream */ function setupVideoPlayer(station) { if (!station) { console.error('setupVideoPlayer: Station parameter is required'); return; } // Cleanup existing interval if any if (moduleState.updateInterval) { clearInterval(moduleState.updateInterval); moduleState.updateInterval = null; } // Load required CSS and JavaScript loadCSS('vjscss', CONFIG.VIDEOJS.CSS); loadCSS('livecss', CONFIG.API.LIVE_CSS); loadJS('vjsscript', CONFIG.VIDEOJS.JS, async () => { try { const container = document.getElementById(CONFIG.ELEMENTS.CONTAINER); if (!container) { console.error('Container element not found:', CONFIG.ELEMENTS.CONTAINER); return; } const video = createVideoElement(station); if (!video) { console.error('Failed to create video element'); return; } container.appendChild(video); // Show appropriate UI based on mode if (moduleState.radioMode) { createRadioModeOverlay(); setupRadioKeyboardControls(); } else { showPoster(station); } // Initialize Video.js player const player = videojs(CONFIG.ELEMENTS.VIDEO); moduleState.player = player; global.player = player; // Maintain global reference for backwards compatibility // Store station for stream validation moduleState.current_station = station; // Apply radio mode controls styling if enabled if (moduleState.radioMode) { const controlBar = player.controlBar.el(); if (controlBar) { controlBar.classList.add('radio-mode-controls'); } } // Set up event handlers player.on('ended', function () { console.log('Video ended. Updating stream...'); player.controls(false); updateStreamURLWithRetry(player, station); }); player.on('timeupdate', function(timeUpdate) { handleTimeUpdate(timeUpdate); }); player.on('waiting', function () { // Reserved for future functionality }); player.on('seeked', function () { // Reserved for future functionality }); player.on('playing', function () { if (moduleState.radioMode) { // Keep radio overlay visible but ensure controls are available player.controls(true); setRadioPlayState('playing'); } else { hidePoster(); player.controls(true); } }); player.on('pause', function () { if (moduleState.radioMode) { setRadioPlayState('paused'); } }); player.on('waiting', function () { if (moduleState.radioMode) { setRadioPlayState('loading'); } }); player.on('error', function (error) { console.error('Player error occurred:', error); if (moduleState.radioMode) { // Reset loading state and attempt retry if not already retrying moduleState.isLoadingStream = false; // Check if this is a recoverable error const errorCode = player.error()?.code; const isRecoverableError = errorCode === 2 || errorCode === 4; // Network or media decode errors if (isRecoverableError && moduleState.audioRetryAttempts < moduleState.maxRetryAttempts) { console.log('Recoverable error detected, attempting retry'); setRadioPlayState('loading'); setTimeout(() => { // Reset validation to force stream re-check moduleState.streamValidated = false; attemptAudioLoad(player, true); }, moduleState.retryDelayMs); } else { setRadioPlayState('error'); } } }); // Hide Video.js default poster and spinner const spinner = document.getElementsByClassName('vjs-loading-spinner')[0]; const vjsPoster = document.getElementsByClassName('vjs-poster')[0]; if (spinner) spinner.style.display = 'none'; if (vjsPoster) vjsPoster.style.display = 'none'; // Start playback if autoplay is not disabled if (window.location.href.indexOf('autoplay=false') === -1) { updateStreamURLWithRetry(player, station); // Auto-start radio mode if enabled if (moduleState.radioMode) { setTimeout(() => { playRadio(); }, 2000); // Increased delay to ensure player is fully initialized } } // Set up periodic stream updates using enhanced retry logic moduleState.updateInterval = setInterval(() => { updateStreamURLWithRetry(player, station); }, CONFIG.UPDATE_INTERVAL); } catch (error) { console.error('Error setting up video player:', error); } }); } /** * Creates or returns existing black overlay for fade effects * @returns {HTMLElement} Overlay element */ function createOverlay() { let overlay = document.getElementById(CONFIG.ELEMENTS.OVERLAY); if (!overlay) { try { const videoElement = document.getElementById(CONFIG.ELEMENTS.VIDEO); if (!videoElement) { console.warn('Video element not found for overlay'); return null; } overlay = document.createElement('div'); overlay.id = CONFIG.ELEMENTS.OVERLAY; Object.assign(overlay.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'black', zIndex: '1', display: 'none' }); videoElement.appendChild(overlay); } catch (error) { console.error('Error creating overlay:', error); return null; } } return overlay; } /** * Handles fade-in effect near video end * @param {HTMLElement} overlay - Overlay element * @param {Object} player - Video.js player instance */ function handleFadeIn(overlay, player) { if (!overlay || !player) { return; } try { const timeLeft = player.duration() - player.currentTime(); // Only apply fade-in for videos longer than threshold if (player.duration() >= CONFIG.FADE_IN_THRESHOLD) { if (timeLeft < 1) { overlay.style.display = 'block'; overlay.style.opacity = Math.max(0, 1 - timeLeft); } else { overlay.style.opacity = '0'; overlay.style.display = 'none'; } } else { overlay.style.opacity = '0'; overlay.style.display = 'none'; } } catch (error) { console.error('Error handling fade-in:', error); } } /** * Shows the poster with play button overlay * @param {string} station - Station identifier * @returns {HTMLElement} Poster element */ function showPoster(station) { if (!station) { console.warn('showPoster: Station parameter is required'); return null; } try { const container = document.getElementById(CONFIG.ELEMENTS.CONTAINER); if (!container) { console.error('Container element not found'); return null; } const poster = document.createElement('div'); poster.id = CONFIG.ELEMENTS.POSTER; Object.assign(poster.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', zIndex: '1', backgroundImage: `url("${CONFIG.API.POSTER_URL}/${encodeURIComponent(station)}/liveposter")`, backgroundSize: 'cover', cursor: 'pointer' }); poster.onclick = function () { moduleState.status = 'playing'; global.status = 'playing'; // Maintain global reference if (moduleState.player) { moduleState.player.play(); } hidePoster(); }; // Create the play button const playButton = document.createElement('div'); playButton.className = 'vjs-icon-play'; Object.assign(playButton.style, { position: 'absolute', top: '50%', left: '50%', fontSize: '50px', transform: 'translate(-50%, -50%)', color: 'white', cursor: 'pointer' }); poster.appendChild(playButton); container.appendChild(poster); return poster; } catch (error) { console.error('Error showing poster:', error); return null; } } /** * Shows coming soon poster when stream is unavailable * @param {string} station - Station identifier * @returns {HTMLElement} Coming soon poster element */ function showComingSoonPoster(station) { if (!station) { console.warn('showComingSoonPoster: Station parameter is required'); return null; } try { const container = document.getElementById(CONFIG.ELEMENTS.CONTAINER); if (!container) { console.error('Container element not found'); return null; } const poster = document.createElement('div'); poster.id = CONFIG.ELEMENTS.POSTER; Object.assign(poster.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', zIndex: '1', backgroundImage: `url("${CONFIG.API.POSTER_URL}/${encodeURIComponent(station)}/liveposter")`, backgroundSize: 'cover', display: 'flex', justifyContent: 'center', alignItems: 'center', }); const comingSoonText = document.createElement('div'); Object.assign(comingSoonText.style, { color: '#FFFFFF', fontSize: '2em', fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", textShadow: '2px 2px 4px rgba(0,0,0,0.5)', padding: '20px 40px', background: 'rgba(255, 255, 255, 0.2)', borderRadius: '15px', border: '2px solid #FFFFFF', backdropFilter: 'blur(5px)', textAlign: 'center' }); comingSoonText.innerText = "We'll be back soon"; poster.appendChild(comingSoonText); container.appendChild(poster); return poster; } catch (error) { console.error('Error showing coming soon poster:', error); return null; } } /** * Hides the current poster element */ function hidePoster() { try { const poster = document.getElementById(CONFIG.ELEMENTS.POSTER); if (poster && poster.parentElement) { poster.parentElement.removeChild(poster); } } catch (error) { console.error('Error hiding poster:', error); } } /** * Calculates time offset between client and server * @param {Object} currentContent - Current content data from server * @param {Object} player - Video.js player instance * @returns {number} Time offset in seconds */ function calculateTimeOffset(currentContent, player) { if (!currentContent || !player) { return 0; } try { const timeSinceContent = currentContent.serverTime - Date.now() / 1000; const serverPosition = currentContent.currentTimeInVideo - timeSinceContent; const offset = player.currentTime() - serverPosition; return offset; } catch (error) { console.error('Error calculating time offset:', error); return 0; } } /** * Adjusts playback speed based on synchronization offset * @param {Object} player - Video.js player instance * @param {number} offset - Time offset in seconds */ function adjustPlaybackSpeed(player, offset) { if (!player || typeof offset !== 'number') { return; } try { const timeLeft = player.duration() - player.currentTime(); const timeSinceContent = Date.now() / 1000 - moduleState.current_content.serverTime; // Only adjust speed for videos longer than threshold if (player.duration() >= CONFIG.SYNC_THRESHOLD) { if (offset < 0 && timeLeft > 0) { if (offset < CONFIG.LARGE_OFFSET_THRESHOLD) { // Large offset: jump to correct position player.currentTime( moduleState.current_content.currentTimeInVideo + timeSinceContent ); } // Calculate playback speed adjustment const playbackSpeed = Math.min( 1 + (offset / CONFIG.SPEED_ADJUSTMENT_FACTOR) * -1, CONFIG.MAX_PLAYBACK_SPEED ); player.playbackRate(playbackSpeed); } else { player.playbackRate(1); // Reset to default speed } } else { player.playbackRate(1); // Reset to default speed for short videos } } catch (error) { console.error('Error adjusting playback speed:', error); player.playbackRate(1); // Fallback to default speed } } /** * Handles time update events for synchronization and effects * @param {Event} timeUpdate - Time update event */ function handleTimeUpdate(timeUpdate) { try { const overlay = createOverlay(); if (overlay && moduleState.player) { handleFadeIn(overlay, moduleState.player); } if (moduleState.current_content && moduleState.player) { const offset = calculateTimeOffset(moduleState.current_content, moduleState.player); adjustPlaybackSpeed(moduleState.player, offset); } } catch (error) { console.error('Error handling time update:', error); } } // Expose functions globally for backwards compatibility global.loadCSS = loadCSS; global.loadJS = loadJS; global.createVideoElement = createVideoElement; global.updateStreamURL = updateStreamURL; global.setupVideoPlayer = setupVideoPlayer; global.createOverlay = createOverlay; global.handleFadeIn = handleFadeIn; global.showPoster = showPoster; global.showComingSoonPoster = showComingSoonPoster; global.hidePoster = hidePoster; global.calculateTimeOffset = calculateTimeOffset; global.adjustPlaybackSpeed = adjustPlaybackSpeed; global.handleTimeUpdate = handleTimeUpdate; // Expose radio mode functions global.getQueryParams = getQueryParams; global.isRadioModeEnabled = isRadioModeEnabled; global.createRadioModeOverlay = createRadioModeOverlay; global.addRadioModeCSS = addRadioModeCSS; global.applyRadioModeToVideo = applyRadioModeToVideo; // Expose radio control functions global.createRadioControls = createRadioControls; global.handleRadioPlayPause = handleRadioPlayPause; global.playRadio = playRadio; global.pauseRadio = pauseRadio; global.setRadioPlayState = setRadioPlayState; global.setupRadioKeyboardControls = setupRadioKeyboardControls; // Cleanup function for proper resource management global.cleanupLivePlayer = function() { if (moduleState.updateInterval) { clearInterval(moduleState.updateInterval); moduleState.updateInterval = null; } // Clean up radio mode keyboard handler if (moduleState.keyboardHandler) { document.removeEventListener('keydown', moduleState.keyboardHandler); moduleState.keyboardHandler = null; } if (moduleState.player) { try { moduleState.player.dispose(); } catch (error) { console.error('Error disposing player:', error); } moduleState.player = null; global.player = null; } // Reset radio mode state and retry state moduleState.radioPlayState = 'paused'; moduleState.radioControlsCreated = false; moduleState.audioRetryAttempts = 0; moduleState.isLoadingStream = false; moduleState.streamValidated = false; moduleState.playerReady = false; console.log('Live player cleanup completed with enhanced state reset'); }; })(window); // Initialize with the station parameter (maintains backwards compatibility) setupVideoPlayer("hmongusatv");