From 5f24d7e264357b290aefc51d2c37abc0d6cb4a57 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sun, 11 Jan 2026 16:08:20 -0800 Subject: [PATCH] perf: collapse groups by default and defer channel rendering in channel list --- public/js/components/ChannelList.js | 124 +++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 1256b7c..b52a99a 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -16,6 +16,7 @@ class ChannelList { 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; @@ -48,9 +49,13 @@ class ChannelList { 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; } } @@ -71,8 +76,12 @@ class ChannelList { 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(); } @@ -83,7 +92,17 @@ class ChannelList { expandAll() { this.collapsedGroups.clear(); this.saveCollapsedState(); - this.container.querySelectorAll('.group-header.collapsed').forEach(h => h.classList.remove('collapsed')); + + // 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) { @@ -334,6 +353,18 @@ class ChannelList { 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 => { @@ -431,6 +462,12 @@ class ChannelList { // 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 += `
@@ -441,6 +478,13 @@ class ChannelList {
`; + // 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) { // Check hidden again for styling (showHidden mode) @@ -501,8 +545,20 @@ class ChannelList { 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(header.dataset.group); + 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)); } @@ -524,6 +580,70 @@ class ChannelList { }); } + /** + * Render channels for a specific group (called when expanding a collapsed group) + */ + renderGroupChannels(groupName, container) { + const channels = this.groupedChannels[groupName]; + if (!channels || channels.length === 0) return; + + const isFavoritesGroup = groupName === 'Favorites'; + + // Filter visible channels + const visibleChannels = channels.filter(channel => { + if (isFavoritesGroup) return true; + const rawChannelId = channel.streamId || channel.id; + const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); + return !channelHidden || this.showHidden; + }); + + let html = ''; + for (const channel of visibleChannels) { + const rawChannelId = channel.streamId || channel.id; + const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); + const isActive = this.currentChannel?.id === channel.id; + const isFavorite = this.isFavorite(channel.sourceId, channel.id); + + html += ` +
+ +
+
${this.escapeHtml(channel.name)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+
+ +
+ `; + } + + container.innerHTML = html; + + // Attach listeners to the new channel items + container.querySelectorAll('.channel-item').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.closest('.favorite-btn')) 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.channelId); + }); + } + }); + } + /** * Load sources into dropdown */