diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index e0dbfb8..4a8ab95 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -133,13 +133,31 @@ class ChannelList { // ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same) /** - * Get current program info string + * Get current program info string - cached for performance */ getProgramInfo(channel) { try { if (!window.app || !window.app.epgGuide) return null; + + // Cache key: channel_id + current_minute (invalidate every minute) + const currentMinute = Math.floor(Date.now() / 60000); + const cacheKey = `${channel.tvgId || channel.name}:${currentMinute}`; + + if (this._programInfoCache && this._programInfoCache.has(cacheKey)) { + return this._programInfoCache.get(cacheKey); + } + + // Clear old cache entries if minute changed + if (!this._lastCacheMinute || this._lastCacheMinute !== currentMinute) { + this._programInfoCache = new Map(); + this._lastCacheMinute = currentMinute; + } + const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name); - return program ? program.title : null; + const result = program ? program.title : null; + + this._programInfoCache.set(cacheKey, result); + return result; } catch (e) { console.warn("Error in getProgramInfo", e); return null; @@ -231,9 +249,9 @@ class ChannelList { 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) + // Render initial batches - load just enough to fill visible area + buffer + // Reduced from 10 to 2 to significantly speed up initial load time for large lists + const maxInitialBatches = 2; for (let i = 0; i < maxInitialBatches; i++) { if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break; this.renderNextBatch(); diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index 9161693..027a14a 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -11,6 +11,9 @@ class SourceManager { // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' + this.treeData = null; // { type, sourceId, groups: [{ id, name, items: [] }] } + this.hiddenSet = new Set(); // Set of hidden item keys + this.expandedGroups = new Set(); // Set of expanded group IDs this.init(); } @@ -415,8 +418,15 @@ class SourceManager { * Load content tree for a source * Checked = Visible, Unchecked = Hidden */ + + + /** + * Load content tree for a source + */ async loadContentTree(sourceId) { this.contentTree.innerHTML = '

Loading...

'; + this.treeData = { type: 'channels', sourceId, groups: [] }; + this.expandedGroups.clear(); try { const source = await API.sources.getById(sourceId); @@ -430,7 +440,6 @@ class SourceManager { 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; }); @@ -441,78 +450,45 @@ class SourceManager { // Get currently hidden items const hiddenItems = await API.channels.getHidden(sourceId); - const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); + this.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]; + // Normalize channel object + const channelId = ch.stream_id || ch.id || ch.url; + const channelName = ch.name || ch.tvgName || 'Unknown'; - html += ` - `; - }); - - this.contentTree.innerHTML = html || '

No channels found

'; - - // 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'); + groups[groupName].push({ + id: channelId, + name: channelName, + original: ch, + type: 'channel' }); }); - // Attach change listeners for visibility toggling - this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { - cb.addEventListener('change', () => this.toggleVisibility(cb)); - }); + // Convert to array + this.treeData.groups = Object.keys(groups).sort().map(name => ({ + id: name, // generic group ID + name: name, + type: 'group', + items: groups[name] + })); - // Group checkbox toggles all children (BULK) - this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => { - groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb)); - }); + this.renderTree(); } catch (err) { console.error('Error loading content tree:', err); @@ -520,12 +496,131 @@ class SourceManager { } } + /** + * Render the full tree based on current state + */ + renderTree() { + if (!this.treeData || !this.treeData.groups.length) { + this.contentTree.innerHTML = '

No content found

