/**
* 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, "'");
}
/**
* 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 = `
${searchTerm ? 'No channels match your search' : 'No channels loaded'}
${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}
`;
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 = '';
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 += `
▼${groupName}${channels.length}
`;
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 += `