Files
KiraTV-APP-NodeCast-Modific…/public/js/components/VideoPlayer.js
T
Trevor Mears 4f9ec55874 Initial commit
2025-12-27 14:16:40 -08:00

506 lines
18 KiB
JavaScript

/**
* Video Player Component
* Handles HLS video playback with custom controls
*/
class VideoPlayer {
constructor() {
this.video = document.getElementById('video-player');
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);
}
}
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({
enableWorker: true,
lowLatencyMode: true
});
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;
if (finalUrl.includes('.m3u8') && Hls.isSupported()) {
this.hls = new Hls();
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')) {
// Native HLS support (Safari)
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 {
// Try direct playback
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 = `<p style="color: var(--color-error);">${message}</p>`;
}
/**
* 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 {
this.video.requestFullscreen();
}
}
}
// Export
window.VideoPlayer = VideoPlayer;