Proyecto KiraTV
Build and Publish Container Image / build-and-push (push) Has been cancelled

This commit is contained in:
root
2026-05-09 19:31:07 +00:00
commit 6f6e3161ec
76 changed files with 30802 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
/**
* API Client - Frontend API wrapper for NodeCast TV
*/
const API = {
/**
* Make API request
*/
async request(method, endpoint, data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json'
}
};
// Add authentication token if available
const token = localStorage.getItem('authToken');
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch(`/api${endpoint}`, options);
let result;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
result = await response.json();
} else {
const text = await response.text();
result = { error: text || 'API request failed' };
}
if (!response.ok) {
// If unauthorized, redirect to login
if (response.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
return;
}
throw new Error(result.error || `Server responded with ${response.status}`);
}
return result;
},
// Sources
sources: {
getAll: () => API.request('GET', '/sources'),
getByType: (type) => API.request('GET', `/sources/type/${type}`),
getById: (id) => API.request('GET', `/sources/${id}`),
create: (data) => API.request('POST', '/sources', data),
update: (id, data) => API.request('PUT', `/sources/${id}`, data),
delete: (id) => API.request('DELETE', `/sources/${id}`),
toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
test: (id) => API.request('POST', `/sources/${id}/test`),
sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync
getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size
estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
},
// Channels (hidden items)
channels: {
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }),
bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }),
// Fast bulk operations - single SQL statement
showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }),
hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType })
},
// Favorites
favorites: {
getAll: (sourceId = null, itemType = null) => {
let url = '/favorites';
const params = [];
if (sourceId) params.push(`sourceId=${sourceId}`);
if (itemType) params.push(`itemType=${itemType}`);
if (params.length) url += '?' + params.join('&');
return API.request('GET', url);
},
add: (sourceId, itemId, itemType = 'channel') =>
API.request('POST', '/favorites', { sourceId, itemId, itemType }),
remove: (sourceId, itemId, itemType = 'channel') =>
API.request('DELETE', '/favorites', { sourceId, itemId, itemType }),
check: (sourceId, itemId, itemType = 'channel') =>
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
},
// Proxy
proxy: {
// Xtream
xtream: {
auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`),
liveCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`);
},
liveStreams: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`);
},
vodCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`);
},
vodStreams: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`);
},
seriesCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`);
},
series: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`);
},
seriesInfo: (sourceId, seriesId) =>
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') =>
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
},
// EPG
epg: {
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
},
// Cache management
cache: {
clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`)
}
},
// Settings
settings: {
get: () => API.request('GET', '/settings'),
update: (data) => API.request('PUT', '/settings', data),
reset: () => API.request('DELETE', '/settings'),
getDefaults: () => API.request('GET', '/settings/defaults')
},
// Users (admin only)
users: {
getAll: () => API.request('GET', '/auth/users'),
create: (data) => API.request('POST', '/auth/users', data),
update: (id, data) => API.request('PUT', `/auth/users/${id}`, data),
delete: (id) => API.request('DELETE', `/auth/users/${id}`)
}
};
// Make API available globally
window.API = API;
+298
View File
@@ -0,0 +1,298 @@
/**
* KiraTV Application Entry Point
*/
class App {
constructor() {
this.currentPage = 'home';
this.pages = {};
this.currentUser = null;
// Initialize components
this.player = new VideoPlayer();
this.channelList = new ChannelList();
this.sourceManager = new SourceManager();
this.epgGuide = new EpgGuide();
// Initialize page controllers
this.pages.home = new HomePage(this);
this.pages.live = new LivePage(this);
this.pages.guide = new GuidePage(this);
this.pages.movies = new MoviesPage(this);
this.pages.series = new SeriesPage(this);
this.pages.settings = new SettingsPage(this);
this.pages.watch = new WatchPage(this);
this.init();
}
async init() {
// Check authentication first
await this.checkAuth();
// Mobile menu toggle
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const navbarMenu = document.getElementById('navbar-menu');
if (mobileMenuToggle && navbarMenu) {
mobileMenuToggle.addEventListener('click', () => {
mobileMenuToggle.classList.toggle('active');
navbarMenu.classList.toggle('active');
});
// Close menu when a nav link is clicked
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', () => {
mobileMenuToggle.classList.remove('active');
navbarMenu.classList.remove('active');
});
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar')) {
mobileMenuToggle.classList.remove('active');
navbarMenu.classList.remove('active');
}
});
}
// Channel drawer toggle (mobile)
const channelToggleBtn = document.getElementById('channel-toggle-btn');
const channelSidebar = document.getElementById('channel-sidebar');
const channelOverlay = document.getElementById('channel-sidebar-overlay');
if (channelToggleBtn && channelSidebar && channelOverlay) {
const toggleChannelDrawer = () => {
channelSidebar.classList.toggle('active');
channelOverlay.classList.toggle('active');
};
channelToggleBtn.addEventListener('click', toggleChannelDrawer);
channelOverlay.addEventListener('click', toggleChannelDrawer);
// Close drawer when a channel is selected
channelSidebar.addEventListener('click', (e) => {
if (e.target.closest('.channel-item')) {
// Small delay to let the channel selection happen
setTimeout(() => {
channelSidebar.classList.remove('active');
channelOverlay.classList.remove('active');
}, 300);
}
});
}
// Desktop sidebar collapse toggle
const sidebarCollapseBtn = document.getElementById('sidebar-collapse-btn');
const sidebarExpandBtn = document.getElementById('sidebar-expand-btn');
const homeLayout = document.querySelector('.home-layout');
const toggleSidebarCollapse = () => {
channelSidebar?.classList.toggle('collapsed');
homeLayout?.classList.toggle('sidebar-collapsed');
// Persist preference
const isCollapsed = channelSidebar?.classList.contains('collapsed');
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
};
sidebarCollapseBtn?.addEventListener('click', toggleSidebarCollapse);
sidebarExpandBtn?.addEventListener('click', toggleSidebarCollapse);
// Restore sidebar state from localStorage
if (localStorage.getItem('sidebarCollapsed') === 'true') {
channelSidebar?.classList.add('collapsed');
homeLayout?.classList.add('sidebar-collapsed');
}
// Navigation handling
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo(link.dataset.page);
});
});
// Now Playing indicator
const nowPlayingBtn = document.getElementById('now-playing-indicator');
if (nowPlayingBtn) {
nowPlayingBtn.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo('watch');
});
}
// Toggle groups button
document.getElementById('toggle-groups').addEventListener('click', () => {
this.channelList.toggleAllGroups();
});
// Search clear buttons (global handler for all)
document.querySelectorAll('.search-clear').forEach(btn => {
btn.addEventListener('click', () => {
const wrapper = btn.closest('.search-wrapper');
const input = wrapper?.querySelector('.search-input');
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
});
});
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
const page = e.state?.page || 'home';
this.navigateTo(page, false); // false = don't add to history
});
// Initialize home page first (it's needed for channel list)
await this.pages.home.init();
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
await this.channelList.loadSources();
await this.channelList.loadChannels();
// Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => {
console.warn('Background EPG load failed:', err.message);
});
// Navigate to the page from URL hash, or default to home
const hash = window.location.hash.slice(1); // Remove #
const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('KiraTV initialized');
}
async checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) {
// No token, redirect to login (replace to avoid back button issues)
window.location.replace('/login.html');
return;
}
try {
// Verify token with server
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Invalid token');
}
this.currentUser = await response.json();
// Hide settings for viewers
if (this.currentUser.role === 'viewer') {
const settingsLink = document.querySelector('.nav-link[data-page="settings"]');
if (settingsLink) {
settingsLink.style.display = 'none';
}
}
// Add logout button to navbar
this.addLogoutButton();
} catch (err) {
console.error('Authentication error:', err);
localStorage.removeItem('authToken');
window.location.replace('/login.html');
}
}
addLogoutButton() {
const navbar = document.querySelector('.navbar-menu');
if (!navbar || document.getElementById('logout-btn')) return;
const logoutLink = document.createElement('a');
logoutLink.href = '#';
logoutLink.className = 'nav-link';
logoutLink.id = 'logout-btn';
logoutLink.innerHTML = `
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg></span>
<span>Logout</span>
`;
logoutLink.addEventListener('click', async (e) => {
e.preventDefault();
const token = localStorage.getItem('authToken');
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
}
localStorage.removeItem('authToken');
window.location.replace('/login.html');
});
navbar.appendChild(logoutLink);
}
navigateTo(pageName, replaceHistory = false) {
// Don't navigate if already on this page
if (this.currentPage === pageName && !replaceHistory) {
return;
}
// Update browser history
if (replaceHistory) {
// Replace current history entry (used on initial load)
history.replaceState({ page: pageName }, '', `#${pageName}`);
} else {
// Add new history entry
history.pushState({ page: pageName }, '', `#${pageName}`);
}
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === pageName);
});
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageName}`);
});
// Notify page controllers
if (this.pages[this.currentPage]?.hide) {
this.pages[this.currentPage].hide();
}
this.currentPage = pageName;
if (this.pages[pageName]?.show) {
this.pages[pageName].show();
}
}
}
// Start app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.app = new App();
// Fetch and display version badge
fetch('/api/version')
.then(res => res.json())
.then(data => {
const badge = document.getElementById('version-badge');
if (badge && data.version) badge.textContent = `v${data.version}`;
})
.catch(() => { });
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Auth Manager - Frontend authentication state management
*/
const Auth = {
currentUser: null,
/**
* Initialize auth - check setup status and current user
*/
async init() {
try {
// Check if setup is required
const setupStatus = await API.auth.checkSetup();
if (setupStatus.setupRequired) {
this.showSetup();
return false;
}
// Check if user is logged in
if (API.getToken()) {
try {
this.currentUser = await API.auth.me();
return true;
} catch (error) {
// Token invalid, clear it
console.log('Token invalid, showing login');
API.setToken(null);
this.showLogin();
return false;
}
} else {
this.showLogin();
return false;
}
} catch (error) {
console.error('Auth initialization failed:', error);
// On any error, show login (don't loop)
this.showLogin();
return false;
}
},
/**
* Show setup screen
*/
showSetup() {
document.getElementById('setup-screen').classList.add('active');
document.getElementById('login-screen').classList.remove('active');
document.getElementById('app').classList.remove('active');
},
/**
* Show login screen
*/
showLogin() {
document.getElementById('setup-screen').classList.remove('active');
document.getElementById('login-screen').classList.add('active');
document.getElementById('app').classList.remove('active');
},
/**
* Show main app
*/
showApp() {
document.getElementById('setup-screen').classList.remove('active');
document.getElementById('login-screen').classList.remove('active');
document.getElementById('app').classList.add('active');
// Hide settings tab if viewer
if (!this.isAdmin()) {
const settingsLink = document.querySelector('[data-page="settings"]');
if (settingsLink) {
settingsLink.parentElement.style.display = 'none';
}
}
},
/**
* Setup initial admin user
*/
async setup(username, password) {
try {
const result = await API.auth.setup(username, password);
API.setToken(result.token);
this.currentUser = result.user;
this.showApp();
return true;
} catch (error) {
throw error;
}
},
/**
* Login user
*/
async login(username, password) {
try {
const result = await API.auth.login(username, password);
API.setToken(result.token);
this.currentUser = result.user;
this.showApp();
return true;
} catch (error) {
throw error;
}
},
/**
* Logout user
*/
async logout() {
try {
await API.auth.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
API.setToken(null);
this.currentUser = null;
this.showLogin();
}
},
/**
* Check if current user is admin
*/
isAdmin() {
return this.currentUser && this.currentUser.role === 'admin';
},
/**
* Check if current user is viewer
*/
isViewer() {
return this.currentUser && this.currentUser.role === 'viewer';
},
/**
* Get current user
*/
getCurrentUser() {
return this.currentUser;
}
};
File diff suppressed because it is too large Load Diff
+931
View File
@@ -0,0 +1,931 @@
/**
* EPG Guide Component
* Cable TV-style program guide grid
*/
class EpgGuide {
constructor() {
this.container = document.getElementById('epg-grid');
this.dateDisplay = document.getElementById('guide-date');
this.prevBtn = document.getElementById('guide-prev');
this.nextBtn = document.getElementById('guide-next');
this.nextBtn = document.getElementById('guide-next');
this.groupSelect = document.getElementById('epg-group-select');
this.searchInput = document.getElementById('epg-search');
this.channels = [];
this.programmes = [];
this.currentDate = new Date();
this.timeOffset = 0; // Hours offset from now
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
this.favorites = new Set(); // Set<"sourceId:channelId">
this.selectedGroup = 'Favorites'; // Default to Favorites
// Virtual scrolling properties
this.filteredChannels = [];
this.rowHeight = 60; // Height of each channel row in pixels
this.bufferRows = 5; // Extra rows to render above/below viewport
this.startTime = null;
this.endTime = null;
this.epgContainer = null;
this.epgSpacer = null;
this.scrollContainer = null;
this.visibleRows = new Map(); // Map<index, rowElement>
this._scrollHandler = null;
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
this.init();
}
/**
* Get proxied image URL to avoid mixed content errors on HTTPS
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
if (!url || url.length === 0) return '/img/placeholder.png';
// Only proxy if we're on HTTPS and the image is HTTP
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
return `/api/proxy/image?url=${encodeURIComponent(url)}`;
}
return url;
}
init() {
this.prevBtn.addEventListener('click', () => this.navigate(-2));
this.nextBtn.addEventListener('click', () => this.navigate(2));
// Group filter change
this.groupSelect?.addEventListener('change', () => {
this.selectedGroup = this.groupSelect.value;
this.render();
});
// Search input
if (this.searchInput) {
this.searchInput.addEventListener('input', this.debounce(() => {
this.render();
}, 300));
}
// Update current time indicator every minute
setInterval(() => this.updateNowIndicator(), 60000);
this.initResizer();
}
/**
* Initialize sidebar resizer
*/
initResizer() {
let isResizing = false;
let startX, startWidth;
document.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('resize-handle')) {
isResizing = true;
startX = e.clientX;
const sidebar = document.querySelector('.epg-channel-info');
startWidth = sidebar.getBoundingClientRect().width;
e.target.classList.add('active');
document.body.style.cursor = 'col-resize';
e.preventDefault(); // Prevent text selection
}
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const diff = e.clientX - startX;
const newWidth = Math.max(150, Math.min(500, startWidth + diff)); // Min 150px, Max 500px
document.documentElement.style.setProperty('--epg-sidebar-width', `${newWidth}px`);
this.updateNowIndicator(); // Update line position
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.querySelectorAll('.resize-handle').forEach(h => h.classList.remove('active'));
document.body.style.cursor = '';
}
});
}
/**
* Navigate time
*/
navigate(hours) {
this.timeOffset += hours;
this.render();
}
/**
* Start background EPG display refresh timer
* This only refreshes the UI from cached server data.
* The actual sync runs on the server independently.
*/
startBackgroundRefresh() {
// Clear any existing timer
this.stopBackgroundRefresh();
// Refresh display from cache every 5 minutes to pick up server-side sync results
const refreshIntervalMs = 5 * 60 * 1000; // 5 minutes
console.log('[EPG] Starting display refresh timer: every 5 minutes');
this._backgroundRefreshTimer = setInterval(async () => {
console.log('[EPG] Refreshing EPG display from cache');
try {
await this.fetchEpgData(false); // Fetch cached data (no force refresh)
// Update channel list program info if visible
if (window.app?.channelList) {
window.app.channelList.clearProgramInfoCache();
window.app.channelList.updateVisibleEpgInfo?.();
}
} catch (err) {
console.error('[EPG] Display refresh failed:', err);
}
}, refreshIntervalMs);
}
/**
* Stop background EPG refresh timer
*/
stopBackgroundRefresh() {
if (this._backgroundRefreshTimer) {
clearInterval(this._backgroundRefreshTimer);
this._backgroundRefreshTimer = null;
console.log('[EPG] Background refresh timer stopped');
}
}
/**
* Get last refresh time for display
*/
getLastRefreshTime() {
return this.lastRefreshTime || null;
}
/**
* Load EPG data (server-side caching)
*/
async loadEpg(forceRefresh = false) {
try {
this.container.innerHTML = '<div class="loading"></div>';
await this.fetchEpgData(forceRefresh);
this.lastRefreshTime = new Date();
this.render();
// Start background refresh timer after initial load
// This ensures EPG data stays fresh while the app is open
this.startBackgroundRefresh();
} catch (err) {
console.error('Error loading EPG:', err);
this.container.innerHTML = `
<div class="empty-state">
<p>Error loading EPG</p>
<p class="hint">${err.message}</p>
</div>
`;
}
}
/**
* Fetch EPG data from sources
*/
async fetchEpgData(forceRefresh = false) {
// Get ALL sources and filter for EPG-capable types
const allSources = await API.sources.getAll();
const sources = allSources.filter(s => (s.type === 'epg' || s.type === 'xtream') && s.enabled);
if (sources.length === 0) {
throw new Error('No EPG sources or Xtream accounts configured');
}
// Build query params for server-side caching
// Sync interval is controlled by server, we just hint at max cache age
const maxAge = 24; // hours - server controls actual refresh
const queryParams = forceRefresh ? '?refresh=1' : `?maxAge=${maxAge}`;
// Load EPG from ALL sources in parallel
const fetchPromises = sources.map(async (source) => {
try {
const response = await fetch(`/api/proxy/epg/${source.id}${queryParams}`);
if (!response.ok) throw new Error(`Status ${response.status}`);
return await response.json();
} catch (e) {
console.warn(`Failed to load EPG for source ${source.name}:`, e);
return null;
}
});
const results = await Promise.all(fetchPromises);
// Merge results
this.channels = [];
this.programmes = [];
let hasData = false;
results.forEach(data => {
if (data) {
if (data.channels && data.channels.length > 0) {
this.channels = this.channels.concat(data.channels);
}
if (data.programmes && data.programmes.length > 0) {
this.programmes = this.programmes.concat(data.programmes);
}
if (data.channels || data.programmes) {
hasData = true;
}
}
});
if (!hasData) {
throw new Error('Failed to load EPG data from any source');
}
// Build secondary indexes for faster lookup
this.channelMap = new Map();
// Index by ID
this.channels.forEach(ch => {
this.channelMap.set(ch.id, ch);
// Also index by name (normalized) for fallback matching
if (ch.name) {
this.channelMap.set(ch.name.toLowerCase(), ch);
}
});
// Load favorites
const favs = await API.favorites.getAll();
this.favorites = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
}
/**
* Get current program for a channel
* @param {string} tvgId - The EPG channel ID (tvg-id)
* @param {string} channelName - The channel name (for fallback)
* @returns {object|null} Program object with title, start, stop
*/
getCurrentProgram(tvgId, channelName) {
if (!this.programmes || this.programmes.length === 0) return null;
// Find EPG channel using fast map lookup
let epgChannel = null;
if (tvgId && this.channelMap && this.channelMap.has(tvgId)) {
epgChannel = this.channelMap.get(tvgId);
} else if (channelName && this.channelMap) {
epgChannel = this.channelMap.get(channelName.toLowerCase());
} else {
// Fallback to slow search if map fails or not built yet
epgChannel = this.channels.find(epg =>
(tvgId && epg.id === tvgId) || epg.name === channelName
);
}
if (!epgChannel) return null;
const now = new Date();
const nowTime = now.getTime();
// Filter programs for this channel
const current = this.programmes.find(p => {
if (p.channelId !== epgChannel.id) return false;
const start = new Date(p.start).getTime();
const stop = new Date(p.stop).getTime();
return nowTime >= start && nowTime < stop;
});
return current ? {
title: current.title,
start: current.start,
stop: current.stop,
description: current.description
} : null;
}
/**
* Update filtered channels based on search or group
*/
updateFilteredChannels() {
const searchTerm = this.searchInput ? this.searchInput.value.toLowerCase().trim() : '';
// SEARCH MODE: Filter all channels by name
if (searchTerm) {
this.filteredChannels = this.allMatchedChannels.filter(ch => {
const name = (ch.sourceChannel?.name || '').toLowerCase();
return name.includes(searchTerm);
});
return;
}
// GROUP MODE (Default)
if (this.selectedGroup === 'Favorites') {
this.filteredChannels = this.allMatchedChannels.filter(m =>
this.favorites.has(`${m.sourceChannel.sourceId}:${m.sourceChannel.id}`)
);
return;
}
if (!this.selectedGroup || this.selectedGroup === 'All') {
this.filteredChannels = [...this.allMatchedChannels];
} else {
this.filteredChannels = this.allMatchedChannels.filter(m =>
(m.sourceChannel.groupTitle || 'Uncategorized') === this.selectedGroup
);
}
}
/**
* Render the EPG grid
*/
render() {
// Get channel list instance
const channelList = window.app?.channelList;
if (!channelList) return;
// Get channels and filter out hidden ones (always enforce hidden in EPG)
// Note: We only check individual channel visibility, not group visibility
// A group is implicitly visible if it has any visible children
const playableChannels = (channelList.channels || []).filter(ch => {
// Use streamId (raw ID) for hidden check since that's what SourceManager stores
const rawChannelId = ch.streamId || ch.id;
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
return !isChannelHidden;
});
if (playableChannels.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>No visible channels available</p>
<p class="hint">Check your content settings or add a source</p>
</div>
`;
return;
}
// Match ALL playable channels with optional EPG data
const allChannels = playableChannels.map(sourceChannel => {
// Try to find matching EPG channel by tvgId or name
const epgChannel = this.channels.find(epg =>
epg.id === sourceChannel.tvgId || epg.name === sourceChannel.name
);
return { epgChannel, sourceChannel };
});
// Collect unique groups from ALL playable channels
const groups = [...new Set(allChannels.map(m => m.sourceChannel.groupTitle || 'Uncategorized'))].sort();
// Add Favorites at the top if there are any
const hasFavorites = this.favorites.size > 0;
// Only rebuild dropdown if groups have changed (performance optimization)
const groupsKey = groups.join('|') + (hasFavorites ? '|FAV' : '');
if (this.groupSelect && this._lastGroupsKey !== groupsKey) {
this._lastGroupsKey = groupsKey;
const currentValue = this.selectedGroup;
let optionsHtml = '';
if (hasFavorites) {
optionsHtml += `<option value="Favorites" ${currentValue === 'Favorites' ? 'selected' : ''}>Favorites</option>`;
}
optionsHtml += `<option value="" ${currentValue === '' ? 'selected' : ''}>All Groups</option>`;
optionsHtml += groups.map(g => `<option value="${g}" ${g === currentValue ? 'selected' : ''}>${g}</option>`).join('');
this.groupSelect.innerHTML = optionsHtml;
} else if (this.groupSelect) {
// Just update the selected value without rebuilding
this.groupSelect.value = this.selectedGroup;
}
// Handle case where we defaulted to Favorites but user has none
if (this.selectedGroup === 'Favorites' && !hasFavorites) {
this.selectedGroup = ''; // Fallback to 'All Groups'
if (this.groupSelect) this.groupSelect.value = '';
}
// Store all channels (matched with EPG data) for filtering
this.allMatchedChannels = allChannels;
this.updateFilteredChannels();
// Calculate time range and store for batch rendering
this.startTime = new Date();
this.startTime.setHours(this.startTime.getHours() + this.timeOffset);
this.startTime.setMinutes(0, 0, 0);
this.endTime = new Date(this.startTime);
this.endTime.setHours(this.endTime.getHours() + 24); // Show 24 hours of programming
// Update date display
this.updateDateDisplay(this.startTime);
// Generate time slots
const timeSlots = this.generateTimeSlots(this.startTime, this.endTime);
// Calculate total height for virtual scrolling
const totalHeight = this.filteredChannels.length * this.rowHeight;
// Build HTML structure - header is INSIDE scroll container for natural sync
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-scroll-container" style="overflow: auto; max-height: calc(100vh - 200px);">
<div class="epg-time-header">
<div class="epg-header-corner"></div>
<div class="epg-time-slots">
${timeSlots.map(slot => `
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
`).join('')}
</div>
</div>
<div class="epg-spacer" style="height: ${totalHeight}px; position: relative;">
<div class="epg-channel-rows" style="position: absolute; top: 0; left: 0; right: 0;"></div>
</div>
</div>
</div>
`;
// Get references for virtual scrolling
this.scrollContainer = this.container.querySelector('.epg-scroll-container');
this.epgSpacer = this.container.querySelector('.epg-spacer');
this.epgContainer = this.container.querySelector('.epg-channel-rows');
// Clear visible rows cache
this.visibleRows.clear();
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
// Remove old scroll handler if exists
if (this._scrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._scrollHandler);
}
if (this._hScrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._hScrollHandler);
}
// Get reference to time slots container for horizontal scroll sync
this.timeSlotsContainer = this.container.querySelector('.epg-time-slots');
// Set up scroll handler for virtual scrolling (debounced for performance)
this._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
// No need for horizontal scroll sync anymore - header is inside scroll container
// Initial render of visible rows
this.updateVisibleRows();
// Sync header corner width with actual sidebar width (handles CSS overrides)
this.syncHeaderCornerWidth();
// Add now indicator and set up periodic refresh
this.updateNowIndicator();
// Clear any existing interval
if (this._nowIndicatorInterval) {
clearInterval(this._nowIndicatorInterval);
}
// Update indicator every 60 seconds
this._nowIndicatorInterval = setInterval(() => this.updateNowIndicator(), 60000);
}
/**
* Sync header corner width with actual sidebar width
* This handles cases where CSS variables are overridden on mobile
*/
syncHeaderCornerWidth() {
const sidebar = this.container.querySelector('.epg-channel-info');
const headerCorner = this.container.querySelector('.epg-header-corner');
if (sidebar && headerCorner) {
const actualWidth = sidebar.offsetWidth;
headerCorner.style.width = `${actualWidth}px`;
}
}
/**
* Update visible rows based on scroll position (Virtual Scrolling)
*/
updateVisibleRows() {
if (!this.scrollContainer || !this.epgContainer) return;
const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight;
// Calculate visible range
const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.bufferRows);
const endIndex = Math.min(
this.filteredChannels.length - 1,
Math.ceil((scrollTop + viewportHeight) / this.rowHeight) + this.bufferRows
);
// Skip if nothing changed
if (startIndex === this._lastVisibleStart && endIndex === this._lastVisibleEnd) {
return;
}
this._lastVisibleStart = startIndex;
this._lastVisibleEnd = endIndex;
// Determine which rows to add and remove
const newVisibleSet = new Set();
for (let i = startIndex; i <= endIndex; i++) {
newVisibleSet.add(i);
}
// Remove rows that are no longer visible
for (const [index, row] of this.visibleRows) {
if (!newVisibleSet.has(index)) {
row.remove();
this.visibleRows.delete(index);
}
}
// Add new visible rows
for (let i = startIndex; i <= endIndex; i++) {
if (!this.visibleRows.has(i) && i < this.filteredChannels.length) {
const row = this.createChannelRow(i);
this.visibleRows.set(i, row);
this.epgContainer.appendChild(row);
}
}
}
/**
* Create a channel row element for virtual scrolling
*/
createChannelRow(index) {
const { epgChannel, sourceChannel } = this.filteredChannels[index];
const isFavorite = this.favorites.has(`${sourceChannel.sourceId}:${sourceChannel.id}`);
// Get programs if EPG data exists
let channelProgrammes = [];
if (epgChannel) {
channelProgrammes = this.programmes
.filter(p => p.channelId === epgChannel.id)
.filter(p => {
const start = new Date(p.start);
const stop = new Date(p.stop);
return start < this.endTime && stop > this.startTime;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
}
// Fallback values if EPG channel is missing
const logo = this.getProxiedImageUrl(sourceChannel.tvgLogo || (epgChannel && epgChannel.icon));
const name = sourceChannel.name || (epgChannel && epgChannel.name);
const row = document.createElement('div');
row.className = 'epg-channel-row';
row.dataset.channelId = sourceChannel.id;
row.dataset.sourceId = sourceChannel.sourceId;
row.dataset.index = index;
// Position absolutely for virtual scrolling
row.style.position = 'absolute';
row.style.top = `${index * this.rowHeight}px`;
row.style.left = '0';
row.style.right = '0';
row.style.height = `${this.rowHeight}px`;
row.innerHTML = `
<div class="epg-channel-info">
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
</button>
<img class="epg-channel-logo" src="${logo}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<span class="epg-channel-name">${name}</span>
<div class="resize-handle"></div>
</div>
<div class="epg-programs">
${this.renderProgrammes(channelProgrammes, this.startTime, this.endTime)}
</div>
`;
this.attachRowListeners(row);
return row;
}
/**
* Attach event listeners to an EPG row
*/
attachRowListeners(row) {
// Program click handlers
row.querySelectorAll('.epg-program').forEach(prog => {
prog.addEventListener('click', () => this.showProgramDetails(prog.dataset));
});
const info = row.querySelector('.epg-channel-info');
if (info) {
// Name/Logo click plays channel
info.querySelector('.epg-channel-name')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
info.querySelector('.epg-channel-logo')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
// Favorite click
const favBtn = info.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const sourceId = parseInt(row.dataset.sourceId);
const channelId = row.dataset.channelId;
await this.toggleFavorite(sourceId, channelId);
});
}
}
}
/**
* Toggle favorite
*/
async toggleFavorite(sourceId, channelId) {
const key = `${sourceId}:${channelId}`;
const wasFavorite = this.favorites.has(key);
const isNowFavorite = !wasFavorite;
// 1. Optimistic Update (EPG)
if (isNowFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
// Update DOM (All matching buttons in EPG)
const rows = this.container.querySelectorAll(`.epg-channel-row[data-channel-id="${channelId}"][data-source-id="${sourceId}"]`);
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (isNowFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// 2. Sync Channel List State (Optimistic)
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, isNowFavorite);
}
try {
// 3. API Call
if (wasFavorite) {
await API.favorites.remove(sourceId, channelId, 'channel');
} else {
await API.favorites.add(sourceId, channelId, 'channel');
}
// 4. Re-render if viewing Favorites group (so new favorites appear immediately)
if (this.selectedGroup === 'Favorites') {
this.render();
}
} catch (err) {
console.error('Error toggling favorite in EPG:', err);
// Revert EPG
if (wasFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (wasFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// Revert Channel List
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, wasFavorite);
}
}
}
/**
* Sync favorite status from external source (e.g. ChannelList) without API call
*/
syncFavorite(sourceId, channelId, isFavorite) {
// Ensure consistent string format for key
const key = `${String(sourceId)}:${String(channelId)}`;
const currentlyFav = this.favorites.has(key);
if (currentlyFav === isFavorite) return; // No change needed
// Update State
if (isFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
// Update DOM (All instances in EPG)
const rows = this.container.querySelectorAll(`.epg-channel-row[data-channel-id="${channelId}"][data-source-id="${sourceId}"]`);
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (isFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// Note: We don't call render() here - the favorites Set is updated
// and will be used when the user navigates to Guide or switches groups
}
/**
* Generate time slots
*/
generateTimeSlots(start, end) {
const slots = [];
const current = new Date(start);
while (current < end) {
slots.push(new Date(current));
current.setMinutes(current.getMinutes() + 30);
}
return slots;
}
/**
* Render programmes for a channel
*/
renderProgrammes(programmes, startTime, endTime) {
if (programmes.length === 0) {
const width = (endTime - startTime) / 60000 * this.pixelsPerMinute;
return `<div class="epg-program" style="width: ${width}px;"><span class="epg-program-title">No data</span></div>`;
}
const now = new Date();
let html = '';
let currentPos = startTime.getTime();
for (const prog of programmes) {
const progStart = Math.max(new Date(prog.start).getTime(), startTime.getTime());
const progEnd = Math.min(new Date(prog.stop).getTime(), endTime.getTime());
// Fill gap if needed
if (progStart > currentPos) {
const gapWidth = (progStart - currentPos) / 60000 * this.pixelsPerMinute;
html += `<div class="epg-program" style="width: ${gapWidth}px;"></div>`;
}
const width = (progEnd - progStart) / 60000 * this.pixelsPerMinute;
const isCurrent = new Date(prog.start) <= now && new Date(prog.stop) > now;
html += `
<div class="epg-program ${isCurrent ? 'current' : ''}"
style="width: ${width}px;"
data-title="${prog.title || ''}"
data-description="${prog.description || ''}"
data-start="${prog.start}"
data-stop="${prog.stop}">
<div class="epg-program-title">${prog.title || 'Unknown'}</div>
<div class="epg-program-time">
${new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
`;
currentPos = progEnd;
}
return html;
}
/**
* Update date display
*/
updateDateDisplay(date) {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
this.dateDisplay.textContent = 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
this.dateDisplay.textContent = 'Tomorrow';
} else {
this.dateDisplay.textContent = date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
}
/**
* Debounce utility
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
updateNowIndicator() {
const now = new Date();
// Place indicator inside the spacer so it scrolls with content
const spacer = this.container.querySelector('.epg-spacer');
if (!spacer) return;
// Remove existing indicator
const existing = spacer.querySelector('.epg-now-line');
if (existing) existing.remove();
// Calculate position relative to EPG start time
const minutesFromStart = (now - this.startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 1440) return; // Not in visible 24h range
// Get sidebar width to offset the indicator
const sidebar = this.container.querySelector('.epg-channel-info');
const sidebarWidth = sidebar ? sidebar.offsetWidth : 150;
// Position is sidebar + time offset
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
const indicator = document.createElement('div');
indicator.className = 'epg-now-line';
indicator.style.left = `${leftPos}px`;
spacer.appendChild(indicator);
}
/**
* Show program details modal
*/
showProgramDetails(data) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
title.textContent = data.title || 'Program Details';
const start = new Date(data.start);
const stop = new Date(data.stop);
body.innerHTML = `
<p><strong>Time:</strong> ${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${stop.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
<p><strong>Description:</strong></p>
<p>${data.description || 'No description available'}</p>
`;
footer.innerHTML = '<button class="btn btn-secondary" id="modal-close">Close</button>';
modal.classList.add('active');
document.getElementById('modal-close').onclick = () => modal.classList.remove('active');
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
}
/**
* Play channel from EPG
*/
async playChannel(channelName) {
// Find channel in channel list and play
if (window.app?.channelList) {
const channel = window.app.channelList.channels.find(c =>
c.name === channelName || c.tvgName === channelName
);
if (channel) {
await window.app.channelList.selectChannel({ channelId: channel.id });
// Switch to live TV page
document.querySelector('[data-page="live"]').click();
}
}
}
}
// Export
window.EpgGuide = EpgGuide;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
/**
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
*/
(function () {
function showToast(msg, type, durationMs) {
let container = document.getElementById('kiratv-desktop-toast');
if (!container) {
container = document.createElement('div');
container.id = 'kiratv-desktop-toast';
Object.assign(container.style, {
position: 'fixed',
bottom: '24px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
pointerEvents: 'none'
});
document.body.appendChild(container);
}
const toast = document.createElement('div');
Object.assign(toast.style, {
background: type === 'error' ? '#c0392b' : '#1a7f4a',
color: '#fff',
padding: '10px 20px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'sans-serif',
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
opacity: '1',
transition: 'opacity 0.4s'
});
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 450);
}, durationMs || 3500);
}
function unwrapDesktopWrapperUrl(urlLike) {
try {
const u = new URL(String(urlLike), window.location.origin);
const wrappedPath = u.pathname;
if (
wrappedPath === '/api/transcode' ||
wrappedPath === '/api/remux' ||
wrappedPath === '/api/proxy/stream'
) {
const original = u.searchParams.get('url');
if (original) return original;
}
} catch (_) {
/* */
}
return urlLike;
}
function absolutePlaybackUrl(streamUrl) {
if (!streamUrl) return '';
const raw = String(streamUrl).trim();
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
if (/^https?:\/\//i.test(s)) return s;
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
}
async function resolveNativePlaybackUrl(streamUrl) {
const abs = absolutePlaybackUrl(streamUrl);
try {
const u = new URL(abs);
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
const r = await fetch(abs, { credentials: 'include' });
if (r.ok) {
const j = await r.json();
if (j && typeof j.url === 'string' && j.url.trim()) {
return j.url.trim();
}
}
}
} catch (_) {
/* usar abs */
}
return abs;
}
function authBearerPayload() {
try {
const t = localStorage.getItem('authToken');
return t && t.length > 0 ? { authorizationBearer: t } : {};
} catch (_) {
return {};
}
}
function authBearerPayloadFor(url) {
try {
const u = new URL(url, window.location.origin);
if (u.origin !== window.location.origin) return {};
} catch (_) {
return {};
}
return authBearerPayload();
}
function elementToBounds(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.max(1, Math.round(r.width)),
height: Math.max(1, Math.round(r.height))
};
}
/**
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
*/
function getEmbedBounds() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
if (window.kiratvDesktop.embedUsesNativePanel) return null;
const watchPage = document.getElementById('page-watch');
if (watchPage && watchPage.classList.contains('active')) {
const videoEl = document.getElementById('watch-video');
if (videoEl) return elementToBounds(videoEl);
const section = document.querySelector('.watch-video-section');
if (section) return elementToBounds(section);
}
const livePage = document.getElementById('page-live');
if (livePage && livePage.classList.contains('active')) {
const ps = document.querySelector('.player-section');
if (ps) return elementToBounds(ps);
}
const ps = document.querySelector('.player-section');
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
const wv = document.getElementById('watch-video');
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
return null;
}
function embedUsesNativePanel() {
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
}
function setEmbeddedUiClass(active) {
const native = embedUsesNativePanel();
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
}
/**
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
*/
function shellChromeModeFromHash() {
const raw = location.hash || '#home';
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
if (h === 'live') return 'liveSplit';
if (h === 'watch') return 'vodSplit';
return 'fullWeb';
}
function syncShellChrome() {
const d = window.kiratvDesktop;
if (typeof d?.setShellChromeMode !== 'function') return;
const mode = shellChromeModeFromHash();
d.setShellChromeMode({ mode }).catch(() => {});
if (embedUsesNativePanel() && mode === 'fullWeb') {
d.stopExternalMpv?.().catch(() => {});
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
}
}
function initEmbedBoundsSync() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
if (embedUsesNativePanel()) return;
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
let t = null;
function flushBounds() {
clearTimeout(t);
t = setTimeout(() => {
const b = getEmbedBounds();
if (!b) return;
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
}, 72);
}
window.addEventListener('resize', flushBounds);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(flushBounds);
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
try {
ro.observe(el);
} catch (_) {
/* */
}
});
}
window.addEventListener('hashchange', flushBounds);
flushBounds();
}
function useEmbeddedPlayback() {
const d = window.kiratvDesktop;
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
}
async function playInMpv(absUrl, extraArgs) {
const d = window.kiratvDesktop;
if (!d?.playExternalMpv) {
showToast('Error: sin puente escritorio', 'error');
return { ok: false, error: 'Sin puente escritorio' };
}
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
if (useEmbeddedPlayback()) {
const native = embedUsesNativePanel();
let bounds = null;
if (!native) {
bounds = getEmbedBounds();
if (!bounds || bounds.width < 2 || bounds.height < 2) {
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
return { ok: false, error: 'bounds vídeo vacíos' };
}
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
try {
await d.setVideoHostBounds(bounds);
} catch (_) {
/* */
}
} else {
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
}
setEmbeddedUiClass(true);
const payload = {
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
};
if (!native && bounds) payload.bounds = bounds;
const result = await d.playEmbeddedMpv(payload);
if (!result?.ok) {
setEmbeddedUiClass(false);
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast('⚠ ' + msg, 'error', 6000);
}
return result;
}
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
const result = await d.playExternalMpv({
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
});
if (!result?.ok) {
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast(
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
'error',
6000
);
}
return result;
}
function whenReady(cb) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb, { once: true });
} else {
cb();
}
}
whenReady(() => {
if (window.kiratvDesktop?.isDesktopApp) {
document.body.classList.add('kiratv-desktop-app');
}
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
initEmbedBoundsSync();
}
window.addEventListener('hashchange', syncShellChrome);
setTimeout(syncShellChrome, 0);
});
window.kiratvDesktopClearEmbeddedChrome = function () {
setEmbeddedUiClass(false);
};
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
return playInMpv(absoluteUrl);
};
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
return playInMpv(absoluteUrl, extraArgs);
};
})();
+32
View File
@@ -0,0 +1,32 @@
/**
* Application Icons (SVG)
* Replaces emojis for consistent cross-platform appearance
*/
const Icons = {
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>`, // TV with Play
live: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>`, // TV with Play (Same as Logo)
guide: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>`, // List
movies: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z"/></svg>`, // Film
series: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>`, // TV Generic
settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`,
favorite: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
favoriteOutline: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"/></svg>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M8 5v14l11-7z"/></svg>`,
hide: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>`,
save: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>`,
star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon text-warning"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>`,
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>`,
expandAll: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></svg>`, // Unfold More
collapseAll: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M7.41 18.59L8.83 20 12 16.83 15.17 20l1.41-1.41L12 14l-4.59 4.59zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10l4.59-4.59z"/></svg>`, // Unfold Less
refresh: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
circle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/></svg>`,
};
window.Icons = Icons;
+14
View File
@@ -0,0 +1,14 @@
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await API.request('POST', '/auth/login', { username, password });
localStorage.setItem('sessionToken', response.token);
window.location.href = '/';
} catch (err) {
document.getElementById('login-error').textContent = 'Login failed: ' + err.message;
}
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Guide Page Controller
*/
class GuidePage {
constructor(app) {
this.app = app;
}
async init() {
// EPG guide will lazy load when shown
}
async show() {
// Ensure channel data is loaded before rendering EPG
// This fixes a race condition where navigating directly to the Guide page
// before visiting Live TV would result in an empty EPG.
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Only load EPG data if not already loaded
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg();
} else {
// Just re-render with existing data (updates time position)
this.app.epgGuide.render();
}
}
hide() {
// Page is hidden
}
}
window.GuidePage = GuidePage;
+512
View File
@@ -0,0 +1,512 @@
/**
* Home Dashboard Page
* Features "Continue Watching" and "Recently Added" content
*/
class HomePage {
constructor(app) {
this.app = app;
this.container = null; // Will be set in renderLayout
this.isLoading = false;
}
async init() {
// Initialization if needed
}
async show() {
this.renderLayout();
await this.loadDashboardData();
}
hide() {
// Cleanup if needed
if (this.container) {
this.container.innerHTML = '';
}
}
renderLayout() {
const pageHome = document.getElementById('page-home');
if (!pageHome) return;
pageHome.innerHTML = `
<div class="dashboard-content" id="home-content">
<section class="dashboard-section" id="favorite-channels-section">
<div class="section-header">
<h2>Favorite Channels</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll channel-tiles" id="favorite-channels-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading favorites...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section" id="continue-watching-section">
<div class="section-header">
<h2>Continue Watching</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="continue-watching-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading history...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Movies</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-movies-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Series</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-series-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
</div>
`;
this.container = document.getElementById('home-content');
// Attach scroll arrow handlers
this.initScrollArrows();
}
initScrollArrows() {
this.container.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
const scrollContainer = wrapper.querySelector('.horizontal-scroll');
const leftBtn = wrapper.querySelector('.scroll-left');
const rightBtn = wrapper.querySelector('.scroll-right');
if (!scrollContainer || !leftBtn || !rightBtn) return;
const scrollAmount = 300; // pixels to scroll per click
leftBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
rightBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Update arrow visibility based on scroll position
const updateArrows = () => {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
leftBtn.classList.toggle('hidden', scrollLeft <= 0);
rightBtn.classList.toggle('hidden', scrollLeft + clientWidth >= scrollWidth - 5);
};
// Store reference for later updates
wrapper._updateArrows = updateArrows;
scrollContainer.addEventListener('scroll', updateArrows);
// Initial check after content loads
setTimeout(updateArrows, 100);
});
}
/**
* Re-check scroll arrow visibility for all sections
* Call this after dynamically loading content
*/
updateScrollArrows() {
this.container?.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
if (wrapper._updateArrows) {
wrapper._updateArrows();
}
});
}
async loadDashboardData() {
if (this.isLoading) return;
this.isLoading = true;
try {
// 0. Load Favorite Channels (first section)
await this.renderFavoriteChannels();
// 1. Load Watch History
const history = await window.API.request('GET', '/history?limit=12');
if (history && Array.isArray(history)) {
this.renderHistory(history);
}
// 2. Load Recent Items
this.renderRecentMovies();
this.renderRecentSeries();
} catch (err) {
console.error('[Dashboard] Error loading data:', err);
} finally {
this.isLoading = false;
}
}
async renderFavoriteChannels() {
const list = document.getElementById('favorite-channels-list');
const section = document.getElementById('favorite-channels-section');
if (!list || !section) return;
try {
// Fetch favorite channels for current user
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Ensure channel list is loaded to resolve channel details
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Match favorites to channel data
const channels = [];
for (const fav of favorites) {
// Find channel in loaded channel list
const channel = channelList.channels.find(ch =>
String(ch.sourceId) === String(fav.source_id) &&
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
);
if (channel) {
channels.push({ ...channel, favoriteId: fav.id });
}
}
if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Render channel tiles
list.innerHTML = channels.map(ch => this.createChannelTile(ch)).join('');
// Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId;
const sourceId = tile.dataset.sourceId;
this.playChannel(channelId, sourceId);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading favorite channels:', err);
list.innerHTML = '<div class="empty-state hint">Error loading favorites</div>';
}
}
createChannelTile(channel) {
const logo = channel.tvgLogo || '/img/placeholder.png';
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown';
return `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
`;
}
playChannel(channelId, sourceId) {
// Navigate to Live TV and select the channel
this.app.navigateTo('live');
// Small delay to ensure page is ready
setTimeout(() => {
const channelList = this.app.channelList;
if (channelList) {
// Find and select the channel
const channel = channelList.channels.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
);
if (channel) {
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
}
renderHistory(items) {
const list = document.getElementById('continue-watching-list');
const section = document.getElementById('continue-watching-section');
if (!list || !section) return;
if (items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
list.innerHTML = items.map(item => this.createCard(item)).join('');
// Attach click listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = items.find(i => i.item_id === id);
if (item) {
const type = item.item_type || item.type;
// IF it's a series, checking details is better than blind resume
// BUT for "Continue Watching", we ideally want to resume
// Prioritize playing directly for resume tiles
this.playItem(item, true); // true for resume
}
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
}
navigateToSeries(item) {
if (!this.app.pages.series) return;
// Prepare the series object as expected by SeriesPage.showSeriesDetails
const series = {
series_id: item.item_id,
sourceId: item.source_id,
name: item.name || (item.data ? item.data.title : 'Series'),
cover: item.stream_icon || (item.data ? item.data.poster : null),
plot: item.data ? item.data.description : '',
year: item.data ? item.data.year : ''
};
// Switch page
this.app.navigateTo('series');
// Show details (delay slightly to ensure page is visible)
setTimeout(() => {
this.app.pages.series.showSeriesDetails(series);
}, 100);
}
async renderRecentMovies() {
const list = document.getElementById('recent-movies-list');
if (!list) return;
try {
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
return;
}
list.innerHTML = movies.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = movies.find(m => m.item_id === id);
if (item) this.playItem(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent movies:', err);
}
}
async renderRecentSeries() {
const list = document.getElementById('recent-series-list');
if (!list) return;
try {
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
if (!series || series.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
return;
}
list.innerHTML = series.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = series.find(s => s.item_id === id);
if (item) this.navigateToSeries(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent series:', err);
}
}
createCard(item) {
const { data, progress, duration, item_id } = item;
const type = item.item_type || item.type;
const percent = Math.min(100, Math.round((progress / duration) * 100));
// Proxy the poster if it's an external URL
const poster = data.poster || '/img/poster-placeholder.jpg';
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${percent}%"></div>
</div>
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || data.title}">${item.name || data.title || 'Unknown Title'}</div>
<div class="card-subtitle">${data.subtitle || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
createRecentCard(item) {
const { data, item_id } = item;
const type = item.type || item.item_type;
const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg';
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || (data && data.title)}">${item.name || (data && data.title) || 'Unknown Title'}</div>
<div class="card-subtitle">${(data && data.subtitle) || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
async playItem(item, isResume = false) {
if (!this.app.pages.watch) return;
try {
const type = item.item_type || item.type;
const streamType = type === 'movie' ? 'movie' : 'series';
const sourceId = item.source_id || (item.data && item.data.sourceId);
const streamId = item.item_id;
const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4';
const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
if (result && result.url) {
const content = {
id: item.item_id,
type: type,
title: item.name || item.data.title,
subtitle: item.data.subtitle || (type === 'movie' ? 'Movie' : 'Series'),
poster: item.stream_icon || item.data.poster,
sourceId: sourceId,
resumeTime: isResume ? item.progress : 0,
containerExtension: container
};
// For episodes, try to restore series data for next episode functionality
if (type === 'episode' && item.data) {
content.seriesId = item.data.seriesId || null;
content.currentSeason = item.data.currentSeason || null;
content.currentEpisode = item.data.currentEpisode || null;
// Fetch seriesInfo if we have a seriesId
if (content.seriesId && sourceId) {
try {
const seriesInfo = await window.API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`);
if (seriesInfo) {
content.seriesInfo = seriesInfo;
}
} catch (e) {
console.warn('[Dashboard] Could not fetch seriesInfo for next episode:', e);
}
}
}
// Switch to watch page
this.app.navigateTo('watch');
this.app.pages.watch.play(content, result.url);
}
} catch (err) {
console.error('[Dashboard] Playback failed:', err);
}
}
}
window.HomePage = HomePage;
+109
View File
@@ -0,0 +1,109 @@
/**
* Home Page Controller
*/
class LivePage {
constructor(app) {
this.app = app;
this.handleKeydown = this.handleKeydown.bind(this);
}
async init() {
// Load sources and channels on initial page load
await this.app.channelList.loadSources();
await this.app.channelList.loadChannels();
// Silently fetch EPG data for sidebar info
try {
await this.app.epgGuide.fetchEpgData();
// Clear cache so we don't get stale "null" results from initial render
this.app.channelList.clearProgramInfoCache();
// Update program info in existing DOM elements without re-rendering
this.updateProgramInfo();
} catch (err) {
console.warn('Background EPG fetch failed:', err);
}
}
/**
* Update "Now Playing" info in existing channel elements without blocking UI
*/
updateProgramInfo() {
const channelItems = Array.from(document.querySelectorAll('.channel-item'));
if (channelItems.length === 0) return;
// Build a map for O(1) channel lookups
const channelMap = new Map();
this.app.channelList.channels.forEach(c => channelMap.set(c.id, c));
// Process in small batches to avoid blocking UI
const BATCH_SIZE = 50;
let index = 0;
const processBatch = () => {
const end = Math.min(index + BATCH_SIZE, channelItems.length);
for (let i = index; i < end; i++) {
const item = channelItems[i];
const channelId = item.dataset.channelId;
const channel = channelMap.get(channelId);
if (channel) {
const programDiv = item.querySelector('.channel-program');
if (programDiv) {
const programTitle = this.app.channelList.getProgramInfo(channel);
programDiv.textContent = programTitle || '';
}
}
}
index = end;
if (index < channelItems.length) {
// Yield to browser before next batch
requestAnimationFrame(processBatch);
}
};
// Start processing
requestAnimationFrame(processBatch);
}
handleKeydown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.key) {
case 'ArrowUp':
// Check if player handles arrows for volume
if (this.app.player && !this.app.player.settings.arrowKeysChangeChannel) return;
e.preventDefault();
this.app.channelList.selectPrevChannel();
break;
case 'ArrowDown':
// Check if player handles arrows for volume
if (this.app.player && !this.app.player.settings.arrowKeysChangeChannel) return;
e.preventDefault();
this.app.channelList.selectNextChannel();
break;
}
}
async show() {
document.addEventListener('keydown', this.handleKeydown);
// Only reload if channels aren't already loaded
if (this.app.channelList.channels.length === 0) {
await this.app.channelList.loadSources();
await this.app.channelList.loadChannels();
}
}
hide() {
document.removeEventListener('keydown', this.handleKeydown);
}
}
window.LivePage = LivePage;
+402
View File
@@ -0,0 +1,402 @@
/**
* Movies Page Controller
* Handles VOD movie browsing and playback
*/
class MoviesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('movies-grid');
this.sourceSelect = document.getElementById('movies-source-select');
this.categorySelect = document.getElementById('movies-category-select');
this.searchInput = document.getElementById('movies-search');
this.movies = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredMovies = [];
this.isLoading = false;
this.observer = null;
this.favoriteIds = new Set(); // Track favorite movie IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadMovies();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadMovies();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('movies-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load movies if empty
if (this.movies.length === 0) {
await this.loadCategories();
await this.loadMovies();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'movie');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set(); // Track hidden categories
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'vod_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.vodCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadMovies() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.movies = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue; // Skip this source if category is from different source
}
}
const movies = await API.proxy.xtream.vodStreams(source.id, catId);
console.log(`[Movies] Source ${source.id}, Category ${catId || 'ALL'}: Got ${movies?.length || 0} movies`);
if (movies && Array.isArray(movies)) {
movies.forEach(m => {
// Skip movies from hidden categories
if (this.hiddenCategoryIds && this.hiddenCategoryIds.has(`${source.id}:${m.category_id}`)) {
return;
}
this.movies.push({
...m,
sourceId: source.id,
id: `${source.id}:${m.stream_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load movies from source ${source.id}:`, err.message);
}
}
console.log(`[Movies] Total loaded: ${this.movies.length} movies`);
this.filterAndRender();
} catch (err) {
console.error('Error loading movies:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading movies</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredMovies = this.movies.filter(m => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${m.sourceId}:${m.stream_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !m.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Movies] Displaying ${this.filteredMovies.length} of ${this.movies.length} movies`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredMovies.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado películas</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'movies-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches (more to fill viewport)
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredMovies.slice(start, end);
console.log(`[Movies] Rendering batch ${this.currentBatch}: ${batch.length} cards (${start}-${end})`);
if (batch.length === 0) {
const loader = this.container.querySelector('.movies-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId;
const rawPoster = movie.stream_icon || movie.cover || '';
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
const isFav = this.favoriteIds.has(`${movie.sourceId}:${movie.stream_id}`);
card.innerHTML = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="movie-info">
<div class="movie-title">${movie.name}</div>
<div class="movie-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
// Card click plays movie, but not if clicking favorite button
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(movie, btn);
e.stopPropagation();
} else {
this.playMovie(movie);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.movies-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredMovies.length && loader) {
loader.style.display = 'none';
}
}
async playMovie(movie) {
try {
// Get stream URL for movie using the actual container extension from API
// Xtream API returns container_extension (e.g., 'mp4', 'mkv', 'avi')
const container = movie.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
this.app.pages.watch.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
sourceId: movie.sourceId,
categoryId: movie.category_id,
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing movie:', err);
}
}
async toggleFavorite(movie, btn) {
const favKey = `${movie.sourceId}:${movie.stream_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(movie.sourceId, movie.stream_id, 'movie');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.MoviesPage = MoviesPage;
+504
View File
@@ -0,0 +1,504 @@
/**
* Series Page Controller
* Handles TV series browsing and playback
*/
class SeriesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('series-grid');
this.sourceSelect = document.getElementById('series-source-select');
this.categorySelect = document.getElementById('series-category-select');
this.searchInput = document.getElementById('series-search');
this.detailsPanel = document.getElementById('series-details');
this.seasonsContainer = document.getElementById('series-seasons');
this.seriesList = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredSeries = [];
this.isLoading = false;
this.observer = null;
this.hiddenCategoryIds = new Set();
this.currentSeries = null;
this.favoriteIds = new Set(); // Track favorite series IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadSeries();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadSeries();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Back button
document.querySelector('.series-back-btn')?.addEventListener('click', () => {
this.hideDetails();
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('series-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Hide details panel when showing page
this.hideDetails();
// Load sources if not loaded
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load series if empty
if (this.seriesList.length === 0) {
await this.loadCategories();
await this.loadSeries();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'series');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set();
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'series_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.seriesCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load series categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadSeries() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.seriesList = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue;
}
}
const series = await API.proxy.xtream.series(source.id, catId);
console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`);
if (series && Array.isArray(series)) {
series.forEach(s => {
// Skip series from hidden categories
if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) {
return;
}
this.seriesList.push({
...s,
sourceId: source.id,
id: `${source.id}:${s.series_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load series from source ${source.id}:`, err.message);
}
}
console.log(`[Series] Total loaded: ${this.seriesList.length} series`);
this.filterAndRender();
} catch (err) {
console.error('Error loading series:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading series</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredSeries = this.seriesList.filter(s => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${s.sourceId}:${s.series_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredSeries.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado series</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'series-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredSeries.slice(start, end);
if (batch.length === 0) {
const loader = this.container.querySelector('.series-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(series => {
const card = document.createElement('div');
card.className = 'series-card';
card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId;
const rawPoster = series.cover || '';
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`);
card.innerHTML = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="series-card-info">
<div class="series-title">${series.name}</div>
<div class="series-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(series, btn);
e.stopPropagation();
} else {
this.showSeriesDetails(series);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.series-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredSeries.length && loader) {
loader.style.display = 'none';
}
}
async showSeriesDetails(series) {
this.currentSeries = series;
// Show details panel
this.container.classList.add('hidden');
this.detailsPanel.classList.remove('hidden');
// Set header info
const rawCover = series.cover || '';
document.getElementById('series-poster').src = rawCover.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawCover)}` : (rawCover || '/img/placeholder.png');
document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || '';
// Show loading
this.seasonsContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
// Fetch series info (seasons/episodes)
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
if (!info || !info.episodes) {
this.seasonsContainer.innerHTML = '<p class="hint">No hay episodios</p>';
return;
}
// Store series info for WatchPage
this.currentSeriesInfo = info;
// Render seasons and episodes
let html = '';
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
seasons.forEach(seasonNum => {
const episodes = info.episodes[seasonNum];
html += `
<div class="season-group">
<div class="season-header">
<span class="season-expander">${Icons.chevronDown}</span>
<span class="season-name">Temporada ${seasonNum} (${episodes.length} episodios)</span>
</div>
<div class="episode-list">
${episodes.map(ep => `
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
<span class="episode-number">E${ep.episode_num}</span>
<span class="episode-title">${ep.title || `Episodio ${ep.episode_num}`}</span>
<span class="episode-duration">${ep.duration || ''}</span>
</div>
`).join('')}
</div>
</div>`;
});
this.seasonsContainer.innerHTML = html;
// Add click handlers
this.seasonsContainer.querySelectorAll('.season-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.season-group').classList.toggle('collapsed');
});
});
this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => {
ep.addEventListener('click', () => this.playEpisode(ep));
});
} catch (err) {
console.error('Error loading series info:', err);
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error al cargar episodios</p>';
}
}
hideDetails() {
this.detailsPanel.classList.add('hidden');
this.container.classList.remove('hidden');
this.currentSeries = null;
}
async playEpisode(episodeEl) {
const episodeId = episodeEl.dataset.episodeId;
const sourceId = parseInt(episodeEl.dataset.sourceId);
const container = episodeEl.dataset.container || 'mp4';
// Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Temporada (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episodio ${episodeNum}`;
this.app.pages.watch.play({
type: 'series',
id: episodeId,
title: this.currentSeries?.name || 'Series',
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.currentSeries?.cover,
description: this.currentSeries?.plot || '',
year: this.currentSeries?.year,
rating: this.currentSeries?.rating,
sourceId: sourceId,
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum,
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing episode:', err);
}
}
async toggleFavorite(series, btn) {
const favKey = `${series.sourceId}:${series.series_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(series.sourceId, series.series_id, 'series');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(series.sourceId, series.series_id, 'series');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.SeriesPage = SeriesPage;
+653
View File
@@ -0,0 +1,653 @@
/**
* Settings Page Controller
*/
class SettingsPage {
constructor(app) {
this.app = app;
this.tabs = document.querySelectorAll('.tabs .tab');
this.tabContents = document.querySelectorAll('.tab-content');
this.init();
}
init() {
// Tab switching
this.tabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Player settings
this.initPlayerSettings();
// Transcoding settings
this.initTranscodingSettings();
// User management (admin only)
this.initUserManagement();
}
initPlayerSettings() {
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
// Load current settings
if (this.app.player?.settings) {
arrowKeysToggle.checked = this.app.player.settings.arrowKeysChangeChannel;
overlayDurationInput.value = this.app.player.settings.overlayDuration;
defaultVolumeSlider.value = this.app.player.settings.defaultVolume;
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
}
// Arrow keys toggle
arrowKeysToggle.addEventListener('change', () => {
this.app.player.settings.arrowKeysChangeChannel = arrowKeysToggle.checked;
this.app.player.saveSettings();
});
// Overlay duration
overlayDurationInput.addEventListener('change', () => {
this.app.player.settings.overlayDuration = parseInt(overlayDurationInput.value) || 5;
this.app.player.saveSettings();
});
// Default volume slider
defaultVolumeSlider.addEventListener('input', () => {
const value = defaultVolumeSlider.value;
volumeValueDisplay.textContent = value + '%';
this.app.player.settings.defaultVolume = parseInt(value);
this.app.player.saveSettings();
});
// Remember volume toggle
rememberVolumeToggle.addEventListener('change', () => {
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
this.app.player.saveSettings();
});
// Auto-play next episode toggle
autoPlayNextToggle.addEventListener('change', () => {
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
this.app.player.saveSettings();
});
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) {
// Load saved value from player settings
epgRefreshSelect.value = this.app.player.settings.epgRefreshInterval || '24';
// Save on change - server will restart its sync timer via PUT /api/settings
epgRefreshSelect.addEventListener('change', () => {
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
this.app.player.saveSettings();
});
}
// Update last refreshed display
this.updateEpgLastRefreshed();
}
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
const maxResolutionSelect = document.getElementById('setting-max-resolution');
const qualitySelect = document.getElementById('setting-quality');
// Stream processing (use -tc suffix IDs from Transcoding tab)
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode-tc');
const forceTranscodeToggle = document.getElementById('setting-force-transcode-tc');
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
// User-Agent (Transcoding tab versions)
const userAgentSelect = document.getElementById('setting-user-agent-tc');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom-tc');
const customUaContainer = document.getElementById('custom-user-agent-container-tc');
// Fetch settings directly from API to avoid race condition with VideoPlayer
let s;
try {
s = await API.settings.get();
} catch (err) {
console.warn('[Settings] Failed to load settings from API, using player defaults:', err);
s = this.app.player?.settings || {};
}
if (hwEncoderSelect) hwEncoderSelect.value = s.hwEncoder || 'auto';
if (maxResolutionSelect) maxResolutionSelect.value = s.maxResolution || '1080p';
if (qualitySelect) qualitySelect.value = s.quality || 'medium';
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy === true;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode !== false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
// Event listeners for encoder settings
hwEncoderSelect?.addEventListener('change', () => {
this.app.player.settings.hwEncoder = hwEncoderSelect.value;
this.app.player.saveSettings();
});
maxResolutionSelect?.addEventListener('change', () => {
this.app.player.settings.maxResolution = maxResolutionSelect.value;
this.app.player.saveSettings();
});
qualitySelect?.addEventListener('change', () => {
this.app.player.settings.quality = qualitySelect.value;
this.app.player.saveSettings();
});
// Audio Mix Preset
const audioMixSelect = document.getElementById('setting-audio-mix');
if (audioMixSelect) {
audioMixSelect.value = s.audioMixPreset || 'auto';
audioMixSelect.addEventListener('change', () => {
this.app.player.settings.audioMixPreset = audioMixSelect.value;
this.app.player.saveSettings();
});
}
// Upscaling Settings
const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled');
const upscaleMethodSelect = document.getElementById('setting-upscale-method');
const upscaleTargetSelect = document.getElementById('setting-upscale-target');
const upscaleMethodContainer = document.getElementById('upscale-method-container');
const upscaleTargetContainer = document.getElementById('upscale-target-container');
// Helper to toggle upscale options visibility
const toggleUpscaleOptions = (enabled) => {
if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none';
if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none';
};
// Load upscaling settings
if (upscaleEnabledToggle) {
upscaleEnabledToggle.checked = s.upscaleEnabled || false;
toggleUpscaleOptions(upscaleEnabledToggle.checked);
}
if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware';
if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p';
// Upscaling event handlers
upscaleEnabledToggle?.addEventListener('change', () => {
this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked;
this.app.player.saveSettings();
toggleUpscaleOptions(upscaleEnabledToggle.checked);
});
upscaleMethodSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleMethod = upscaleMethodSelect.value;
this.app.player.saveSettings();
});
upscaleTargetSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleTarget = upscaleTargetSelect.value;
this.app.player.saveSettings();
});
// Stream processing toggles
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
this.app.player.saveSettings();
});
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceVideoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
});
streamFormatSelect?.addEventListener('change', () => {
this.app.player.settings.streamFormat = streamFormatSelect.value;
this.app.player.saveSettings();
});
// User-Agent handlers
const toggleCustomInput = () => {
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
};
userAgentSelect?.addEventListener('change', () => {
this.app.player.settings.userAgentPreset = userAgentSelect.value;
this.app.player.saveSettings();
toggleCustomInput();
});
userAgentCustomInput?.addEventListener('change', () => {
this.app.player.settings.userAgentCustom = userAgentCustomInput.value;
this.app.player.saveSettings();
});
}
/**
* Load and display hardware info in Transcoding tab
*/
async loadHardwareInfo() {
const container = document.getElementById('hw-info-container');
if (!container) return;
try {
const response = await fetch('/api/settings/hw-info');
if (!response.ok) throw new Error('Failed to fetch hardware info');
const hwInfo = await response.json();
const detected = [];
// Only show detected hardware
if (hwInfo.nvidia?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ NVIDIA</span>
<span class="hw-name">${hwInfo.nvidia.name}</span>
</div>`);
}
if (hwInfo.amf?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ AMD</span>
<span class="hw-name">${hwInfo.amf.name || 'Available'}</span>
</div>`);
}
if (hwInfo.qsv?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ Intel QSV</span>
<span class="hw-name">Available</span>
</div>`);
}
if (hwInfo.vaapi?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ VAAPI</span>
<span class="hw-name">${hwInfo.vaapi.device || 'Available'}</span>
</div>`);
}
let html;
if (detected.length > 0) {
html = `<div class="hw-info-grid">${detected.join('')}</div>`;
html += `<p class="hint" style="margin-top: var(--space-sm);">Recommended encoder: <strong>${hwInfo.recommended}</strong></p>`;
} else {
html = `<p class="hint">No GPU acceleration detected. Using software encoding.</p>`;
}
container.innerHTML = html;
} catch (err) {
console.error('Error loading hardware info:', err);
container.innerHTML = '<p class="hint error">Failed to load hardware info</p>';
}
}
initUserManagement() {
// User tab visibility is handled in show() method
// when currentUser is available
// Handle add user form
const addUserForm = document.getElementById('add-user-form');
if (addUserForm) {
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
try {
await API.users.create({ username, password, role });
alert('Usuario creado correctamente.');
addUserForm.reset();
this.loadUsers();
} catch (err) {
alert('Error al crear usuario: ' + err.message);
}
});
}
}
async loadUsers() {
const userList = document.getElementById('user-list');
if (!userList) return;
try {
const users = await API.users.getAll();
// Store users in memory for easy access during edit
this.users = users;
if (users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="hint">No users found</td></tr>';
return;
}
userList.innerHTML = users.map(user => {
const isSSO = !!user.oidcId;
const typeBadge = isSSO
? '<span class="user-badge user-badge-sso">SSO</span>'
: '<span class="user-badge user-badge-local">Local</span>';
const roleBadge = user.role === 'admin'
? '<span class="user-badge user-badge-admin">Admin</span>'
: '<span class="user-badge user-badge-viewer">Viewer</span>';
return `
<tr>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<strong>${user.username}</strong>
${typeBadge}
</div>
</td>
<td>${user.email || '<span class="hint">-</span>'}</td>
<td>${roleBadge}</td>
<td>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.openEditUserModal(${user.id})">Edit</button>
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
</td>
</tr>
`}).join('');
} catch (err) {
console.error('Error loading users:', err);
userList.innerHTML = '<tr><td colspan="5" class="hint">Error loading users</td></tr>';
}
}
openEditUserModal(userId) {
console.log('openEditUserModal called with ID:', userId, 'Type:', typeof userId);
console.log('Current users list:', this.users);
const user = this.users.find(u => u.id === userId);
if (!user) {
console.error('User not found in this.users cache!');
console.log('Available IDs:', this.users.map(u => u.id));
return;
}
console.log('User found:', user);
const modal = document.getElementById('edit-user-modal');
console.log('Modal element:', modal);
if (!modal) {
console.error('CRITICAL: Modal element #edit-user-modal not found in DOM!');
alert('Error: Modal not found. Please refresh the page.');
return;
}
const isSSO = !!user.oidcId;
console.log('Is SSO user:', isSSO);
// Populate form with null checks
try {
const editId = document.getElementById('edit-user-id');
const editUsername = document.getElementById('edit-username');
const editEmail = document.getElementById('edit-email');
const editRole = document.getElementById('edit-role');
const editPassword = document.getElementById('edit-password');
console.log('Form elements found:', { editId, editUsername, editEmail, editRole, editPassword });
if (editId) editId.value = user.id;
if (editUsername) editUsername.value = user.username;
if (editEmail) editEmail.value = user.email || '';
if (editRole) editRole.value = user.role;
if (editPassword) editPassword.value = '';
// Handle SSO specific UI
const passwordHint = document.getElementById('edit-password-hint');
const oidcGroup = document.getElementById('oidc-info-group');
const oidcIdDisplay = document.getElementById('edit-oidc-id');
if (isSSO) {
if (editPassword) {
editPassword.disabled = true;
editPassword.placeholder = "Managed by SSO Provider";
}
if (passwordHint) passwordHint.textContent = "Password cannot be changed for SSO users.";
if (oidcGroup) oidcGroup.classList.remove('hidden');
if (oidcIdDisplay) oidcIdDisplay.textContent = user.oidcId;
} else {
if (editPassword) {
editPassword.disabled = false;
editPassword.placeholder = "Leave blank to keep current";
}
if (passwordHint) passwordHint.textContent = "Optional. Leave blank to keep unchanged.";
if (oidcGroup) oidcGroup.classList.add('hidden');
}
// Show modal
console.log('Adding active class to modal...');
modal.classList.add('active');
console.log('Modal classes after add:', modal.classList.toString());
// Setup Close/Cancel handlers (once)
this.setupModalHandlers(modal);
console.log('Modal should now be visible!');
} catch (err) {
console.error('Error populating modal:', err);
alert('Error al abrir edición: ' + err.message);
}
}
setupModalHandlers(modal) {
if (this.modalHandlersSetup) return;
const closeBtn = document.getElementById('edit-user-close');
const cancelBtn = document.getElementById('edit-user-cancel');
const saveBtn = document.getElementById('edit-user-save');
const closeModal = () => modal.classList.remove('active');
closeBtn.onclick = closeModal;
cancelBtn.onclick = closeModal;
// Click outside to close
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
// Save Handler
saveBtn.onclick = async () => {
const userId = document.getElementById('edit-user-id').value;
const updates = {
username: document.getElementById('edit-username').value,
role: document.getElementById('edit-role').value
};
const newPassword = document.getElementById('edit-password').value;
if (newPassword && !document.getElementById('edit-password').disabled) {
updates.password = newPassword;
}
try {
await API.users.update(userId, updates);
// alert('User updated successfully!'); // Optional: Replace with toast?
closeModal();
this.loadUsers();
} catch (err) {
alert('Error al actualizar usuario: ' + err.message);
}
};
this.modalHandlersSetup = true;
}
async deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
await API.users.delete(userId);
this.loadUsers();
} catch (err) {
alert('Error al eliminar usuario: ' + err.message);
}
}
switchTab(tabName) {
this.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
this.tabContents.forEach(c => c.classList.toggle('active', c.id === `tab-${tabName}`));
// Load content browser when switching to that tab
if (tabName === 'content') {
this.app.sourceManager.loadContentSources();
}
// Refrescar listas al volver a Fuentes (evita lista vacía tras crear/asignar)
if (tabName === 'sources') {
this.app.sourceManager.loadSources();
}
// Load users when switching to users tab
if (tabName === 'users') {
this.loadUsers();
}
// Load hardware info when switching to transcode tab
if (tabName === 'transcode') {
this.loadHardwareInfo();
}
}
async show() {
// Show users tab for admin
if (this.app.currentUser && this.app.currentUser.role === 'admin') {
const usersTab = document.getElementById('users-tab');
if (usersTab) {
usersTab.style.display = 'block';
}
}
// Load sources when page is shown
await this.app.sourceManager.loadSources();
// Refresh ALL player settings from server
if (this.app.player?.settings) {
const s = this.app.player.settings;
// Player settings
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
const streamFormatSelect = document.getElementById('setting-stream-format');
if (arrowKeysToggle) arrowKeysToggle.checked = s.arrowKeysChangeChannel;
if (overlayDurationInput) overlayDurationInput.value = s.overlayDuration;
if (defaultVolumeSlider) defaultVolumeSlider.value = s.defaultVolume;
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom');
const customUaContainer = document.getElementById('custom-user-agent-container');
if (userAgentSelect) {
userAgentSelect.value = s.userAgentPreset || 'chrome';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect.value === 'custom' ? 'flex' : 'none';
}
}
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
}
// Update EPG last refreshed display
this.updateEpgLastRefreshed();
}
/**
* Update the EPG last refreshed display
*/
async updateEpgLastRefreshed() {
const display = document.getElementById('epg-last-refreshed');
if (!display) return;
try {
// Fetch last sync time from server
const response = await fetch('/api/settings/sync-status');
if (!response.ok) throw new Error('Failed to fetch sync status');
const data = await response.json();
if (data.lastSyncTime) {
const lastRefreshTime = new Date(data.lastSyncTime);
// Format as relative time or absolute
const now = new Date();
const diffMs = now - lastRefreshTime;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
let text;
if (diffMins < 1) {
text = 'Hace un momento';
} else if (diffMins < 60) {
text = `Hace ${diffMins} min`;
} else if (diffHours < 24) {
text = `Hace ${diffHours} h`;
} else {
text = lastRefreshTime.toLocaleString('es-ES');
}
display.textContent = text;
display.title = lastRefreshTime.toLocaleString('es-ES');
} else {
display.textContent = 'Nunca';
display.title = 'La sincronización aún no se ha ejecutado';
}
} catch (err) {
console.error('Error fetching sync status:', err);
display.textContent = '—';
display.title = 'No se pudo obtener el estado de sincronización';
}
}
hide() {
// Page is hidden
}
}
window.SettingsPage = SettingsPage;
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
/**
* Movies Page Controller
* Handles VOD movie browsing and playback
*/
class MoviesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('movies-grid');
this.sourceSelect = document.getElementById('movies-source-select');
this.categorySelect = document.getElementById('movies-category-select');
this.searchInput = document.getElementById('movies-search');
this.movies = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredMovies = [];
this.isLoading = false;
this.observer = null;
this.favoriteIds = new Set(); // Track favorite movie IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadMovies();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadMovies();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('movies-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load movies if empty
if (this.movies.length === 0) {
await this.loadCategories();
await this.loadMovies();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'movie');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set(); // Track hidden categories
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'vod_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.vodCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadMovies() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.movies = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue; // Skip this source if category is from different source
}
}
const movies = await API.proxy.xtream.vodStreams(source.id, catId);
console.log(`[Movies] Source ${source.id}, Category ${catId || 'ALL'}: Got ${movies?.length || 0} movies`);
if (movies && Array.isArray(movies)) {
movies.forEach(m => {
// Skip movies from hidden categories
if (this.hiddenCategoryIds && this.hiddenCategoryIds.has(`${source.id}:${m.category_id}`)) {
return;
}
this.movies.push({
...m,
sourceId: source.id,
id: `${source.id}:${m.stream_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load movies from source ${source.id}:`, err.message);
}
}
console.log(`[Movies] Total loaded: ${this.movies.length} movies`);
this.filterAndRender();
} catch (err) {
console.error('Error loading movies:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading movies</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredMovies = this.movies.filter(m => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${m.sourceId}:${m.stream_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !m.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Movies] Displaying ${this.filteredMovies.length} of ${this.movies.length} movies`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredMovies.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'movies-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches (more to fill viewport)
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredMovies.slice(start, end);
console.log(`[Movies] Rendering batch ${this.currentBatch}: ${batch.length} cards (${start}-${end})`);
if (batch.length === 0) {
const loader = this.container.querySelector('.movies-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId;
const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
const isFav = this.favoriteIds.has(`${movie.sourceId}:${movie.stream_id}`);
card.innerHTML = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="movie-info">
<div class="movie-title">${movie.name}</div>
<div class="movie-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
// Card click plays movie, but not if clicking favorite button
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(movie, btn);
e.stopPropagation();
} else {
this.playMovie(movie);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.movies-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredMovies.length && loader) {
loader.style.display = 'none';
}
}
async playMovie(movie) {
try {
// Get stream URL for movie using the actual container extension from API
// Xtream API returns container_extension (e.g., 'mp4', 'mkv', 'avi')
const container = movie.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
this.app.pages.watch.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
sourceId: movie.sourceId,
categoryId: movie.category_id,
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing movie:', err);
}
}
async toggleFavorite(movie, btn) {
const favKey = `${movie.sourceId}:${movie.stream_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(movie.sourceId, movie.stream_id, 'movie');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.MoviesPage = MoviesPage;
+502
View File
@@ -0,0 +1,502 @@
/**
* Series Page Controller
* Handles TV series browsing and playback
*/
class SeriesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('series-grid');
this.sourceSelect = document.getElementById('series-source-select');
this.categorySelect = document.getElementById('series-category-select');
this.searchInput = document.getElementById('series-search');
this.detailsPanel = document.getElementById('series-details');
this.seasonsContainer = document.getElementById('series-seasons');
this.seriesList = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredSeries = [];
this.isLoading = false;
this.observer = null;
this.hiddenCategoryIds = new Set();
this.currentSeries = null;
this.favoriteIds = new Set(); // Track favorite series IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadSeries();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadSeries();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Back button
document.querySelector('.series-back-btn')?.addEventListener('click', () => {
this.hideDetails();
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('series-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Hide details panel when showing page
this.hideDetails();
// Load sources if not loaded
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load series if empty
if (this.seriesList.length === 0) {
await this.loadCategories();
await this.loadSeries();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'series');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set();
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'series_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.seriesCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load series categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadSeries() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.seriesList = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue;
}
}
const series = await API.proxy.xtream.series(source.id, catId);
console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`);
if (series && Array.isArray(series)) {
series.forEach(s => {
// Skip series from hidden categories
if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) {
return;
}
this.seriesList.push({
...s,
sourceId: source.id,
id: `${source.id}:${s.series_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load series from source ${source.id}:`, err.message);
}
}
console.log(`[Series] Total loaded: ${this.seriesList.length} series`);
this.filterAndRender();
} catch (err) {
console.error('Error loading series:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading series</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredSeries = this.seriesList.filter(s => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${s.sourceId}:${s.series_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredSeries.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'series-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredSeries.slice(start, end);
if (batch.length === 0) {
const loader = this.container.querySelector('.series-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(series => {
const card = document.createElement('div');
card.className = 'series-card';
card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId;
const poster = series.cover || '/img/placeholder.png';
const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`);
card.innerHTML = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="series-card-info">
<div class="series-title">${series.name}</div>
<div class="series-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(series, btn);
e.stopPropagation();
} else {
this.showSeriesDetails(series);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.series-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredSeries.length && loader) {
loader.style.display = 'none';
}
}
async showSeriesDetails(series) {
this.currentSeries = series;
// Show details panel
this.container.classList.add('hidden');
this.detailsPanel.classList.remove('hidden');
// Set header info
document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || '';
// Show loading
this.seasonsContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
// Fetch series info (seasons/episodes)
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
if (!info || !info.episodes) {
this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
return;
}
// Store series info for WatchPage
this.currentSeriesInfo = info;
// Render seasons and episodes
let html = '';
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
seasons.forEach(seasonNum => {
const episodes = info.episodes[seasonNum];
html += `
<div class="season-group">
<div class="season-header">
<span class="season-expander">${Icons.chevronDown}</span>
<span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
</div>
<div class="episode-list">
${episodes.map(ep => `
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
<span class="episode-number">E${ep.episode_num}</span>
<span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
<span class="episode-duration">${ep.duration || ''}</span>
</div>
`).join('')}
</div>
</div>`;
});
this.seasonsContainer.innerHTML = html;
// Add click handlers
this.seasonsContainer.querySelectorAll('.season-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.season-group').classList.toggle('collapsed');
});
});
this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => {
ep.addEventListener('click', () => this.playEpisode(ep));
});
} catch (err) {
console.error('Error loading series info:', err);
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
}
}
hideDetails() {
this.detailsPanel.classList.add('hidden');
this.container.classList.remove('hidden');
this.currentSeries = null;
}
async playEpisode(episodeEl) {
const episodeId = episodeEl.dataset.episodeId;
const sourceId = parseInt(episodeEl.dataset.sourceId);
const container = episodeEl.dataset.container || 'mp4';
// Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Season (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
this.app.pages.watch.play({
type: 'series',
id: episodeId,
title: this.currentSeries?.name || 'Series',
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.currentSeries?.cover,
description: this.currentSeries?.plot || '',
year: this.currentSeries?.year,
rating: this.currentSeries?.rating,
sourceId: sourceId,
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum,
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing episode:', err);
}
}
async toggleFavorite(series, btn) {
const favKey = `${series.sourceId}:${series.series_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(series.sourceId, series.series_id, 'series');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(series.sourceId, series.series_id, 'series');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.SeriesPage = SeriesPage;
File diff suppressed because it is too large Load Diff