Reduce batch size for channel list, memoize EPG, virtualized source manager
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.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
|
* 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user