Initial commit
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user