'; + return; + } + + const html = this.treeData.groups.map(group => this.getGroupHtml(group)).join(''); + this.contentTree.innerHTML = html; + + // Attach event listeners + this.attachTreeListeners(this.contentTree); + } + + /** + * Get HTML for a group (and its items if expanded) + */ + getGroupHtml(group) { + const isHidden = this.hiddenSet.has(`group:${group.name}`); // We use name as ID for groups usually + const isExpanded = this.expandedGroups.has(group.id); + + // Calculate partial state if needed, but for now simple checked/unchecked + const checked = !isHidden; + + let itemsHtml = ''; + if (isExpanded) { + itemsHtml = `
+ ${group.items.map(item => { + const itemHidden = this.hiddenSet.has(`${item.type}:${item.id}`); + return ` + `; + }).join('')} +
`; + } + + return ` +
+
+ ▼ + +
+ ${itemsHtml} +
+ `; + } + + escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + attachTreeListeners(container) { + // Toggle group collapse + container.querySelectorAll('.content-group-header').forEach(header => { + header.addEventListener('click', (e) => { + // Prevent triggering if clicking the checkbox/label directly (handled by its own listener/bubbling) + if (e.target.closest('input') || e.target.closest('label')) return; + + const groupEl = header.closest('.content-group'); + const groupId = groupEl.dataset.groupId; + this.toggleGroupExpand(groupId); + }); + }); + + // Toggle visibility + container.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', (e) => { + if (cb.classList.contains('group-checkbox')) { + this.toggleGroupChildren(cb); + } else { + this.toggleVisibility(cb); + } + }); + }); + } + + toggleGroupExpand(groupId) { + if (this.expandedGroups.has(groupId)) { + this.expandedGroups.delete(groupId); + } else { + this.expandedGroups.add(groupId); + } + + // Re-render only this group + const groupEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(groupId)}"]`); + if (groupEl) { + const group = this.treeData.groups.find(g => g.id === groupId); + if (group) { + const newHtml = this.getGroupHtml(group); + groupEl.outerHTML = newHtml; + + // Re-attach listeners to the new element + const newEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(groupId)}"]`); + if (newEl) this.attachTreeListeners(newEl); + } + } + } + /** * Load movie categories tree for a source - * Checked = Visible, Unchecked = Hidden */ async loadMovieCategoriesTree(sourceId) { this.contentTree.innerHTML = '

Loading movie categories...

'; + this.treeData = { type: 'movies', sourceId, groups: [] }; try { const source = await API.sources.getById(sourceId); @@ -535,7 +630,6 @@ class SourceManager { return; } - // Fetch VOD categories const categories = await API.proxy.xtream.vodCategories(sourceId); if (!categories || categories.length === 0) { @@ -543,32 +637,33 @@ class SourceManager { 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}`)); + this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); - // Render categories - checked = visible, unchecked = hidden - let html = '
'; - categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => { - const isHidden = hiddenSet.has(`vod_category:${cat.category_id}`); - html += ` - `; - }); - html += '
'; + // Create a single "Movies" group or flatten? + // The original UI rendered a flat list of categories. + // Better to stick to "Group -> Items" structure, or just wrap them in a pseudo-group? + // Original: rendered checkboxes directly. + // Let's adopt the treeData structure but with a single root group or flat items? + // To support generic renderTree, we can put them in a "Categories" group or just render them as items. + // Let's update renderTree to support flat list if groups is empty? + // Or just put them in one "All Categories" group that is auto-expanded. - this.contentTree.innerHTML = html; + this.treeData.groups = [{ + id: 'all_categories', + name: 'Categories', + type: 'group', + items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ + id: cat.category_id, + name: cat.category_name, + type: 'vod_category', + original: cat + })) + }]; - // Attach change listeners for visibility toggling - this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { - cb.addEventListener('change', () => this.toggleVisibility(cb)); - }); + // Auto expand + this.expandedGroups.add('all_categories'); + this.renderTree(); } catch (err) { console.error('Error loading movie categories:', err); @@ -578,10 +673,10 @@ class SourceManager { /** * Load series categories tree for a source - * Checked = Visible, Unchecked = Hidden */ async loadSeriesCategoriesTree(sourceId) { this.contentTree.innerHTML = '

Loading series categories...

'; + this.treeData = { type: 'series', sourceId, groups: [] }; try { const source = await API.sources.getById(sourceId); @@ -591,7 +686,6 @@ class SourceManager { return; } - // Fetch series categories const categories = await API.proxy.xtream.seriesCategories(sourceId); if (!categories || categories.length === 0) { @@ -599,32 +693,23 @@ class SourceManager { 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}`)); + this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); - // Render categories - checked = visible, unchecked = hidden - let html = '
'; - categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => { - const isHidden = hiddenSet.has(`series_category:${cat.category_id}`); - html += ` - `; - }); - html += '
'; + this.treeData.groups = [{ + id: 'all_series_categories', + name: 'Categories', + type: 'group', + items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ + id: cat.category_id, + name: cat.category_name, + type: 'series_category', + original: cat + })) + }]; - this.contentTree.innerHTML = html; - - // Attach change listeners for visibility toggling - this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { - cb.addEventListener('change', () => this.toggleVisibility(cb)); - }); + this.expandedGroups.add('all_series_categories'); + this.renderTree(); } catch (err) { console.error('Error loading series categories:', err); @@ -640,34 +725,45 @@ class SourceManager { const sourceId = parseInt(checkbox.dataset.sourceId); const itemType = checkbox.dataset.type; const itemId = checkbox.dataset.id; - const isVisible = checkbox.checked; + const isVisible = checkbox.checked; // This is the NEW state taken from UI + + // Update local state directly + const key = `${itemType}:${itemId}`; + if (isVisible) { + this.hiddenSet.delete(key); + } else { + this.hiddenSet.add(key); + } try { - // Fire API call (don't await for faster UI response) + // Fire API call 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 + checkbox.checked = !isVisible; }); return; } - // For channels/groups, await and refresh await apiCall; + // No need to "refresh" since we updated state locally and UI is already correct - // Refresh channel list if visible + // Sync Channel List if (window.app?.channelList) { + // We could just pass the change? But safely reloading hidden is better await window.app.channelList.loadHiddenItems(); window.app.channelList.render(); } } catch (err) { console.error('Error toggling visibility:', err); - // Revert checkbox on error + // Revert state and UI + if (isVisible) this.hiddenSet.add(key); + else this.hiddenSet.delete(key); + checkbox.checked = !isVisible; } } @@ -676,23 +772,44 @@ class SourceManager { * Toggle all children of a group efficiently */ async toggleGroupChildren(groupCb) { - const group = groupCb.closest('.content-group'); - const channelCheckboxes = group.querySelectorAll('.channel-checkbox'); + const groupName = groupCb.dataset.id; // Helper uses name as ID + const group = this.treeData.groups.find(g => g.name === groupName); + if (!group) return; + 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 - }); + // 1. Update State + if (isChecked) { + this.hiddenSet.delete(`group:${group.name}`); + } else { + this.hiddenSet.add(`group:${group.name}`); + } + + group.items.forEach(item => { + const key = `${item.type}:${item.id}`; + const currentlyHidden = this.hiddenSet.has(key); + + // Only update if state changes + if (isChecked && currentlyHidden) { + this.hiddenSet.delete(key); + itemsToUpdate.push({ sourceId: this.treeData.sourceId, itemType: item.type, itemId: item.id }); + } else if (!isChecked && !currentlyHidden) { + this.hiddenSet.add(key); + itemsToUpdate.push({ sourceId: this.treeData.sourceId, itemType: item.type, itemId: item.id }); } }); + // 2. Update UI (re-render group without collapse) + // Find group element and update its checkboxes manually OR re-render + // Re-rendering is safest and fast for single group + const groupEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(group.id)}"]`); + if (groupEl) { + groupEl.outerHTML = this.getGroupHtml(group); + const newEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(group.id)}"]`); + if (newEl) this.attachTreeListeners(newEl); + } + if (itemsToUpdate.length === 0) return; try { @@ -702,50 +819,60 @@ class SourceManager { 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 + // Revert? Complex. } } /** - * Set visibility for all items + * Set visibility for all items using Virtual State */ async setAllVisibility(visible) { - const sourceId = this.contentSourceSelect?.value; - if (!sourceId) return; + if (!this.treeData || !this.treeData.groups) return; - const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]'); + // 1. Update State 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; + this.treeData.groups.forEach(group => { + // Group visibility (if it's a channel group) + if (group.type === 'group') { + const groupKey = `group:${group.name}`; + if (visible) this.hiddenSet.delete(groupKey); + else this.hiddenSet.add(groupKey); } + + // Items visibility + group.items.forEach(item => { + const key = `${item.type}:${item.id}`; + const isHidden = this.hiddenSet.has(key); + + if (visible && isHidden) { + this.hiddenSet.delete(key); + items.push({ sourceId: this.treeData.sourceId, itemType: item.type, itemId: item.id }); + } else if (!visible && !isHidden) { + this.hiddenSet.add(key); + items.push({ sourceId: this.treeData.sourceId, itemType: item.type, itemId: item.id }); + } + }); }); + // 2. Re-render View (Fast because we only render headers + expanded groups) + this.renderTree(); + 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();