perf: collapse groups by default and defer channel rendering in channel list
This commit is contained in:
@@ -16,6 +16,7 @@ class ChannelList {
|
|||||||
this.groups = [];
|
this.groups = [];
|
||||||
this.hiddenItems = new Set(); // Set<"type:sourceId:itemId">
|
this.hiddenItems = new Set(); // Set<"type:sourceId:itemId">
|
||||||
this.collapsedGroups = new Set(); // Track collapsed groups
|
this.collapsedGroups = new Set(); // Track collapsed groups
|
||||||
|
this._userExpandedGroups = new Set(); // Track groups user has explicitly expanded
|
||||||
this.favorites = []; // Array of favorite objects
|
this.favorites = []; // Array of favorite objects
|
||||||
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
|
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
|
||||||
this.currentChannel = null;
|
this.currentChannel = null;
|
||||||
@@ -48,9 +49,13 @@ class ChannelList {
|
|||||||
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
|
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.collapsedGroups = new Set(JSON.parse(saved));
|
this.collapsedGroups = new Set(JSON.parse(saved));
|
||||||
|
this._hasCollapsedState = true;
|
||||||
|
} else {
|
||||||
|
this._hasCollapsedState = false; // First load - will collapse all by default
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading collapsed state:', err);
|
console.error('Error loading collapsed state:', err);
|
||||||
|
this._hasCollapsedState = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +76,12 @@ class ChannelList {
|
|||||||
toggleGroup(groupName) {
|
toggleGroup(groupName) {
|
||||||
if (this.collapsedGroups.has(groupName)) {
|
if (this.collapsedGroups.has(groupName)) {
|
||||||
this.collapsedGroups.delete(groupName);
|
this.collapsedGroups.delete(groupName);
|
||||||
|
// Track that user explicitly expanded this group
|
||||||
|
this._userExpandedGroups.add(groupName);
|
||||||
} else {
|
} else {
|
||||||
this.collapsedGroups.add(groupName);
|
this.collapsedGroups.add(groupName);
|
||||||
|
// User collapsed it, remove from expanded tracking
|
||||||
|
this._userExpandedGroups.delete(groupName);
|
||||||
}
|
}
|
||||||
this.saveCollapsedState();
|
this.saveCollapsedState();
|
||||||
}
|
}
|
||||||
@@ -83,7 +92,17 @@ class ChannelList {
|
|||||||
expandAll() {
|
expandAll() {
|
||||||
this.collapsedGroups.clear();
|
this.collapsedGroups.clear();
|
||||||
this.saveCollapsedState();
|
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
|
// Update toggle button
|
||||||
if (this.toggleGroupsBtn) {
|
if (this.toggleGroupsBtn) {
|
||||||
@@ -334,6 +353,18 @@ class ChannelList {
|
|||||||
this.groupedChannels = groupedChannels;
|
this.groupedChannels = groupedChannels;
|
||||||
this.showHidden = showHidden;
|
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)
|
// Build rendered channel list for navigation (matches visual order)
|
||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
this.sortedGroups.forEach(groupName => {
|
this.sortedGroups.forEach(groupName => {
|
||||||
@@ -431,6 +462,12 @@ class ChannelList {
|
|||||||
// Skip group if no visible channels (derived visibility)
|
// Skip group if no visible channels (derived visibility)
|
||||||
if (visibleChannels.length === 0) continue;
|
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 += `
|
html += `
|
||||||
<div class="channel-group">
|
<div class="channel-group">
|
||||||
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
|
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
|
||||||
@@ -441,6 +478,13 @@ class ChannelList {
|
|||||||
<div class="group-channels">
|
<div class="group-channels">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 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 += '</div></div>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const channel of visibleChannels) {
|
for (const channel of visibleChannels) {
|
||||||
// Check hidden again for styling (showHidden mode)
|
// Check hidden again for styling (showHidden mode)
|
||||||
@@ -501,8 +545,20 @@ class ChannelList {
|
|||||||
const header = groupEl.querySelector('.group-header');
|
const header = groupEl.querySelector('.group-header');
|
||||||
if (header) {
|
if (header) {
|
||||||
header.addEventListener('click', () => {
|
header.addEventListener('click', () => {
|
||||||
|
const groupName = header.dataset.group;
|
||||||
|
const isCollapsed = header.classList.contains('collapsed');
|
||||||
|
|
||||||
header.classList.toggle('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));
|
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 += `
|
||||||
|
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
||||||
|
data-channel-id="${channel.id}"
|
||||||
|
data-source-id="${channel.sourceId}"
|
||||||
|
data-source-type="${channel.sourceType}"
|
||||||
|
data-stream-id="${channel.streamId || ''}"
|
||||||
|
data-url="${channel.url || ''}">
|
||||||
|
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
||||||
|
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
|
<div class="channel-info">
|
||||||
|
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
||||||
|
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
|
||||||
|
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* Load sources into dropdown
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user