Initial commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
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`)
|
||||
},
|
||||
|
||||
// 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 })
|
||||
},
|
||||
|
||||
// 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) => API.request('GET', `/proxy/xtream/${sourceId}/live_categories`),
|
||||
liveStreams: (sourceId, categoryId = null) =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/live_streams${categoryId ? `?category_id=${categoryId}` : ''}`),
|
||||
vodCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/vod_categories`),
|
||||
vodStreams: (sourceId, categoryId = null) =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${categoryId ? `?category_id=${categoryId}` : ''}`),
|
||||
seriesCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/series_categories`),
|
||||
series: (sourceId, categoryId = null) =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/series${categoryId ? `?category_id=${categoryId}` : ''}`),
|
||||
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}`)
|
||||
},
|
||||
|
||||
// M3U
|
||||
m3u: {
|
||||
get: (sourceId) => API.request('GET', `/proxy/m3u/${sourceId}`)
|
||||
},
|
||||
|
||||
// EPG
|
||||
epg: {
|
||||
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
|
||||
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make API available globally
|
||||
window.API = API;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Trev-TV Application Entry Point
|
||||
*/
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.currentPage = 'home';
|
||||
this.pages = {};
|
||||
|
||||
// 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.guide = new GuidePage(this);
|
||||
this.pages.movies = new MoviesPage(this);
|
||||
this.pages.series = new SeriesPage(this);
|
||||
this.pages.settings = new SettingsPage(this);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Navigation handling
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.navigateTo(link.dataset.page);
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle groups button
|
||||
document.getElementById('toggle-groups').addEventListener('click', () => {
|
||||
this.channelList.toggleAllGroups();
|
||||
});
|
||||
|
||||
// Initialize home page
|
||||
await this.pages.home.init();
|
||||
this.navigateTo('home');
|
||||
|
||||
console.log('NodeCast TV initialized');
|
||||
}
|
||||
|
||||
navigateTo(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();
|
||||
});
|
||||
@@ -0,0 +1,964 @@
|
||||
/**
|
||||
* Channel List Component
|
||||
* Handles the sidebar channel list
|
||||
*/
|
||||
|
||||
class ChannelList {
|
||||
constructor() {
|
||||
this.container = document.getElementById('channel-list');
|
||||
this.searchInput = document.getElementById('channel-search');
|
||||
this.sourceSelect = document.getElementById('source-select');
|
||||
this.showHiddenCheckbox = document.getElementById('show-hidden');
|
||||
this.contextMenu = document.getElementById('context-menu');
|
||||
|
||||
this.channels = [];
|
||||
this.groups = [];
|
||||
this.hiddenItems = new Set(); // Set<"type:sourceId:itemId">
|
||||
this.collapsedGroups = new Set(); // Track collapsed groups
|
||||
this.favorites = []; // Array of favorite objects
|
||||
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
|
||||
this.currentChannel = null;
|
||||
this.sources = [];
|
||||
this.isLoading = false;
|
||||
|
||||
this.loadCollapsedState();
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load collapsed state from localStorage
|
||||
*/
|
||||
loadCollapsedState() {
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
|
||||
if (saved) {
|
||||
this.collapsedGroups = new Set(JSON.parse(saved));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading collapsed state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save collapsed state to localStorage
|
||||
*/
|
||||
saveCollapsedState() {
|
||||
try {
|
||||
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
|
||||
} catch (err) {
|
||||
console.error('Error saving collapsed state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle group collapsed state
|
||||
*/
|
||||
toggleGroup(groupName) {
|
||||
if (this.collapsedGroups.has(groupName)) {
|
||||
this.collapsedGroups.delete(groupName);
|
||||
} else {
|
||||
this.collapsedGroups.add(groupName);
|
||||
}
|
||||
this.saveCollapsedState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand all groups
|
||||
*/
|
||||
expandAll() {
|
||||
this.collapsedGroups.clear();
|
||||
this.saveCollapsedState();
|
||||
this.container.querySelectorAll('.group-header.collapsed').forEach(h => h.classList.remove('collapsed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse all groups
|
||||
*/
|
||||
collapseAll() {
|
||||
this.container.querySelectorAll('.group-header').forEach(h => {
|
||||
const groupName = h.dataset.group;
|
||||
this.collapsedGroups.add(groupName);
|
||||
h.classList.add('collapsed');
|
||||
});
|
||||
this.saveCollapsedState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between expand/collapse all
|
||||
*/
|
||||
toggleAllGroups() {
|
||||
const allHeaders = this.container.querySelectorAll('.group-header');
|
||||
const allCollapsed = [...allHeaders].every(h => h.classList.contains('collapsed'));
|
||||
|
||||
if (allCollapsed) {
|
||||
this.expandAll();
|
||||
} else {
|
||||
this.collapseAll();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Search handler (debounced)
|
||||
let searchTimeout;
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.render();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Source filter handler
|
||||
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
||||
|
||||
// Show hidden toggle
|
||||
if (this.showHiddenCheckbox) {
|
||||
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
||||
}
|
||||
|
||||
// Context menu handlers
|
||||
document.addEventListener('click', () => this.hideContextMenu());
|
||||
|
||||
this.contextMenu.querySelectorAll('.context-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => this.handleContextAction(e));
|
||||
});
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.renderNextBatch();
|
||||
}
|
||||
}, { rootMargin: '100px' });
|
||||
}
|
||||
|
||||
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
|
||||
|
||||
/**
|
||||
* Get current program info string
|
||||
*/
|
||||
getProgramInfo(channel) {
|
||||
try {
|
||||
if (!window.app || !window.app.epgGuide) return null;
|
||||
const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
|
||||
return program ? program.title : null;
|
||||
} catch (e) {
|
||||
console.warn("Error in getProgramInfo", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render channel list
|
||||
*/
|
||||
render() {
|
||||
const searchTerm = this.searchInput.value.toLowerCase();
|
||||
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
|
||||
|
||||
// Reset batching
|
||||
this.currentBatch = 0;
|
||||
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
||||
this.container.innerHTML = ''; // Clear container
|
||||
|
||||
// Filter and Group channels
|
||||
const groupedChannels = {};
|
||||
|
||||
// 1. Filter
|
||||
this.filteredChannels = this.channels;
|
||||
if (searchTerm) {
|
||||
this.filteredChannels = this.channels.filter(ch =>
|
||||
ch.name.toLowerCase().includes(searchTerm) ||
|
||||
(ch.groupTitle && ch.groupTitle.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
let filteredChannels = this.filteredChannels;
|
||||
|
||||
// 2. Group
|
||||
filteredChannels.forEach(ch => {
|
||||
const groupKey = ch.groupTitle || 'Uncategorized';
|
||||
if (!groupedChannels[groupKey]) {
|
||||
groupedChannels[groupKey] = [];
|
||||
}
|
||||
groupedChannels[groupKey].push(ch);
|
||||
});
|
||||
|
||||
// 3. Add Favorites
|
||||
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
|
||||
if (favoritedChannels.length > 0) {
|
||||
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
||||
groupedChannels['Favorites'] = favoritedChannels;
|
||||
}
|
||||
|
||||
// 4. Sort Groups
|
||||
this.sortedGroups = Object.keys(groupedChannels).sort((a, b) => {
|
||||
if (a === 'Favorites') return -1;
|
||||
if (b === 'Favorites') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
this.groupedChannels = groupedChannels;
|
||||
this.showHidden = showHidden;
|
||||
|
||||
// Empty State
|
||||
if (this.sortedGroups.length === 0) {
|
||||
this.container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>${searchTerm ? 'No channels match your search' : 'No channels loaded'}</p>
|
||||
<p class="hint">${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap container content in a specific list div to append to
|
||||
this.listContainer = document.createElement('div');
|
||||
this.listContainer.className = 'channel-list-content';
|
||||
this.container.appendChild(this.listContainer);
|
||||
|
||||
// Add loader element at bottom
|
||||
this.loader = document.createElement('div');
|
||||
this.loader.className = 'batch-loader';
|
||||
this.loader.innerHTML = '<div class="loading-spinner"></div>';
|
||||
this.loader.style.opacity = '0'; // Hide initially
|
||||
this.container.appendChild(this.loader);
|
||||
|
||||
// Render initial batches - load enough to create scrollable content
|
||||
// With many hidden groups, we may need multiple batches to get enough visible groups
|
||||
const maxInitialBatches = 10; // Load up to 200 groups (10 batches x 20 groups)
|
||||
for (let i = 0; i < maxInitialBatches; i++) {
|
||||
if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break;
|
||||
this.renderNextBatch();
|
||||
}
|
||||
|
||||
// Start observing loader for additional batches
|
||||
this.observer.observe(this.loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render next batch of groups
|
||||
*/
|
||||
renderNextBatch() {
|
||||
const start = this.currentBatch * this.batchSize;
|
||||
const end = start + this.batchSize;
|
||||
const groupsToRender = this.sortedGroups.slice(start, end);
|
||||
|
||||
if (groupsToRender.length === 0) {
|
||||
// No more groups
|
||||
this.loader.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loader.style.opacity = '1';
|
||||
let html = '';
|
||||
|
||||
for (const groupName of groupsToRender) {
|
||||
const channels = this.groupedChannels[groupName];
|
||||
if (channels.length === 0) continue;
|
||||
|
||||
const isFavoritesGroup = groupName === 'Favorites';
|
||||
const groupHidden = !isFavoritesGroup && channels.length > 0 && this.isHidden('group', channels[0].sourceId, groupName);
|
||||
|
||||
if (groupHidden && !this.showHidden) continue;
|
||||
|
||||
html += `
|
||||
<div class="channel-group">
|
||||
<div class="group-header ${groupHidden ? 'hidden' : ''} ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
|
||||
<span class="group-toggle">▼</span>
|
||||
<span class="group-name">${groupName}</span>
|
||||
<span class="group-count">${channels.length}</span>
|
||||
</div>
|
||||
<div class="group-channels">
|
||||
`;
|
||||
|
||||
for (const channel of channels) {
|
||||
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, channel.id);
|
||||
if (channelHidden && !this.showHidden) continue;
|
||||
|
||||
const isActive = this.currentChannel?.id === channel.id;
|
||||
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
||||
|
||||
html += `
|
||||
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
||||
data-channel-id="${channel.id}"
|
||||
data-source-id="${channel.sourceId}"
|
||||
data-source-type="${channel.sourceType}"
|
||||
data-stream-id="${channel.streamId || ''}"
|
||||
data-url="${channel.url || ''}">
|
||||
<img class="channel-logo" src="${channel.tvgLogo || '/img/placeholder.png'}"
|
||||
alt="" onerror="this.src='/img/placeholder.png'">
|
||||
<div class="channel-info">
|
||||
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
||||
</div>
|
||||
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
|
||||
${isFavorite ? '❤️' : '♡'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Append to list container
|
||||
// Use temp div to parse HTML string
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
while (tempDiv.firstElementChild) {
|
||||
const groupEl = tempDiv.firstElementChild;
|
||||
this.attachGroupListeners(groupEl);
|
||||
this.listContainer.appendChild(groupEl);
|
||||
}
|
||||
|
||||
this.currentBatch++;
|
||||
|
||||
// Hide loader if we might be done (next batch check will confirm)
|
||||
if (end >= this.sortedGroups.length) {
|
||||
this.loader.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
attachGroupListeners(groupEl) {
|
||||
const header = groupEl.querySelector('.group-header');
|
||||
if (header) {
|
||||
header.addEventListener('click', () => {
|
||||
header.classList.toggle('collapsed');
|
||||
this.toggleGroup(header.dataset.group);
|
||||
});
|
||||
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
|
||||
}
|
||||
|
||||
groupEl.querySelectorAll('.channel-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.favorite-btn')) return;
|
||||
this.selectChannel(item.dataset);
|
||||
});
|
||||
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
|
||||
|
||||
const favBtn = item.querySelector('.favorite-btn');
|
||||
if (favBtn) {
|
||||
favBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sources into dropdown
|
||||
*/
|
||||
async loadSources() {
|
||||
try {
|
||||
this.sources = await API.sources.getAll();
|
||||
console.log('[ChannelList] loadSources: Got', this.sources?.length || 0, 'sources');
|
||||
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
|
||||
|
||||
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
|
||||
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
|
||||
|
||||
if (xtreamSources.length > 0) {
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Xtream';
|
||||
xtreamSources.forEach(s => {
|
||||
const option = document.createElement('option');
|
||||
option.value = `xtream:${s.id}`;
|
||||
option.textContent = s.name;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
this.sourceSelect.appendChild(optgroup);
|
||||
}
|
||||
|
||||
if (m3uSources.length > 0) {
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'M3U';
|
||||
m3uSources.forEach(s => {
|
||||
const option = document.createElement('option');
|
||||
option.value = `m3u:${s.id}`;
|
||||
option.textContent = s.name;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
this.sourceSelect.appendChild(optgroup);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load channels from selected source
|
||||
*/
|
||||
async loadChannels() {
|
||||
if (this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
|
||||
const sourceValue = this.sourceSelect.value;
|
||||
const self = this;
|
||||
|
||||
if (!sourceValue) {
|
||||
// Load from all sources
|
||||
await this.loadAllChannels();
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [type, id] = sourceValue.split(':');
|
||||
|
||||
try {
|
||||
this.container.innerHTML = '<div class="loading"></div>';
|
||||
|
||||
if (type === 'xtream') {
|
||||
await this.loadXtreamChannels(parseInt(id));
|
||||
} else if (type === 'm3u') {
|
||||
await this.loadM3uChannels(parseInt(id));
|
||||
}
|
||||
|
||||
// Load hidden items and favorites
|
||||
await Promise.all([
|
||||
this.loadHiddenItems(),
|
||||
this.loadFavorites()
|
||||
]);
|
||||
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error('Error loading channels:', err);
|
||||
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load channels from all enabled sources
|
||||
*/
|
||||
async loadAllChannels() {
|
||||
this.channels = [];
|
||||
this.groups = [];
|
||||
|
||||
try {
|
||||
this.container.innerHTML = '<div class="loading"></div>';
|
||||
|
||||
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
|
||||
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
|
||||
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
|
||||
|
||||
for (const source of xtreamSources) {
|
||||
await this.loadXtreamChannels(source.id, true);
|
||||
}
|
||||
|
||||
for (const source of m3uSources) {
|
||||
await this.loadM3uChannels(source.id, true);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.loadHiddenItems(),
|
||||
this.loadFavorites()
|
||||
]);
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error('Error loading all channels:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Xtream channels
|
||||
*/
|
||||
async loadXtreamChannels(sourceId, append = false) {
|
||||
if (!append) {
|
||||
this.channels = [];
|
||||
this.groups = [];
|
||||
}
|
||||
|
||||
const categories = await API.proxy.xtream.liveCategories(sourceId);
|
||||
const streams = await API.proxy.xtream.liveStreams(sourceId);
|
||||
|
||||
// Map categories to groups
|
||||
const categoryGroups = categories.map(cat => ({
|
||||
id: `xtream_${sourceId}_${cat.category_id}`,
|
||||
name: cat.category_name,
|
||||
sourceId,
|
||||
sourceType: 'xtream'
|
||||
}));
|
||||
|
||||
this.groups.push(...categoryGroups);
|
||||
|
||||
// Map streams to channels
|
||||
const channelList = streams.map(stream => ({
|
||||
id: `xtream_${sourceId}_${stream.stream_id}`,
|
||||
streamId: stream.stream_id,
|
||||
name: stream.name,
|
||||
tvgId: stream.epg_channel_id,
|
||||
tvgLogo: stream.stream_icon,
|
||||
groupId: `xtream_${sourceId}_${stream.category_id}`,
|
||||
groupTitle: categories.find(c => c.category_id === stream.category_id)?.category_name || 'Uncategorized',
|
||||
sourceId,
|
||||
sourceType: 'xtream'
|
||||
}));
|
||||
|
||||
// Deduplicate by name within the same group (Xtream often sends backup streams with same name)
|
||||
const seen = new Set();
|
||||
const uniqueChannels = [];
|
||||
for (const ch of channelList) {
|
||||
const key = `${ch.groupTitle}|${ch.name}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueChannels.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
this.channels.push(...uniqueChannels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load M3U channels
|
||||
*/
|
||||
async loadM3uChannels(sourceId, append = false) {
|
||||
if (!append) {
|
||||
this.channels = [];
|
||||
this.groups = [];
|
||||
}
|
||||
|
||||
const data = await API.proxy.m3u.get(sourceId);
|
||||
|
||||
// Add groups
|
||||
const m3uGroups = data.groups.map(g => ({
|
||||
...g,
|
||||
id: `m3u_${sourceId}_${g.id}`,
|
||||
sourceId,
|
||||
sourceType: 'm3u'
|
||||
}));
|
||||
|
||||
this.groups.push(...m3uGroups);
|
||||
|
||||
// Add channels - use the stable id from the server
|
||||
const channelList = data.channels.map(ch => ({
|
||||
...ch,
|
||||
// Use the stable id provided by the server (from tvgId or hash)
|
||||
id: ch.id,
|
||||
groupId: `m3u_${sourceId}_group_${data.groups.findIndex(g => g.name === ch.groupTitle)}`,
|
||||
sourceId,
|
||||
sourceType: 'm3u'
|
||||
}));
|
||||
|
||||
this.channels.push(...channelList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hidden items
|
||||
*/
|
||||
async loadHiddenItems() {
|
||||
try {
|
||||
const items = await API.channels.getHidden();
|
||||
this.hiddenItems = new Set(items.map(i => `${i.item_type}:${i.source_id}:${i.item_id}`));
|
||||
} catch (err) {
|
||||
console.error('Error loading hidden items:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item is hidden
|
||||
*/
|
||||
isHidden(type, sourceId, itemId) {
|
||||
return this.hiddenItems.has(`${type}:${sourceId}:${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load favorites
|
||||
*/
|
||||
async loadFavorites() {
|
||||
try {
|
||||
// Get all favorites (filtered for channels or legacy items without type)
|
||||
const allFavs = await API.favorites.getAll();
|
||||
const channelFavs = allFavs.filter(f => !f.item_type || f.item_type === 'channel');
|
||||
|
||||
this.visibleFavorites = new Set(
|
||||
channelFavs.map(f => `${f.source_id}:${f.item_id || f.channel_id}`)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error loading favorites:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel is favorite
|
||||
*/
|
||||
isFavorite(sourceId, channelId) {
|
||||
return this.visibleFavorites.has(`${sourceId}:${channelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
async toggleFavorite(sourceId, channelId) {
|
||||
const key = `${sourceId}:${channelId}`;
|
||||
const wasFavorite = this.visibleFavorites.has(key);
|
||||
|
||||
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
|
||||
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (wasFavorite) {
|
||||
this.visibleFavorites.delete(key);
|
||||
btns.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '♡';
|
||||
btn.title = 'Add to Favorites';
|
||||
});
|
||||
} else {
|
||||
this.visibleFavorites.add(key);
|
||||
btns.forEach(btn => {
|
||||
btn.classList.add('active');
|
||||
btn.innerHTML = '❤️';
|
||||
btn.title = 'Remove from Favorites';
|
||||
});
|
||||
}
|
||||
|
||||
// Updates Favorites Group DOM
|
||||
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
||||
if (channel) {
|
||||
this.updateFavoritesGroup(channel, !wasFavorite);
|
||||
}
|
||||
// Do NOT call this.render() - it causes lag
|
||||
|
||||
// Perform API call
|
||||
if (wasFavorite) {
|
||||
await API.favorites.remove(sourceId, channelId, 'channel');
|
||||
} else {
|
||||
await API.favorites.add(sourceId, channelId, 'channel');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
// Revert on error
|
||||
if (wasFavorite) {
|
||||
this.visibleFavorites.add(key);
|
||||
btns.forEach(btn => {
|
||||
btn.classList.add('active');
|
||||
btn.innerHTML = '❤️';
|
||||
});
|
||||
// Revert group update
|
||||
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
||||
if (channel) this.updateFavoritesGroup(channel, true);
|
||||
} else {
|
||||
this.visibleFavorites.delete(key);
|
||||
btns.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '♡';
|
||||
});
|
||||
// Revert group update
|
||||
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
||||
if (channel) this.updateFavoritesGroup(channel, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Favorites group in DOM and data
|
||||
*/
|
||||
updateFavoritesGroup(channel, isAdded) {
|
||||
// 1. Update Data
|
||||
if (!this.groupedChannels['Favorites']) {
|
||||
this.groupedChannels['Favorites'] = [];
|
||||
}
|
||||
|
||||
const favArray = this.groupedChannels['Favorites'];
|
||||
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
|
||||
|
||||
if (isAdded) {
|
||||
if (existingIdx === -1) favArray.push(channel);
|
||||
} else {
|
||||
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
|
||||
}
|
||||
|
||||
// 2. Update DOM
|
||||
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]');
|
||||
|
||||
if (!groupHeader) {
|
||||
// If group doesn't exist and we're adding, we ideally should create it
|
||||
// For now, simpler to just return. User will see it on next refresh.
|
||||
// Or we could force a re-render if it's the first favorite?
|
||||
if (isAdded && favArray.length === 1) {
|
||||
this.render(); // This is the one case where full render is worth it
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const groupChannels = groupHeader.nextElementSibling; // .group-channels
|
||||
const countSpan = groupHeader.querySelector('.group-count');
|
||||
|
||||
if (isAdded) {
|
||||
// Check if already in DOM (to avoid dupes)
|
||||
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
|
||||
if (!existingEl) {
|
||||
const newEl = this.createChannelElement(channel);
|
||||
groupChannels.appendChild(newEl);
|
||||
}
|
||||
} else {
|
||||
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
|
||||
if (existingEl) {
|
||||
existingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update count
|
||||
if (countSpan) countSpan.textContent = favArray.length;
|
||||
|
||||
// Hide/Show group if empty?
|
||||
if (favArray.length === 0) {
|
||||
groupHeader.classList.add('hidden'); // Or remove
|
||||
groupHeader.style.display = 'none';
|
||||
} else {
|
||||
groupHeader.classList.remove('hidden');
|
||||
groupHeader.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
createChannelElement(channel) {
|
||||
const div = document.createElement('div');
|
||||
const isActive = this.currentChannel?.id === channel.id;
|
||||
// In Favorites group, it IS a favorite
|
||||
const isFavorite = true;
|
||||
|
||||
div.className = `channel-item ${isActive ? 'active' : ''}`;
|
||||
div.dataset.channelId = channel.id;
|
||||
div.dataset.sourceId = channel.sourceId;
|
||||
div.dataset.sourceType = channel.sourceType;
|
||||
div.dataset.streamId = channel.streamId || '';
|
||||
div.dataset.url = channel.url || '';
|
||||
|
||||
div.innerHTML = `
|
||||
<img class="channel-logo" src="${channel.tvgLogo || '/img/placeholder.png'}"
|
||||
alt="" onerror="this.src='/img/placeholder.png'">
|
||||
<div class="channel-info">
|
||||
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
||||
<div class="channel-program">${this.getProgramInfo(channel) || ''}</div>
|
||||
</div>
|
||||
<button class="favorite-btn active" title="Remove from Favorites">
|
||||
❤️
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Attach listeners
|
||||
div.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.favorite-btn')) return;
|
||||
this.selectChannel(div.dataset);
|
||||
});
|
||||
div.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', div.dataset));
|
||||
|
||||
const favBtn = div.querySelector('.favorite-btn');
|
||||
if (favBtn) {
|
||||
favBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleFavorite(parseInt(div.dataset.sourceId), div.dataset.channelId);
|
||||
});
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and play a channel
|
||||
*/
|
||||
async selectChannel(dataset) {
|
||||
const channel = this.channels.find(c => c.id === dataset.channelId);
|
||||
if (!channel) return;
|
||||
|
||||
this.currentChannel = channel;
|
||||
|
||||
// Update active state in DOM without full re-render
|
||||
this.container.querySelectorAll('.channel-item.active').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
const activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
|
||||
if (activeItem) {
|
||||
activeItem.classList.add('active');
|
||||
}
|
||||
|
||||
// Get stream URL
|
||||
let streamUrl;
|
||||
if (channel.sourceType === 'xtream') {
|
||||
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live');
|
||||
streamUrl = result.url;
|
||||
} else {
|
||||
streamUrl = channel.url;
|
||||
}
|
||||
|
||||
// Play channel
|
||||
if (window.app?.player) {
|
||||
window.app.player.play(channel, streamUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu
|
||||
*/
|
||||
showContextMenu(e, type, data) {
|
||||
e.preventDefault();
|
||||
this.contextMenu.dataset.type = type;
|
||||
this.contextMenu.dataset.sourceId = data.sourceId;
|
||||
this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId;
|
||||
|
||||
this.contextMenu.style.left = `${e.clientX}px`;
|
||||
this.contextMenu.style.top = `${e.clientY}px`;
|
||||
this.contextMenu.classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide context menu
|
||||
*/
|
||||
hideContextMenu() {
|
||||
this.contextMenu.classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle context menu action
|
||||
*/
|
||||
async handleContextAction(e) {
|
||||
const action = e.target.dataset.action;
|
||||
const { type, sourceId, itemId } = this.contextMenu.dataset;
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
if (type === 'channel') {
|
||||
const channel = this.channels.find(c => c.id === itemId);
|
||||
if (channel) {
|
||||
await this.selectChannel({ channelId: channel.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'hide':
|
||||
await API.channels.hide(parseInt(sourceId), type, itemId);
|
||||
this.hiddenItems.add(`${type}:${sourceId}:${itemId}`);
|
||||
this.render();
|
||||
break;
|
||||
case 'epg':
|
||||
// Show EPG info modal
|
||||
this.showEpgInfo(itemId);
|
||||
break;
|
||||
}
|
||||
|
||||
this.hideContextMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync favorite status from external source (e.g. EPG) without API call
|
||||
*/
|
||||
syncFavorite(sourceId, channelId, isFavorite) {
|
||||
const key = `${sourceId}:${channelId}`;
|
||||
const currentlyFav = this.visibleFavorites.has(key);
|
||||
|
||||
if (currentlyFav === isFavorite) return; // No change needed
|
||||
|
||||
// Update State
|
||||
if (isFavorite) {
|
||||
this.visibleFavorites.add(key);
|
||||
} else {
|
||||
this.visibleFavorites.delete(key);
|
||||
}
|
||||
|
||||
// Update DOM (All instances)
|
||||
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
|
||||
|
||||
btns.forEach(btn => {
|
||||
if (isFavorite) {
|
||||
btn.classList.add('active');
|
||||
btn.innerHTML = '❤️';
|
||||
btn.title = 'Remove from Favorites';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '♡';
|
||||
btn.title = 'Add to Favorites';
|
||||
}
|
||||
});
|
||||
|
||||
// Update Favorites Group
|
||||
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
||||
if (channel) {
|
||||
this.updateFavoritesGroup(channel, isFavorite);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select next channel in the current list
|
||||
*/
|
||||
selectNextChannel() {
|
||||
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
|
||||
|
||||
const currentIndex = this.filteredChannels.findIndex(c =>
|
||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
||||
);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const nextIndex = (currentIndex + 1) % this.filteredChannels.length;
|
||||
const nextChannel = this.filteredChannels[nextIndex];
|
||||
|
||||
this.selectChannel({
|
||||
channelId: nextChannel.id,
|
||||
sourceId: nextChannel.sourceId,
|
||||
sourceType: nextChannel.sourceType,
|
||||
streamId: nextChannel.streamId,
|
||||
url: nextChannel.url
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select previous channel in the current list
|
||||
*/
|
||||
selectPrevChannel() {
|
||||
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
|
||||
|
||||
const currentIndex = this.filteredChannels.findIndex(c =>
|
||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
||||
);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const prevIndex = (currentIndex - 1 + this.filteredChannels.length) % this.filteredChannels.length;
|
||||
const prevChannel = this.filteredChannels[prevIndex];
|
||||
|
||||
this.selectChannel({
|
||||
channelId: prevChannel.id,
|
||||
sourceId: prevChannel.sourceId,
|
||||
sourceType: prevChannel.sourceType,
|
||||
streamId: prevChannel.streamId,
|
||||
url: prevChannel.url
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show EPG info for channel
|
||||
*/
|
||||
async showEpgInfo(channelId) {
|
||||
const channel = this.channels.find(c => c.id === channelId);
|
||||
if (!channel) return;
|
||||
|
||||
// This would show a modal with EPG info
|
||||
console.log('Show EPG for:', channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of visible (non-hidden) channels in display order
|
||||
*/
|
||||
getVisibleChannels() {
|
||||
const showHidden = this.showHiddenCheckbox.checked;
|
||||
return this.channels.filter(ch => {
|
||||
if (showHidden) return true;
|
||||
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
|
||||
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
|
||||
return !channelHidden && !groupHidden;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.ChannelList = ChannelList;
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* 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 = 3.5; // Width scaling
|
||||
this.favorites = new Set(); // Set<"sourceId:channelId">
|
||||
this.selectedGroup = 'Favorites'; // Default to Favorites
|
||||
|
||||
// Lazy loading properties
|
||||
this.filteredChannels = [];
|
||||
this.currentBatch = 0;
|
||||
this.batchSize = 20; // Channels per batch
|
||||
this.startTime = null;
|
||||
this.endTime = null;
|
||||
this.epgContainer = null;
|
||||
this.epgLoader = null;
|
||||
this.epgObserver = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get EPG refresh interval from settings (in hours)
|
||||
*/
|
||||
getRefreshInterval() {
|
||||
const saved = localStorage.getItem('nodecast_tv_epg_refresh_interval');
|
||||
return saved ? parseInt(saved) : 24; // Default 24 hours
|
||||
}
|
||||
|
||||
/**
|
||||
* Load EPG data (server-side caching)
|
||||
*/
|
||||
async loadEpg(forceRefresh = false) {
|
||||
try {
|
||||
this.container.innerHTML = '<div class="loading"></div>';
|
||||
await this.fetchEpgData(forceRefresh);
|
||||
this.render();
|
||||
} 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
|
||||
const maxAge = this.getRefreshInterval();
|
||||
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.channel_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,
|
||||
desc: current.desc
|
||||
} : 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)
|
||||
const playableChannels = (channelList.channels || []).filter(ch => {
|
||||
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, ch.id);
|
||||
const isGroupHidden = channelList.isHidden('group', ch.sourceId, ch.groupTitle);
|
||||
return !isChannelHidden && !isGroupHidden;
|
||||
});
|
||||
|
||||
|
||||
|
||||
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);
|
||||
|
||||
// Build initial HTML structure
|
||||
this.container.innerHTML = `
|
||||
<div class="epg-container" style="position: relative;">
|
||||
<div class="epg-time-header">
|
||||
${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 class="epg-channel-rows"></div>
|
||||
<div class="epg-loader" style="height: 50px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Get references for batch rendering
|
||||
this.epgContainer = this.container.querySelector('.epg-channel-rows');
|
||||
this.epgLoader = this.container.querySelector('.epg-loader');
|
||||
|
||||
// Reset batch state
|
||||
this.currentBatch = 0;
|
||||
|
||||
// Set up IntersectionObserver for lazy loading
|
||||
if (this.epgObserver) {
|
||||
this.epgObserver.disconnect();
|
||||
}
|
||||
this.epgObserver = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.renderNextEpgBatch();
|
||||
}
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
// Render initial batches (render enough to fill viewport)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.renderNextEpgBatch();
|
||||
}
|
||||
|
||||
// Start observing loader
|
||||
this.epgObserver.observe(this.epgLoader);
|
||||
|
||||
// Add now indicator
|
||||
this.updateNowIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render next batch of EPG channel rows
|
||||
*/
|
||||
renderNextEpgBatch() {
|
||||
const start = this.currentBatch * this.batchSize;
|
||||
const end = start + this.batchSize;
|
||||
const batch = this.filteredChannels.slice(start, end);
|
||||
|
||||
if (batch.length === 0) {
|
||||
this.epgLoader.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const { epgChannel, sourceChannel } of batch) {
|
||||
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 = sourceChannel.tvgLogo || (epgChannel && epgChannel.icon) || '/img/placeholder.png';
|
||||
const name = sourceChannel.name || (epgChannel && epgChannel.name);
|
||||
|
||||
html += `
|
||||
<div class="epg-channel-row"
|
||||
data-channel-id="${sourceChannel.id}"
|
||||
data-source-id="${sourceChannel.sourceId}">
|
||||
<div class="epg-channel-info">
|
||||
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
|
||||
${isFavorite ? '❤️' : '♡'}
|
||||
</button>
|
||||
<img class="epg-channel-logo" src="${logo}"
|
||||
alt="" onerror="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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Append to container
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
while (tempDiv.firstElementChild) {
|
||||
const row = tempDiv.firstElementChild;
|
||||
this.attachRowListeners(row);
|
||||
this.epgContainer.appendChild(row);
|
||||
}
|
||||
|
||||
this.currentBatch++;
|
||||
|
||||
// Hide loader if no more batches
|
||||
if (end >= this.filteredChannels.length) {
|
||||
this.epgLoader.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '❤️';
|
||||
btn.title = 'Remove from Favorites';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '♡';
|
||||
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');
|
||||
}
|
||||
} 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 = '❤️';
|
||||
btn.title = 'Remove from Favorites';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '♡';
|
||||
btn.title = 'Add to Favorites';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Revert Channel List
|
||||
if (window.app?.channelList) {
|
||||
window.app.channelList.syncFavorite(sourceId, channelId, wasFavorite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const container = this.container.querySelector('.epg-container');
|
||||
if (!container) return;
|
||||
|
||||
// Remove existing indicator
|
||||
const existing = container.querySelector('.epg-now-line');
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Calculate position
|
||||
const startTime = new Date();
|
||||
startTime.setHours(startTime.getHours() + this.timeOffset);
|
||||
startTime.setMinutes(0, 0, 0);
|
||||
|
||||
const minutesFromStart = (now - startTime) / 60000;
|
||||
if (minutesFromStart < 0 || minutesFromStart > 240) return; // Not in visible range
|
||||
|
||||
// Get current sidebar width
|
||||
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--epg-sidebar-width')) || 150;
|
||||
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'epg-now-line';
|
||||
indicator.style.left = `${leftPos}px`;
|
||||
container.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 home page
|
||||
document.querySelector('[data-page="home"]').click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.EpgGuide = EpgGuide;
|
||||
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* Source Manager Component
|
||||
* Handles adding, editing, and deleting sources (Xtream, M3U, EPG)
|
||||
*/
|
||||
|
||||
class SourceManager {
|
||||
constructor() {
|
||||
this.xtreamList = document.getElementById('xtream-list');
|
||||
this.m3uList = document.getElementById('m3u-list');
|
||||
this.epgList = document.getElementById('epg-list');
|
||||
|
||||
// Content browser state
|
||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Add source buttons
|
||||
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
|
||||
document.getElementById('add-m3u').addEventListener('click', () => this.showAddModal('m3u'));
|
||||
document.getElementById('add-epg').addEventListener('click', () => this.showAddModal('epg'));
|
||||
|
||||
// Initialize content browser
|
||||
this.initContentBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display all sources
|
||||
*/
|
||||
async loadSources() {
|
||||
try {
|
||||
const sources = await API.sources.getAll();
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render source list
|
||||
*/
|
||||
renderSourceList(container, sources, type) {
|
||||
if (sources.length === 0) {
|
||||
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const icons = { xtream: '📡', m3u: '📋', epg: '📺' };
|
||||
|
||||
container.innerHTML = sources.map(source => `
|
||||
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
|
||||
<span class="source-icon">${icons[type]}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${source.name}</div>
|
||||
<div class="source-url">${source.url}</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">🔄</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="test" title="Test Connection">🔗</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle" title="${source.enabled ? 'Disable' : 'Enable'}">
|
||||
${source.enabled ? '✓' : '○'}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit" title="Edit">✏️</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete" title="Delete">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Attach event listeners
|
||||
container.querySelectorAll('.source-item').forEach(item => {
|
||||
const id = parseInt(item.dataset.id);
|
||||
|
||||
item.querySelector('[data-action="refresh"]').addEventListener('click', () => this.refreshSource(id, type));
|
||||
item.querySelector('[data-action="test"]').addEventListener('click', () => this.testSource(id));
|
||||
item.querySelector('[data-action="toggle"]').addEventListener('click', () => this.toggleSource(id));
|
||||
item.querySelector('[data-action="edit"]').addEventListener('click', () => this.showEditModal(id, type));
|
||||
item.querySelector('[data-action="delete"]').addEventListener('click', () => this.deleteSource(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add source modal
|
||||
*/
|
||||
showAddModal(type) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
const footer = document.getElementById('modal-footer');
|
||||
|
||||
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
|
||||
title.textContent = titles[type];
|
||||
|
||||
body.innerHTML = this.getSourceForm(type);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="modal-save">Add Source</button>
|
||||
`;
|
||||
|
||||
modal.classList.add('active');
|
||||
|
||||
// Event listeners
|
||||
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
|
||||
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
|
||||
document.getElementById('modal-save').onclick = () => this.saveNewSource(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit source modal
|
||||
*/
|
||||
async showEditModal(id, type) {
|
||||
try {
|
||||
const source = await API.sources.getById(id);
|
||||
|
||||
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 = `Edit ${type.toUpperCase()} Source`;
|
||||
body.innerHTML = this.getSourceForm(type, source);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="modal-save">Save Changes</button>
|
||||
`;
|
||||
|
||||
modal.classList.add('active');
|
||||
|
||||
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
|
||||
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
|
||||
document.getElementById('modal-save').onclick = () => this.updateSource(id, type);
|
||||
} catch (err) {
|
||||
console.error('Error loading source:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source form HTML
|
||||
*/
|
||||
getSourceForm(type, source = {}) {
|
||||
const nameField = `
|
||||
<div class="form-group">
|
||||
<label for="source-name">Name</label>
|
||||
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const urlField = `
|
||||
<div class="form-group">
|
||||
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
|
||||
<input type="text" id="source-url" class="form-input"
|
||||
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
|
||||
value="${source.url || ''}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (type === 'xtream') {
|
||||
return `
|
||||
${nameField}
|
||||
${urlField}
|
||||
<div class="form-group">
|
||||
<label for="source-username">Username</label>
|
||||
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="source-password">Password</label>
|
||||
<input type="password" id="source-password" class="form-input"
|
||||
value="${source.password && !source.password.includes('•') ? source.password : ''}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return nameField + urlField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save new source
|
||||
*/
|
||||
async saveNewSource(type) {
|
||||
const name = document.getElementById('source-name').value.trim();
|
||||
const url = document.getElementById('source-url').value.trim();
|
||||
const username = document.getElementById('source-username')?.value.trim() || null;
|
||||
const password = document.getElementById('source-password')?.value.trim() || null;
|
||||
|
||||
if (!name || !url) {
|
||||
alert('Name and URL are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.sources.create({ type, name, url, username, password });
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
await this.loadSources();
|
||||
|
||||
// Refresh channel list
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadSources();
|
||||
await window.app.channelList.loadChannels();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error adding source: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing source
|
||||
*/
|
||||
async updateSource(id, type) {
|
||||
const name = document.getElementById('source-name').value.trim();
|
||||
const url = document.getElementById('source-url').value.trim();
|
||||
const username = document.getElementById('source-username')?.value.trim();
|
||||
const password = document.getElementById('source-password')?.value.trim();
|
||||
|
||||
if (!name || !url) {
|
||||
alert('Name and URL are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = { name, url };
|
||||
if (type === 'xtream') {
|
||||
data.username = username;
|
||||
if (password) data.password = password;
|
||||
}
|
||||
|
||||
await API.sources.update(id, data);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
await this.loadSources();
|
||||
} catch (err) {
|
||||
alert('Error updating source: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete source
|
||||
*/
|
||||
async deleteSource(id) {
|
||||
if (!confirm('Are you sure you want to delete this source?')) return;
|
||||
|
||||
try {
|
||||
await API.sources.delete(id);
|
||||
await this.loadSources();
|
||||
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadSources();
|
||||
await window.app.channelList.loadChannels();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error deleting source: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle source enabled/disabled
|
||||
*/
|
||||
async toggleSource(id) {
|
||||
try {
|
||||
await API.sources.toggle(id);
|
||||
await this.loadSources();
|
||||
} catch (err) {
|
||||
alert('Error toggling source: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test source connection
|
||||
*/
|
||||
async testSource(id) {
|
||||
try {
|
||||
const result = await API.sources.test(id);
|
||||
if (result.success) {
|
||||
alert('Connection successful!');
|
||||
} else {
|
||||
alert('Connection failed: ' + (result.error || result.message));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Connection failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh source data
|
||||
*/
|
||||
async refreshSource(id, type) {
|
||||
try {
|
||||
const btn = document.querySelector(`.source-item[data-id="${id}"] [data-action="refresh"]`);
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳';
|
||||
}
|
||||
|
||||
if (type === 'epg') {
|
||||
// Force refresh EPG data (server-side cache will be bypassed)
|
||||
if (window.app?.epgGuide) {
|
||||
await window.app.epgGuide.loadEpg(true); // Force refresh
|
||||
}
|
||||
alert('EPG data refreshed!');
|
||||
} else if (type === 'xtream') {
|
||||
// Re-fetch xtream data by reloading channels
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadChannels();
|
||||
}
|
||||
alert('Xtream data refreshed!');
|
||||
} else if (type === 'm3u') {
|
||||
// Re-fetch M3U data by reloading channels
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadChannels();
|
||||
}
|
||||
alert('M3U playlist refreshed!');
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error refreshing source:', err);
|
||||
alert('Refresh failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize content browser
|
||||
*/
|
||||
initContentBrowser() {
|
||||
this.contentSourceSelect = document.getElementById('content-source-select');
|
||||
this.contentTree = document.getElementById('content-tree');
|
||||
this.channelsBtn = document.getElementById('content-type-channels');
|
||||
this.moviesBtn = document.getElementById('content-type-movies');
|
||||
this.seriesBtn = document.getElementById('content-type-series');
|
||||
|
||||
// Content type toggle
|
||||
this.channelsBtn?.addEventListener('click', () => {
|
||||
this.contentType = 'channels';
|
||||
this.channelsBtn.classList.add('active');
|
||||
this.moviesBtn?.classList.remove('active');
|
||||
this.seriesBtn?.classList.remove('active');
|
||||
this.reloadContentTree();
|
||||
});
|
||||
|
||||
this.moviesBtn?.addEventListener('click', () => {
|
||||
this.contentType = 'movies';
|
||||
this.moviesBtn.classList.add('active');
|
||||
this.channelsBtn?.classList.remove('active');
|
||||
this.seriesBtn?.classList.remove('active');
|
||||
this.reloadContentTree();
|
||||
});
|
||||
|
||||
this.seriesBtn?.addEventListener('click', () => {
|
||||
this.contentType = 'series';
|
||||
this.seriesBtn.classList.add('active');
|
||||
this.channelsBtn?.classList.remove('active');
|
||||
this.moviesBtn?.classList.remove('active');
|
||||
this.reloadContentTree();
|
||||
});
|
||||
|
||||
// Source selection
|
||||
this.contentSourceSelect?.addEventListener('change', () => this.reloadContentTree());
|
||||
|
||||
// Show All / Hide All buttons
|
||||
document.getElementById('content-show-all')?.addEventListener('click', () => this.setAllVisibility(true));
|
||||
document.getElementById('content-hide-all')?.addEventListener('click', () => this.setAllVisibility(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload content tree based on current type and source
|
||||
*/
|
||||
reloadContentTree() {
|
||||
const sourceId = this.contentSourceSelect?.value;
|
||||
if (!sourceId) {
|
||||
const typeLabel = this.contentType === 'movies' ? 'movie categories' :
|
||||
this.contentType === 'series' ? 'series categories' : 'groups and channels';
|
||||
this.contentTree.innerHTML = `<p class="hint">Select a source to view ${typeLabel}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contentType === 'movies') {
|
||||
this.loadMovieCategoriesTree(parseInt(sourceId));
|
||||
} else if (this.contentType === 'series') {
|
||||
this.loadSeriesCategoriesTree(parseInt(sourceId));
|
||||
} else {
|
||||
this.loadContentTree(parseInt(sourceId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sources into content browser dropdown
|
||||
*/
|
||||
async loadContentSources() {
|
||||
try {
|
||||
const sources = await API.sources.getAll();
|
||||
const select = document.getElementById('content-source-select');
|
||||
if (!select) return;
|
||||
|
||||
// Keep the placeholder option
|
||||
select.innerHTML = '<option value="">Select a source...</option>';
|
||||
|
||||
sources.filter(s => s.type === 'xtream' || s.type === 'm3u').forEach(source => {
|
||||
select.innerHTML += `<option value="${source.id}">${source.name} (${source.type})</option>`;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading content sources:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load content tree for a source
|
||||
* Checked = Visible, Unchecked = Hidden
|
||||
*/
|
||||
async loadContentTree(sourceId) {
|
||||
this.contentTree.innerHTML = '<p class="hint">Loading...</p>';
|
||||
|
||||
try {
|
||||
const source = await API.sources.getById(sourceId);
|
||||
let channels = [];
|
||||
|
||||
let categoryMap = {};
|
||||
|
||||
if (source.type === 'xtream') {
|
||||
// Run sequentially to avoid overwhelming the provider
|
||||
const categories = await API.proxy.xtream.liveCategories(sourceId);
|
||||
const streams = await API.proxy.xtream.liveStreams(sourceId);
|
||||
|
||||
channels = streams;
|
||||
// Create map of category_id -> category_name
|
||||
categories.forEach(cat => {
|
||||
categoryMap[cat.category_id] = cat.category_name;
|
||||
});
|
||||
} else if (source.type === 'm3u') {
|
||||
const m3uData = await API.proxy.m3u.get(sourceId);
|
||||
channels = m3uData.channels || [];
|
||||
}
|
||||
|
||||
// Get currently hidden items
|
||||
const hiddenItems = await API.channels.getHidden(sourceId);
|
||||
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
|
||||
|
||||
// Group channels
|
||||
const groups = {};
|
||||
channels.forEach(ch => {
|
||||
let groupName = 'Uncategorized';
|
||||
|
||||
if (source.type === 'xtream') {
|
||||
// Use category map for Xtream
|
||||
if (ch.category_id && categoryMap[ch.category_id]) {
|
||||
groupName = categoryMap[ch.category_id];
|
||||
}
|
||||
} else {
|
||||
// Use existing fields for M3U
|
||||
groupName = ch.category_name || ch.groupTitle || 'Uncategorized';
|
||||
}
|
||||
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(ch);
|
||||
});
|
||||
|
||||
// Render tree - checked = visible, unchecked = hidden
|
||||
let html = '';
|
||||
Object.keys(groups).sort().forEach(groupName => {
|
||||
const groupHidden = hiddenSet.has(`group:${groupName}`);
|
||||
const groupChannels = groups[groupName];
|
||||
|
||||
html += `
|
||||
<div class="content-group collapsed" data-group="${groupName}">
|
||||
<div class="content-group-header">
|
||||
<span class="group-expander">▼</span>
|
||||
<label class="checkbox-label" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="group-checkbox" data-type="group" data-id="${groupName}" data-source-id="${sourceId}" ${groupHidden ? '' : 'checked'}>
|
||||
<span class="group-name">${groupName} (${groupChannels.length})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="content-channels">
|
||||
${groupChannels.map(ch => {
|
||||
const channelId = ch.stream_id || ch.id || ch.url;
|
||||
const channelName = ch.name || ch.tvgName || 'Unknown';
|
||||
const channelHidden = hiddenSet.has(`channel:${channelId}`);
|
||||
return `
|
||||
<label class="checkbox-label channel-item" title="${channelName}">
|
||||
<input type="checkbox" class="channel-checkbox" data-type="channel" data-id="${channelId}" data-source-id="${sourceId}" ${channelHidden ? '' : 'checked'}>
|
||||
<span class="channel-name">${channelName}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
this.contentTree.innerHTML = html || '<p class="hint">No channels found</p>';
|
||||
|
||||
// Toggle group collapse on header click
|
||||
this.contentTree.querySelectorAll('.content-group-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const group = header.closest('.content-group');
|
||||
group.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Attach change listeners for visibility toggling
|
||||
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => this.toggleVisibility(cb));
|
||||
});
|
||||
|
||||
// Group checkbox toggles all children (BULK)
|
||||
this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => {
|
||||
groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading content tree:', err);
|
||||
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading content</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load movie categories tree for a source
|
||||
* Checked = Visible, Unchecked = Hidden
|
||||
*/
|
||||
async loadMovieCategoriesTree(sourceId) {
|
||||
this.contentTree.innerHTML = '<p class="hint">Loading movie categories...</p>';
|
||||
|
||||
try {
|
||||
const source = await API.sources.getById(sourceId);
|
||||
|
||||
if (source.type !== 'xtream') {
|
||||
this.contentTree.innerHTML = '<p class="hint">Movie categories are only available for Xtream sources</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch VOD categories
|
||||
const categories = await API.proxy.xtream.vodCategories(sourceId);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
this.contentTree.innerHTML = '<p class="hint">No movie categories found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get currently hidden items
|
||||
const hiddenItems = await API.channels.getHidden(sourceId);
|
||||
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
|
||||
|
||||
// Render categories - checked = visible, unchecked = hidden
|
||||
let html = '<div class="content-categories">';
|
||||
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
|
||||
const isHidden = hiddenSet.has(`vod_category:${cat.category_id}`);
|
||||
html += `
|
||||
<label class="checkbox-label category-item">
|
||||
<input type="checkbox" class="category-checkbox"
|
||||
data-type="vod_category"
|
||||
data-id="${cat.category_id}"
|
||||
data-source-id="${sourceId}"
|
||||
${isHidden ? '' : 'checked'}>
|
||||
<span class="category-name">${cat.category_name}</span>
|
||||
</label>`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
this.contentTree.innerHTML = html;
|
||||
|
||||
// Attach change listeners for visibility toggling
|
||||
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => this.toggleVisibility(cb));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading movie categories:', err);
|
||||
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading movie categories</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load series categories tree for a source
|
||||
* Checked = Visible, Unchecked = Hidden
|
||||
*/
|
||||
async loadSeriesCategoriesTree(sourceId) {
|
||||
this.contentTree.innerHTML = '<p class="hint">Loading series categories...</p>';
|
||||
|
||||
try {
|
||||
const source = await API.sources.getById(sourceId);
|
||||
|
||||
if (source.type !== 'xtream') {
|
||||
this.contentTree.innerHTML = '<p class="hint">Series categories are only available for Xtream sources</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch series categories
|
||||
const categories = await API.proxy.xtream.seriesCategories(sourceId);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
this.contentTree.innerHTML = '<p class="hint">No series categories found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get currently hidden items
|
||||
const hiddenItems = await API.channels.getHidden(sourceId);
|
||||
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
|
||||
|
||||
// Render categories - checked = visible, unchecked = hidden
|
||||
let html = '<div class="content-categories">';
|
||||
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
|
||||
const isHidden = hiddenSet.has(`series_category:${cat.category_id}`);
|
||||
html += `
|
||||
<label class="checkbox-label category-item">
|
||||
<input type="checkbox" class="category-checkbox"
|
||||
data-type="series_category"
|
||||
data-id="${cat.category_id}"
|
||||
data-source-id="${sourceId}"
|
||||
${isHidden ? '' : 'checked'}>
|
||||
<span class="category-name">${cat.category_name}</span>
|
||||
</label>`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
this.contentTree.innerHTML = html;
|
||||
|
||||
// Attach change listeners for visibility toggling
|
||||
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => this.toggleVisibility(cb));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading series categories:', err);
|
||||
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading series categories</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of a single item
|
||||
* Checked = show (remove from hidden), Unchecked = hide (add to hidden)
|
||||
*/
|
||||
async toggleVisibility(checkbox) {
|
||||
const sourceId = parseInt(checkbox.dataset.sourceId);
|
||||
const itemType = checkbox.dataset.type;
|
||||
const itemId = checkbox.dataset.id;
|
||||
const isVisible = checkbox.checked;
|
||||
|
||||
try {
|
||||
// Fire API call (don't await for faster UI response)
|
||||
const apiCall = isVisible
|
||||
? API.channels.show(sourceId, itemType, itemId)
|
||||
: API.channels.hide(sourceId, itemType, itemId);
|
||||
|
||||
// For VOD categories, don't refresh channel list (just let API complete in background)
|
||||
if (itemType === 'vod_category' || itemType === 'series_category') {
|
||||
apiCall.catch(err => {
|
||||
console.error(`Error toggling ${itemType} visibility:`, err);
|
||||
checkbox.checked = !isVisible; // Revert on error
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For channels/groups, await and refresh
|
||||
await apiCall;
|
||||
|
||||
// Refresh channel list if visible
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadHiddenItems();
|
||||
window.app.channelList.render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling visibility:', err);
|
||||
// Revert checkbox on error
|
||||
checkbox.checked = !isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all children of a group efficiently
|
||||
*/
|
||||
async toggleGroupChildren(groupCb) {
|
||||
const group = groupCb.closest('.content-group');
|
||||
const channelCheckboxes = group.querySelectorAll('.channel-checkbox');
|
||||
const isChecked = groupCb.checked;
|
||||
const itemsToUpdate = [];
|
||||
|
||||
// Identify items that need changing
|
||||
channelCheckboxes.forEach(chCb => {
|
||||
if (chCb.checked !== isChecked) {
|
||||
chCb.checked = isChecked;
|
||||
itemsToUpdate.push({
|
||||
sourceId: parseInt(chCb.dataset.sourceId),
|
||||
itemType: chCb.dataset.type,
|
||||
itemId: chCb.dataset.id
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (itemsToUpdate.length === 0) return;
|
||||
|
||||
try {
|
||||
if (isChecked) {
|
||||
await API.channels.bulkShow(itemsToUpdate);
|
||||
} else {
|
||||
await API.channels.bulkHide(itemsToUpdate);
|
||||
}
|
||||
|
||||
// Refresh channel list
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadHiddenItems();
|
||||
window.app.channelList.render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling group children:', err);
|
||||
// We might want to revert UI here, but it's complex for bulk items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set visibility for all items
|
||||
*/
|
||||
async setAllVisibility(visible) {
|
||||
const sourceId = this.contentSourceSelect?.value;
|
||||
if (!sourceId) return;
|
||||
|
||||
const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]');
|
||||
const items = [];
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb.checked !== visible) {
|
||||
items.push({
|
||||
sourceId: parseInt(cb.dataset.sourceId),
|
||||
itemType: cb.dataset.type,
|
||||
itemId: cb.dataset.id
|
||||
});
|
||||
cb.checked = visible;
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
try {
|
||||
if (visible) {
|
||||
// Show all - use bulk API
|
||||
await API.channels.bulkShow(items);
|
||||
} else {
|
||||
// Hide all - use bulk API
|
||||
await API.channels.bulkHide(items);
|
||||
}
|
||||
|
||||
// Refresh channel list
|
||||
if (window.app?.channelList) {
|
||||
await window.app.channelList.loadHiddenItems();
|
||||
window.app.channelList.render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error setting all visibility:', err);
|
||||
alert('Failed to update visibility');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export
|
||||
window.SourceManager = SourceManager;
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Video Player Component
|
||||
* Handles HLS video playback with custom controls
|
||||
*/
|
||||
|
||||
class VideoPlayer {
|
||||
constructor() {
|
||||
this.video = document.getElementById('video-player');
|
||||
this.overlay = document.getElementById('player-overlay');
|
||||
this.nowPlaying = document.getElementById('now-playing');
|
||||
this.hls = null;
|
||||
this.currentChannel = null;
|
||||
this.overlayTimer = null;
|
||||
this.overlayDuration = 5000; // 5 seconds
|
||||
this.isUsingProxy = false;
|
||||
this.currentUrl = null;
|
||||
|
||||
// Settings (loaded from localStorage)
|
||||
this.settings = this.loadSettings();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
loadSettings() {
|
||||
const defaults = {
|
||||
arrowKeysChangeChannel: true, // Use arrow keys for channel navigation
|
||||
overlayDuration: 5, // Seconds
|
||||
defaultVolume: 80, // 0-100
|
||||
rememberVolume: true, // Persist volume between sessions
|
||||
lastVolume: 80, // Last used volume (if rememberVolume is true)
|
||||
autoPlayNextEpisode: false // Auto-play next episode in series
|
||||
};
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_player_settings');
|
||||
if (saved) {
|
||||
return { ...defaults, ...JSON.parse(saved) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to localStorage
|
||||
*/
|
||||
saveSettings() {
|
||||
try {
|
||||
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Apply default/remembered volume
|
||||
const volume = this.settings.rememberVolume ? this.settings.lastVolume : this.settings.defaultVolume;
|
||||
this.video.volume = volume / 100;
|
||||
|
||||
// Save volume changes
|
||||
this.video.addEventListener('volumechange', () => {
|
||||
if (this.settings.rememberVolume) {
|
||||
this.settings.lastVolume = Math.round(this.video.volume * 100);
|
||||
this.saveSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize HLS.js if supported
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS error:', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
// If it's a network error and we haven't tried the proxy yet, try it
|
||||
if (this.currentChannel && !this.isUsingProxy) {
|
||||
console.log('Network error detected, trying via proxy...');
|
||||
const proxiedUrl = this.getProxiedUrl(this.currentUrl);
|
||||
this.isUsingProxy = true;
|
||||
this.hls.loadSource(proxiedUrl);
|
||||
this.hls.startLoad();
|
||||
} else {
|
||||
console.log('Network error, attempting recovery...');
|
||||
this.hls.startLoad();
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log('Media error, attempting recovery...');
|
||||
this.hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
||||
|
||||
// Click on video shows overlay
|
||||
this.video.addEventListener('click', () => this.showNowPlayingOverlay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the now playing overlay briefly
|
||||
*/
|
||||
showNowPlayingOverlay() {
|
||||
if (!this.currentChannel) return;
|
||||
|
||||
// Clear existing timer
|
||||
if (this.overlayTimer) {
|
||||
clearTimeout(this.overlayTimer);
|
||||
}
|
||||
|
||||
// Show overlay
|
||||
this.nowPlaying.classList.remove('hidden');
|
||||
|
||||
// Hide after duration
|
||||
this.overlayTimer = setTimeout(() => {
|
||||
this.nowPlaying.classList.add('hidden');
|
||||
}, this.settings.overlayDuration * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the now playing overlay
|
||||
*/
|
||||
hideNowPlayingOverlay() {
|
||||
if (this.overlayTimer) {
|
||||
clearTimeout(this.overlayTimer);
|
||||
}
|
||||
this.nowPlaying.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a channel
|
||||
*/
|
||||
async play(channel, streamUrl) {
|
||||
this.currentChannel = channel;
|
||||
|
||||
try {
|
||||
// Stop current playback
|
||||
this.stop();
|
||||
|
||||
// Hide "select a channel" overlay
|
||||
this.overlay.classList.add('hidden');
|
||||
|
||||
// Determine if HLS or direct stream
|
||||
this.currentUrl = streamUrl;
|
||||
|
||||
// Proactively use proxy for known CORS-restricted domains (like Pluto TV)
|
||||
const proxyRequiredDomains = ['pluto.tv'];
|
||||
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
|
||||
|
||||
this.isUsingProxy = needsProxy;
|
||||
const finalUrl = needsProxy ? this.getProxiedUrl(streamUrl) : streamUrl;
|
||||
|
||||
if (finalUrl.includes('.m3u8') && Hls.isSupported()) {
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(finalUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
});
|
||||
|
||||
// Re-attach error handler for the new Hls instance
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) {
|
||||
console.log('CORS/Network error detected, retrying via proxy...');
|
||||
this.isUsingProxy = true;
|
||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||
this.hls.startLoad();
|
||||
} else {
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS support (Safari)
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
console.log('Autoplay prevented, trying proxy if CORS error:', e);
|
||||
if (!this.isUsingProxy) {
|
||||
this.isUsingProxy = true;
|
||||
this.video.src = this.getProxiedUrl(streamUrl);
|
||||
this.video.play().catch(err => console.error('Proxy play failed:', err));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Try direct playback
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
}
|
||||
|
||||
// Update now playing info
|
||||
this.updateNowPlaying(channel);
|
||||
|
||||
// Show the now playing overlay
|
||||
this.showNowPlayingOverlay();
|
||||
|
||||
// Fetch EPG data for this channel
|
||||
this.fetchEpgData(channel);
|
||||
|
||||
// Dispatch event
|
||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error playing channel:', err);
|
||||
this.showError('Failed to play channel');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch EPG data for current channel
|
||||
*/
|
||||
async fetchEpgData(channel) {
|
||||
try {
|
||||
// First, try to use the centralized EpgGuide data (already loaded)
|
||||
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
|
||||
const epgGuide = window.app.epgGuide;
|
||||
|
||||
// Get current program from EpgGuide
|
||||
const currentProgram = epgGuide.getCurrentProgram(channel.tvgId, channel.name);
|
||||
|
||||
if (currentProgram) {
|
||||
// Find upcoming programs from the guide's data
|
||||
const epgChannel = epgGuide.channelMap?.get(channel.tvgId) ||
|
||||
epgGuide.channelMap?.get(channel.name?.toLowerCase());
|
||||
|
||||
let upcoming = [];
|
||||
if (epgChannel) {
|
||||
const now = Date.now();
|
||||
upcoming = epgGuide.programmes
|
||||
.filter(p => p.channelId === epgChannel.id && new Date(p.start).getTime() > now)
|
||||
.slice(0, 5)
|
||||
.map(p => ({
|
||||
title: p.title,
|
||||
start: new Date(p.start),
|
||||
stop: new Date(p.stop),
|
||||
description: p.desc || ''
|
||||
}));
|
||||
}
|
||||
|
||||
this.updateNowPlaying(channel, {
|
||||
current: {
|
||||
title: currentProgram.title,
|
||||
start: new Date(currentProgram.start),
|
||||
stop: new Date(currentProgram.stop),
|
||||
description: currentProgram.desc || ''
|
||||
},
|
||||
upcoming
|
||||
});
|
||||
return; // Success, exit early
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to get EPG from Xtream API if available
|
||||
if (channel.sourceType === 'xtream' && channel.streamId) {
|
||||
const epgData = await API.proxy.xtream.shortEpg(channel.sourceId, channel.streamId);
|
||||
if (epgData && epgData.epg_listings && epgData.epg_listings.length > 0) {
|
||||
const listings = epgData.epg_listings;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Find current program
|
||||
const current = listings.find(p => {
|
||||
const start = parseInt(p.start_timestamp);
|
||||
const end = parseInt(p.stop_timestamp);
|
||||
return start <= now && end > now;
|
||||
});
|
||||
|
||||
// Get upcoming programs
|
||||
const upcoming = listings
|
||||
.filter(p => parseInt(p.start_timestamp) > now)
|
||||
.slice(0, 5)
|
||||
.map(p => ({
|
||||
title: this.decodeBase64(p.title),
|
||||
start: new Date(parseInt(p.start_timestamp) * 1000),
|
||||
stop: new Date(parseInt(p.stop_timestamp) * 1000),
|
||||
description: this.decodeBase64(p.description)
|
||||
}));
|
||||
|
||||
if (current) {
|
||||
this.updateNowPlaying(channel, {
|
||||
current: {
|
||||
title: this.decodeBase64(current.title),
|
||||
start: new Date(parseInt(current.start_timestamp) * 1000),
|
||||
stop: new Date(parseInt(current.stop_timestamp) * 1000),
|
||||
description: this.decodeBase64(current.description)
|
||||
},
|
||||
upcoming
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('EPG data not available:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxied URL for a stream
|
||||
*/
|
||||
getProxiedUrl(url) {
|
||||
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 EPG data
|
||||
*/
|
||||
decodeBase64(str) {
|
||||
if (!str) return '';
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(str)));
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
stop() {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
this.video.pause();
|
||||
this.video.src = '';
|
||||
this.video.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update now playing display
|
||||
*/
|
||||
updateNowPlaying(channel, epgData = null) {
|
||||
const channelName = this.nowPlaying.querySelector('.channel-name');
|
||||
const programTitle = this.nowPlaying.querySelector('.program-title');
|
||||
const programTime = this.nowPlaying.querySelector('.program-time');
|
||||
const upNextList = document.getElementById('up-next-list');
|
||||
|
||||
channelName.textContent = channel.name || channel.tvgName || 'Unknown Channel';
|
||||
|
||||
if (epgData && epgData.current) {
|
||||
programTitle.textContent = epgData.current.title;
|
||||
const start = new Date(epgData.current.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const end = new Date(epgData.current.stop).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
programTime.textContent = `${start} - ${end}`;
|
||||
} else {
|
||||
programTitle.textContent = '';
|
||||
programTime.textContent = '';
|
||||
}
|
||||
|
||||
// Update up next
|
||||
upNextList.innerHTML = '';
|
||||
if (epgData && epgData.upcoming) {
|
||||
epgData.upcoming.slice(0, 3).forEach(prog => {
|
||||
const li = document.createElement('li');
|
||||
const time = new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
li.textContent = `${time} - ${prog.title}`;
|
||||
upNextList.appendChild(li);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error overlay
|
||||
*/
|
||||
showError(message) {
|
||||
this.overlay.classList.remove('hidden');
|
||||
this.overlay.querySelector('.overlay-content').innerHTML = `<p style="color: var(--color-error);">${message}</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts
|
||||
*/
|
||||
handleKeyboard(e) {
|
||||
if (document.activeElement.tagName === 'INPUT') return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
this.video.paused ? this.video.play() : this.video.pause();
|
||||
break;
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
this.toggleFullscreen();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
this.video.muted = !this.video.muted;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (this.settings.arrowKeysChangeChannel) {
|
||||
this.channelUp();
|
||||
} else {
|
||||
this.video.volume = Math.min(1, this.video.volume + 0.1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (this.settings.arrowKeysChangeChannel) {
|
||||
this.channelDown();
|
||||
} else {
|
||||
this.video.volume = Math.max(0, this.video.volume - 0.1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Volume down when arrow keys are for channels
|
||||
if (this.settings.arrowKeysChangeChannel) {
|
||||
this.video.volume = Math.max(0, this.video.volume - 0.1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Volume up when arrow keys are for channels
|
||||
if (this.settings.arrowKeysChangeChannel) {
|
||||
this.video.volume = Math.min(1, this.video.volume + 0.1);
|
||||
}
|
||||
break;
|
||||
case 'PageUp':
|
||||
case 'ChannelUp':
|
||||
e.preventDefault();
|
||||
this.channelUp();
|
||||
break;
|
||||
case 'PageDown':
|
||||
case 'ChannelDown':
|
||||
e.preventDefault();
|
||||
this.channelDown();
|
||||
break;
|
||||
case 'i':
|
||||
// Show/hide info overlay
|
||||
e.preventDefault();
|
||||
if (this.nowPlaying.classList.contains('hidden')) {
|
||||
this.showNowPlayingOverlay();
|
||||
} else {
|
||||
this.hideNowPlayingOverlay();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous channel
|
||||
*/
|
||||
channelUp() {
|
||||
if (!window.app?.channelList) return;
|
||||
const channels = window.app.channelList.getVisibleChannels();
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
: -1;
|
||||
|
||||
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to next channel
|
||||
*/
|
||||
channelDown() {
|
||||
if (!window.app?.channelList) return;
|
||||
const channels = window.app.channelList.getVisibleChannels();
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
: -1;
|
||||
|
||||
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fullscreen
|
||||
*/
|
||||
toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
this.video.requestFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.VideoPlayer = VideoPlayer;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Guide Page Controller
|
||||
*/
|
||||
|
||||
class GuidePage {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// EPG guide will lazy load when shown
|
||||
}
|
||||
|
||||
async show() {
|
||||
// Always reload EPG data when page is shown to ensure fresh data
|
||||
await this.app.epgGuide.loadEpg();
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Page is hidden
|
||||
}
|
||||
}
|
||||
|
||||
window.GuidePage = GuidePage;
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Home Page Controller
|
||||
*/
|
||||
|
||||
class HomePage {
|
||||
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();
|
||||
// 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':
|
||||
e.preventDefault();
|
||||
this.app.channelList.selectPrevChannel();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
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.HomePage = HomePage;
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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', () => {
|
||||
this.loadCategories();
|
||||
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 ? `⭐ ${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.src='/img/placeholder.png'" loading="lazy">
|
||||
<div class="movie-play-overlay">
|
||||
<span class="play-icon">▶</span>
|
||||
</div>
|
||||
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
|
||||
<span class="fav-icon">♥</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, 'vod', container);
|
||||
|
||||
if (result && result.url) {
|
||||
// Navigate to home and play
|
||||
this.app.navigateTo('home');
|
||||
|
||||
// Play the movie
|
||||
if (this.app.player) {
|
||||
const channel = {
|
||||
id: `movie:${movie.stream_id}`,
|
||||
name: movie.name,
|
||||
tvgLogo: movie.stream_icon || movie.cover,
|
||||
sourceType: 'xtream',
|
||||
sourceId: movie.sourceId,
|
||||
isVod: true // Mark as VOD for player handling
|
||||
};
|
||||
|
||||
// For VOD, use direct URL (don't proxy through HLS)
|
||||
// The video element can play mp4/mkv directly
|
||||
this.app.player.play(channel, 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);
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (isFav) {
|
||||
this.favoriteIds.delete(favKey);
|
||||
btn.classList.remove('active');
|
||||
btn.title = 'Add to Favorites';
|
||||
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
|
||||
} else {
|
||||
this.favoriteIds.add(favKey);
|
||||
btn.classList.add('active');
|
||||
btn.title = 'Remove from Favorites';
|
||||
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');
|
||||
} else {
|
||||
this.favoriteIds.delete(favKey);
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.MoviesPage = MoviesPage;
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 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', () => {
|
||||
this.loadCategories();
|
||||
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 ? `⭐ ${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.src='/img/placeholder.png'" loading="lazy">
|
||||
<div class="series-play-overlay">
|
||||
<span class="play-icon">▶</span>
|
||||
</div>
|
||||
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
|
||||
<span class="fav-icon">♥</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;
|
||||
}
|
||||
|
||||
// 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">▼</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';
|
||||
|
||||
try {
|
||||
// Get stream URL for episode (use 'series' type)
|
||||
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
|
||||
|
||||
if (result && result.url) {
|
||||
// Navigate to home and play
|
||||
this.app.navigateTo('home');
|
||||
|
||||
// Play the episode
|
||||
if (this.app.player) {
|
||||
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || 'Episode';
|
||||
const channel = {
|
||||
id: `episode:${episodeId}`,
|
||||
name: `${this.currentSeries?.name || 'Series'} - ${episodeTitle}`,
|
||||
sourceType: 'xtream',
|
||||
sourceId: sourceId,
|
||||
isVod: true
|
||||
};
|
||||
|
||||
this.app.player.play(channel, 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);
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (isFav) {
|
||||
this.favoriteIds.delete(favKey);
|
||||
btn.classList.remove('active');
|
||||
btn.title = 'Add to Favorites';
|
||||
await API.favorites.remove(series.sourceId, series.series_id, 'series');
|
||||
} else {
|
||||
this.favoriteIds.add(favKey);
|
||||
btn.classList.add('active');
|
||||
btn.title = 'Remove from Favorites';
|
||||
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');
|
||||
} else {
|
||||
this.favoriteIds.delete(favKey);
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.SeriesPage = SeriesPage;
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const value = Math.min(30, Math.max(1, parseInt(overlayDurationInput.value) || 5));
|
||||
overlayDurationInput.value = value;
|
||||
this.app.player.settings.overlayDuration = value;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
// Default volume slider
|
||||
defaultVolumeSlider?.addEventListener('input', () => {
|
||||
const value = parseInt(defaultVolumeSlider.value);
|
||||
volumeValueDisplay.textContent = value + '%';
|
||||
this.app.player.settings.defaultVolume = 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) {
|
||||
// Load saved value
|
||||
const savedInterval = localStorage.getItem('nodecast_tv_epg_refresh_interval');
|
||||
if (savedInterval) {
|
||||
epgRefreshSelect.value = savedInterval;
|
||||
}
|
||||
|
||||
// Save on change
|
||||
epgRefreshSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('nodecast_tv_epg_refresh_interval', epgRefreshSelect.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async show() {
|
||||
// Load sources when page is shown
|
||||
await this.app.sourceManager.loadSources();
|
||||
|
||||
// Refresh player settings display
|
||||
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
|
||||
const overlayDurationInput = document.getElementById('setting-overlay-duration');
|
||||
if (this.app.player?.settings) {
|
||||
arrowKeysToggle.checked = this.app.player.settings.arrowKeysChangeChannel;
|
||||
overlayDurationInput.value = this.app.player.settings.overlayDuration;
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Page is hidden
|
||||
}
|
||||
}
|
||||
|
||||
window.SettingsPage = SettingsPage;
|
||||
Reference in New Issue
Block a user