/**
* 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();
});
// Picture-in-Picture
const btnPip = document.getElementById('btn-pip');
btnPip?.addEventListener('click', (e) => {
e.stopPropagation();
this.togglePictureInPicture();
});
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 Picture-in-Picture mode
*/
async togglePictureInPicture() {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled && this.video.readyState >= 2) {
await this.video.requestPictureInPicture();
}
} catch (err) {
// Silently fail - Firefox users can use native PiP button
if (err.name !== 'NotAllowedError') {
console.error('Picture-in-Picture 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