Reduce batch size for channel list, memoize EPG, virtualized source manager

This commit is contained in:
Trevor Mears
2025-12-28 15:43:45 -08:00
parent 3f4fd49023
commit 4e398bbe38
2 changed files with 285 additions and 140 deletions
+23 -5
View File
@@ -133,13 +133,31 @@ class ChannelList {
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same) // ... (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) { getProgramInfo(channel) {
try { try {
if (!window.app || !window.app.epgGuide) return null; 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); 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) { } catch (e) {
console.warn("Error in getProgramInfo", e); console.warn("Error in getProgramInfo", e);
return null; return null;
@@ -231,9 +249,9 @@ class ChannelList {
this.loader.style.opacity = '0'; // Hide initially this.loader.style.opacity = '0'; // Hide initially
this.container.appendChild(this.loader); this.container.appendChild(this.loader);
// Render initial batches - load enough to create scrollable content // Render initial batches - load just enough to fill visible area + buffer
// With many hidden groups, we may need multiple batches to get enough visible groups // Reduced from 10 to 2 to significantly speed up initial load time for large lists
const maxInitialBatches = 10; // Load up to 200 groups (10 batches x 20 groups) const maxInitialBatches = 2;
for (let i = 0; i < maxInitialBatches; i++) { for (let i = 0; i < maxInitialBatches; i++) {
if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break; if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break;
this.renderNextBatch(); this.renderNextBatch();
+262 -135
View File
@@ -11,6 +11,9 @@ class SourceManager {
// Content browser state // Content browser state
this.contentType = 'channels'; // 'channels' or 'movies' 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(); this.init();
} }
@@ -415,8 +418,15 @@ class SourceManager {
* Load content tree for a source * Load content tree for a source
* Checked = Visible, Unchecked = Hidden * Checked = Visible, Unchecked = Hidden
*/ */
/**
* Load content tree for a source
*/
async loadContentTree(sourceId) { async loadContentTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading...</p>'; this.contentTree.innerHTML = '<p class="hint">Loading...</p>';
this.treeData = { type: 'channels', sourceId, groups: [] };
this.expandedGroups.clear();
try { try {
const source = await API.sources.getById(sourceId); const source = await API.sources.getById(sourceId);
@@ -430,7 +440,6 @@ class SourceManager {
const streams = await API.proxy.xtream.liveStreams(sourceId); const streams = await API.proxy.xtream.liveStreams(sourceId);
channels = streams; channels = streams;
// Create map of category_id -> category_name
categories.forEach(cat => { categories.forEach(cat => {
categoryMap[cat.category_id] = cat.category_name; categoryMap[cat.category_id] = cat.category_name;
}); });
@@ -441,78 +450,45 @@ class SourceManager {
// Get currently hidden items // Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId); 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 // Group channels
const groups = {}; const groups = {};
channels.forEach(ch => { channels.forEach(ch => {
let groupName = 'Uncategorized'; let groupName = 'Uncategorized';
if (source.type === 'xtream') { if (source.type === 'xtream') {
// Use category map for Xtream
if (ch.category_id && categoryMap[ch.category_id]) { if (ch.category_id && categoryMap[ch.category_id]) {
groupName = categoryMap[ch.category_id]; groupName = categoryMap[ch.category_id];
} }
} else { } else {
// Use existing fields for M3U
groupName = ch.category_name || ch.groupTitle || 'Uncategorized'; groupName = ch.category_name || ch.groupTitle || 'Uncategorized';
} }
if (!groups[groupName]) { if (!groups[groupName]) {
groups[groupName] = []; groups[groupName] = [];
} }
groups[groupName].push(ch);
});
// Render tree - checked = visible, unchecked = hidden // Normalize channel object
let html = ''; const channelId = ch.stream_id || ch.id || ch.url;
Object.keys(groups).sort().forEach(groupName => { const channelName = ch.name || ch.tvgName || 'Unknown';
const groupHidden = hiddenSet.has(`group:${groupName}`);
const groupChannels = groups[groupName];
html += ` groups[groupName].push({
<div class="content-group collapsed" data-group="${groupName}"> id: channelId,
<div class="content-group-header"> name: channelName,
<span class="group-expander">▼</span> original: ch,
<label class="checkbox-label" onclick="event.stopPropagation()"> type: 'channel'
<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 // Convert to array
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { this.treeData.groups = Object.keys(groups).sort().map(name => ({
cb.addEventListener('change', () => this.toggleVisibility(cb)); id: name, // generic group ID
}); name: name,
type: 'group',
items: groups[name]
}));
// Group checkbox toggles all children (BULK) this.renderTree();
this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => {
groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb));
});
} catch (err) { } catch (err) {
console.error('Error loading content tree:', 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 = '<p class="hint">No content found</p>';
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 = `<div class="content-channels">
${group.items.map(item => {
const itemHidden = this.hiddenSet.has(`${item.type}:${item.id}`);
return `
<label class="checkbox-label channel-item" title="${this.escapeHtml(item.name)}">
<input type="checkbox" class="channel-checkbox"
data-type="${item.type}"
data-id="${item.id}"
data-source-id="${this.treeData.sourceId}"
${!itemHidden ? 'checked' : ''}>
<span class="channel-name">${this.escapeHtml(item.name)}</span>
</label>`;
}).join('')}
</div>`;
}
return `
<div class="content-group ${isExpanded ? '' : 'collapsed'}" data-group-id="${this.escapeHtml(group.id)}">
<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="${this.escapeHtml(group.name)}"
data-source-id="${this.treeData.sourceId}"
${checked ? 'checked' : ''}>
<span class="group-name">${this.escapeHtml(group.name)} (${group.items.length})</span>
</label>
</div>
${itemsHtml}
</div>
`;
}
escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 * Load movie categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/ */
async loadMovieCategoriesTree(sourceId) { async loadMovieCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading movie categories...</p>'; this.contentTree.innerHTML = '<p class="hint">Loading movie categories...</p>';
this.treeData = { type: 'movies', sourceId, groups: [] };
try { try {
const source = await API.sources.getById(sourceId); const source = await API.sources.getById(sourceId);
@@ -535,7 +630,6 @@ class SourceManager {
return; return;
} }
// Fetch VOD categories
const categories = await API.proxy.xtream.vodCategories(sourceId); const categories = await API.proxy.xtream.vodCategories(sourceId);
if (!categories || categories.length === 0) { if (!categories || categories.length === 0) {
@@ -543,32 +637,33 @@ class SourceManager {
return; return;
} }
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId); 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 // Create a single "Movies" group or flatten?
let html = '<div class="content-categories">'; // The original UI rendered a flat list of categories.
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => { // Better to stick to "Group -> Items" structure, or just wrap them in a pseudo-group?
const isHidden = hiddenSet.has(`vod_category:${cat.category_id}`); // Original: rendered checkboxes directly.
html += ` // Let's adopt the treeData structure but with a single root group or flat items?
<label class="checkbox-label category-item"> // To support generic renderTree, we can put them in a "Categories" group or just render them as items.
<input type="checkbox" class="category-checkbox" // Let's update renderTree to support flat list if groups is empty?
data-type="vod_category" // Or just put them in one "All Categories" group that is auto-expanded.
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; 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 // Auto expand
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { this.expandedGroups.add('all_categories');
cb.addEventListener('change', () => this.toggleVisibility(cb)); this.renderTree();
});
} catch (err) { } catch (err) {
console.error('Error loading movie categories:', err); console.error('Error loading movie categories:', err);
@@ -578,10 +673,10 @@ class SourceManager {
/** /**
* Load series categories tree for a source * Load series categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/ */
async loadSeriesCategoriesTree(sourceId) { async loadSeriesCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading series categories...</p>'; this.contentTree.innerHTML = '<p class="hint">Loading series categories...</p>';
this.treeData = { type: 'series', sourceId, groups: [] };
try { try {
const source = await API.sources.getById(sourceId); const source = await API.sources.getById(sourceId);
@@ -591,7 +686,6 @@ class SourceManager {
return; return;
} }
// Fetch series categories
const categories = await API.proxy.xtream.seriesCategories(sourceId); const categories = await API.proxy.xtream.seriesCategories(sourceId);
if (!categories || categories.length === 0) { if (!categories || categories.length === 0) {
@@ -599,32 +693,23 @@ class SourceManager {
return; return;
} }
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId); 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 this.treeData.groups = [{
let html = '<div class="content-categories">'; id: 'all_series_categories',
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => { name: 'Categories',
const isHidden = hiddenSet.has(`series_category:${cat.category_id}`); type: 'group',
html += ` items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({
<label class="checkbox-label category-item"> id: cat.category_id,
<input type="checkbox" class="category-checkbox" name: cat.category_name,
data-type="series_category" type: 'series_category',
data-id="${cat.category_id}" original: cat
data-source-id="${sourceId}" }))
${isHidden ? '' : 'checked'}> }];
<span class="category-name">${cat.category_name}</span>
</label>`;
});
html += '</div>';
this.contentTree.innerHTML = html; this.expandedGroups.add('all_series_categories');
this.renderTree();
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
} catch (err) { } catch (err) {
console.error('Error loading series categories:', err); console.error('Error loading series categories:', err);
@@ -640,34 +725,45 @@ class SourceManager {
const sourceId = parseInt(checkbox.dataset.sourceId); const sourceId = parseInt(checkbox.dataset.sourceId);
const itemType = checkbox.dataset.type; const itemType = checkbox.dataset.type;
const itemId = checkbox.dataset.id; 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 { try {
// Fire API call (don't await for faster UI response) // Fire API call
const apiCall = isVisible const apiCall = isVisible
? API.channels.show(sourceId, itemType, itemId) ? API.channels.show(sourceId, itemType, itemId)
: API.channels.hide(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') { if (itemType === 'vod_category' || itemType === 'series_category') {
apiCall.catch(err => { apiCall.catch(err => {
console.error(`Error toggling ${itemType} visibility:`, err); console.error(`Error toggling ${itemType} visibility:`, err);
checkbox.checked = !isVisible; // Revert on error checkbox.checked = !isVisible;
}); });
return; return;
} }
// For channels/groups, await and refresh
await apiCall; 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) { if (window.app?.channelList) {
// We could just pass the change? But safely reloading hidden is better
await window.app.channelList.loadHiddenItems(); await window.app.channelList.loadHiddenItems();
window.app.channelList.render(); window.app.channelList.render();
} }
} catch (err) { } catch (err) {
console.error('Error toggling visibility:', 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; checkbox.checked = !isVisible;
} }
} }
@@ -676,23 +772,44 @@ class SourceManager {
* Toggle all children of a group efficiently * Toggle all children of a group efficiently
*/ */
async toggleGroupChildren(groupCb) { async toggleGroupChildren(groupCb) {
const group = groupCb.closest('.content-group'); const groupName = groupCb.dataset.id; // Helper uses name as ID
const channelCheckboxes = group.querySelectorAll('.channel-checkbox'); const group = this.treeData.groups.find(g => g.name === groupName);
if (!group) return;
const isChecked = groupCb.checked; const isChecked = groupCb.checked;
const itemsToUpdate = []; const itemsToUpdate = [];
// Identify items that need changing // 1. Update State
channelCheckboxes.forEach(chCb => { if (isChecked) {
if (chCb.checked !== isChecked) { this.hiddenSet.delete(`group:${group.name}`);
chCb.checked = isChecked; } else {
itemsToUpdate.push({ this.hiddenSet.add(`group:${group.name}`);
sourceId: parseInt(chCb.dataset.sourceId), }
itemType: chCb.dataset.type,
itemId: chCb.dataset.id 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; if (itemsToUpdate.length === 0) return;
try { try {
@@ -702,50 +819,60 @@ class SourceManager {
await API.channels.bulkHide(itemsToUpdate); await API.channels.bulkHide(itemsToUpdate);
} }
// Refresh channel list
if (window.app?.channelList) { if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems(); await window.app.channelList.loadHiddenItems();
window.app.channelList.render(); window.app.channelList.render();
} }
} catch (err) { } catch (err) {
console.error('Error toggling group children:', 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) { async setAllVisibility(visible) {
const sourceId = this.contentSourceSelect?.value; if (!this.treeData || !this.treeData.groups) return;
if (!sourceId) return;
const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]'); // 1. Update State
const items = []; const items = [];
checkboxes.forEach(cb => { this.treeData.groups.forEach(group => {
if (cb.checked !== visible) { // Group visibility (if it's a channel group)
items.push({ if (group.type === 'group') {
sourceId: parseInt(cb.dataset.sourceId), const groupKey = `group:${group.name}`;
itemType: cb.dataset.type, if (visible) this.hiddenSet.delete(groupKey);
itemId: cb.dataset.id else this.hiddenSet.add(groupKey);
});
cb.checked = visible;
} }
// 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; if (items.length === 0) return;
try { try {
if (visible) { if (visible) {
// Show all - use bulk API
await API.channels.bulkShow(items); await API.channels.bulkShow(items);
} else { } else {
// Hide all - use bulk API
await API.channels.bulkHide(items); await API.channels.bulkHide(items);
} }
// Refresh channel list
if (window.app?.channelList) { if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems(); await window.app.channelList.loadHiddenItems();
window.app.channelList.render(); window.app.channelList.render();