Instancia transcoder t2: snapshot
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64">
|
||||
<stop offset="0%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#4f46e5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||
<rect x="10" y="18" width="44" height="28" rx="4" stroke="#fff" stroke-width="3" fill="none"/>
|
||||
<path d="M22 32 L28 38 L42 24" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -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 = 'm3u8') =>
|
||||
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;
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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(() => { });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 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,
|
||||
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;
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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,
|
||||
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;
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* 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 || 'm3u8';
|
||||
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('User created successfully!');
|
||||
addUserForm.reset();
|
||||
this.loadUsers();
|
||||
} catch (err) {
|
||||
alert('Error creating user: ' + 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 opening edit modal: ' + 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 updating user: ' + 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 deleting user: ' + 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();
|
||||
}
|
||||
|
||||
// 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 || 'm3u8';
|
||||
|
||||
// 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 = 'Just now';
|
||||
} else if (diffMins < 60) {
|
||||
text = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
text = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
// Use absolute time for older refreshes
|
||||
text = lastRefreshTime.toLocaleString();
|
||||
}
|
||||
|
||||
display.textContent = text;
|
||||
display.title = lastRefreshTime.toLocaleString(); // Full timestamp on hover
|
||||
} else {
|
||||
display.textContent = 'Never';
|
||||
display.title = 'Sync has not run yet since server started';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync status:', err);
|
||||
display.textContent = 'Unknown';
|
||||
display.title = 'Could not fetch sync status';
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Page is hidden
|
||||
}
|
||||
}
|
||||
|
||||
window.SettingsPage = SettingsPage;
|
||||
@@ -0,0 +1,320 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Iniciar sesión — KiraTV</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-bg-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: var(--space-2xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.login-logo .logo-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto var(--space-md);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-family: 'Century Gothic', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-logo .brand-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-logo p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-sm) 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setup-message {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--color-accent);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.setup-message.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
||||
</svg>
|
||||
<h1>KiraTV</h1>
|
||||
<p id="login-subtitle">Inicia sesión para continuar</p>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="error-message"></div>
|
||||
<div class="setup-message" id="setup-message">
|
||||
Bienvenido. Crea tu cuenta de administrador para empezar.
|
||||
</div>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"
|
||||
placeholder="Enter your username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
minlength="6" placeholder="Enter your password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
||||
<span
|
||||
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
|
||||
<div
|
||||
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-sso-login" class="btn-login"
|
||||
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
style="width: 20px; height: 20px;">
|
||||
<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>
|
||||
Sign in with SSO
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#btn-sso-login:hover {
|
||||
background: var(--color-bg-tertiary) !important;
|
||||
border-color: var(--color-accent) !important;
|
||||
color: var(--color-accent) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Start SSO Login
|
||||
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
});
|
||||
|
||||
// Check for URL Error params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const error = urlParams.get('error');
|
||||
if (error) {
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = error;
|
||||
errorMessage.classList.add('show');
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
// Check if setup is required
|
||||
let isSetupMode = false;
|
||||
|
||||
async function checkSetupRequired() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup-required');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.setupRequired) {
|
||||
isSetupMode = true;
|
||||
document.getElementById('login-subtitle').textContent = 'Create Admin Account';
|
||||
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||
document.getElementById('setup-message').classList.add('show');
|
||||
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
||||
// Hide SSO in setup mode
|
||||
document.querySelector('.sso-divider').style.display = 'none';
|
||||
document.getElementById('btn-sso-login').style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking setup status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
// Clear previous errors
|
||||
errorMessage.classList.remove('show');
|
||||
errorMessage.textContent = '';
|
||||
|
||||
// Disable button
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Please wait...';
|
||||
|
||||
try {
|
||||
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('authToken', data.token);
|
||||
|
||||
// Redirect to main app (replace history to prevent back button issues)
|
||||
window.location.replace('/');
|
||||
|
||||
} catch (err) {
|
||||
errorMessage.textContent = err.message;
|
||||
errorMessage.classList.add('show');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = isSetupMode ? 'Create Account' : 'Sign In';
|
||||
}
|
||||
});
|
||||
|
||||
// Check setup status on load
|
||||
checkSetupRequired();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||