/**
* 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.toggleGroupsBtn = document.getElementById('toggle-groups');
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._userExpandedGroups = new Set(); // Track groups user has explicitly expanded
this.favorites = []; // Array of favorite objects
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
this.currentChannel = null;
this.sources = [];
this.isLoading = false;
this.renderedChannels = [];
this.logicalChannels = [];
this.selectedVariants = new Map();
this.expandedVariants = new Set();
this.preferredLanguageKey = 'nodecast_tv_preferred_language';
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
this.preferredLanguage = this.loadPreferredLanguage();
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.loadCollapsedState();
this.init();
}
/**
* Get proxied image URL to avoid mixed content errors on HTTPS
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
if (!url || url.length === 0) return '/img/placeholder.png';
// Only proxy if we're on HTTPS and the image is HTTP
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
return `/api/proxy/image?url=${encodeURIComponent(url)}`;
}
return url;
}
/**
* Load collapsed state from localStorage
*/
loadCollapsedState() {
try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved));
this._hasCollapsedState = true;
} else {
this._hasCollapsedState = false; // First load - will collapse all by default
}
} catch (err) {
console.error('Error loading collapsed state:', err);
this._hasCollapsedState = false;
}
}
loadPreferredLanguage() {
try {
return localStorage.getItem(this.preferredLanguageKey) || null;
} catch {
return null;
}
}
loadChannelLanguagePreferences() {
try {
return new Map(Object.entries(JSON.parse(localStorage.getItem(this.channelLanguagePreferencesKey) || '{}')));
} catch {
return new Map();
}
}
saveLanguagePreferences() {
try {
if (this.preferredLanguage) localStorage.setItem(this.preferredLanguageKey, this.preferredLanguage);
else localStorage.removeItem(this.preferredLanguageKey);
localStorage.setItem(this.channelLanguagePreferencesKey, JSON.stringify(Object.fromEntries(this.channelLanguagePreferences)));
} catch (err) {
console.warn('[ChannelList] Unable to persist language preferences:', err.message);
}
}
/**
* 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);
// Track that user explicitly expanded this group
this._userExpandedGroups.add(groupName);
} else {
this.collapsedGroups.add(groupName);
// User collapsed it, remove from expanded tracking
this._userExpandedGroups.delete(groupName);
}
this.saveCollapsedState();
}
/**
* Expand all groups
*/
expandAll() {
this.collapsedGroups.clear();
this.saveCollapsedState();
// Expand all and render channels for empty containers
this.container.querySelectorAll('.group-header.collapsed').forEach(h => {
h.classList.remove('collapsed');
const groupName = h.dataset.group;
const groupEl = h.closest('.channel-group');
const channelsContainer = groupEl?.querySelector('.group-channels');
if (channelsContainer && channelsContainer.children.length === 0) {
this.renderGroupChannels(groupName, channelsContainer);
}
});
// Update toggle button
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.innerHTML = Icons.collapseAll;
this.toggleGroupsBtn.title = 'Collapse All';
}
}
/**
* 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();
// Update toggle button
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.innerHTML = Icons.expandAll;
this.toggleGroupsBtn.title = 'Expand All';
}
}
/**
* 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', (e) => {
// Don't close if clicking inside context menu
if (!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
});
this.contextMenu.querySelectorAll('.context-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent document click from firing
this.handleContextAction(e);
});
});
// Intersection Observer for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextBatch();
}
}, { rootMargin: '100px' });
// Start EPG refresh timer (updates visible program info every 60 seconds)
this.startEpgRefreshTimer();
}
/**
* Start timer to refresh EPG info in visible channel items
* Updates every 60 seconds to keep "Now Playing" program info current
*/
startEpgRefreshTimer() {
// Clear any existing timer
if (this._epgRefreshTimer) {
clearInterval(this._epgRefreshTimer);
}
// Refresh every 60 seconds
this._epgRefreshTimer = setInterval(() => {
this.updateVisibleEpgInfo();
}, 60000);
}
/**
* Update EPG info for visible channel items without full re-render
* Only updates the program text, not the entire channel item
*/
updateVisibleEpgInfo() {
if (!window.app || !window.app.epgGuide) return;
// Clear the cache so we get fresh data
this.clearProgramInfoCache();
// Find all visible channel items and update their program info
const channelItems = this.container.querySelectorAll('.channel-item');
channelItems.forEach(item => {
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
if (channel) {
const programInfo = this.getProgramInfo(channel);
const programElement = item.querySelector('.channel-program');
if (programElement) {
programElement.textContent = programInfo || '';
}
}
});
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* 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 variant = channel.preferredVariant || channel;
const cacheKey = `${variant.tvgId || variant.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;
}
let program = window.app.epgGuide.getCurrentProgram(variant.tvgId, variant.name);
if (!program && channel.variants) {
for (const fallback of channel.variants) {
program = window.app.epgGuide.getCurrentProgram(fallback.tvgId, fallback.name);
if (program) break;
}
}
const result = program ? program.title : null;
this._programInfoCache.set(cacheKey, result);
return result;
} catch (e) {
console.warn("Error in getProgramInfo", e);
return null;
}
}
/**
* Clear program info cache
* Useful when EPG data has been updated
*/
clearProgramInfoCache() {
if (this._programInfoCache) {
this._programInfoCache.clear();
}
}
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;
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
showHidden,
selectedVariants: this.selectedVariants,
preferredLanguage: this.preferredLanguage,
languagePreferences: this.channelLanguagePreferences,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
});
// 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.logicalChannels;
if (searchTerm) {
this.filteredChannels = this.logicalChannels.filter(ch =>
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) ||
ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.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.logicalChannels.filter(ch => ch.isFavorite);
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
}
// 4. Sort Groups and filter to only those with visible channels
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
});
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
this.sortedGroups = allGroups.filter(groupName => {
if (groupName === 'Favorites') return true;
const channels = groupedChannels[groupName];
// Check if any channel in this group is visible
return channels.some(channel => {
return !channel.isHidden || showHidden;
});
});
this.groupedChannels = groupedChannels;
this.showHidden = showHidden;
// Collapse all groups by default on first load (for large playlists)
// This prevents rendering 100K+ channel items on initial load
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
this.sortedGroups.forEach(groupName => {
if (groupName !== 'Favorites') {
this.collapsedGroups.add(groupName);
}
});
this._hasCollapsedState = true;
this.saveCollapsedState();
}
// Build rendered channel list for navigation (matches visual order)
this.renderedChannels = [];
this.sortedGroups.forEach(groupName => {
const channels = this.groupedChannels[groupName];
const isFavoritesGroup = groupName === 'Favorites';
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
return !channel.isHidden || this.showHidden;
});
// Assign unique render IDs for linear navigation
visibleChannels.forEach(ch => {
// We clone the object for the rendered list to attach the unique ID
// ensuring no side effects on the main channel object
const renderedCh = {
...ch,
_renderId: `rid_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
_renderGroup: groupName // Track visual group for navigation
};
this.renderedChannels.push(renderedCh);
});
});
// 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 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();
}
// 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 = '';
let renderIndex = start; // Keep track of global index for mapping to renderedChannels
for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
// Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
return !channel.isHidden || this.showHidden;
});
// Skip group if no visible channels (derived visibility)
if (visibleChannels.length === 0) continue;
// Default new groups to collapsed (except Favorites)
// This handles groups loaded via scroll that weren't in the initial collapse
if (!isFavoritesGroup && !this.collapsedGroups.has(groupName) && !this._userExpandedGroups?.has(groupName)) {
this.collapsedGroups.add(groupName);
}
html += `
`;
// Skip rendering channel items if group is collapsed (major performance optimization)
// Channels will be rendered when user expands the group
if (this.collapsedGroups.has(groupName)) {
html += '
';
continue;
}
for (const channel of visibleChannels) {
const isActive = this.currentChannel?.id === channel.id;
// Check if this specific instance is the "active" one for navigation purposes
const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId;
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
renderIndex++;
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive);
}
html += '';
}
// 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', () => {
const groupName = header.dataset.group;
const isCollapsed = header.classList.contains('collapsed');
header.classList.toggle('collapsed');
this.toggleGroup(groupName);
// If expanding, render channels if they weren't rendered initially
if (isCollapsed) {
const channelsContainer = groupEl.querySelector('.group-channels');
if (channelsContainer && channelsContainer.children.length === 0) {
// Channels weren't rendered - render them now
this.renderGroupChannels(groupName, channelsContainer);
}
}
});
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
}
this.attachChannelListeners(groupEl);
}
attachChannelListeners(groupEl) {
groupEl.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) 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.variantId);
});
}
});
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
e.stopPropagation();
const fingerprint = button.closest('.channel-item').dataset.logicalId;
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
this.render();
}));
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
e.stopPropagation();
this.selectChannel(item.dataset);
}));
}
renderLogicalChannel(channel, renderId, renderGroup, isActive = false, isRenderActive = false) {
const selected = channel.preferredVariant;
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
const expanded = this.expandedVariants.has(channel.fingerprint);
const variants = channel.variants.map(variant => {
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
if (hidden && !this.showHidden) return '';
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
const language = variant.language;
const languageLabel = language.code === 'unknown'
? 'Unknown'
: `${language.code.toUpperCase()} ${language.confidence}%`;
const languageDescription = language.code === 'unknown'
? 'Unknown language'
: `${language.label} — ${language.confidence}% confidence`;
return ``;
}).join('');
return `