feat: split live analysis actions and page catalog loading

Separate incremental live checks from full catalog rebuilds and page projected channel/variant loading so first live-list display stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-07 23:45:42 +02:00
parent bbb1afb238
commit 4cca030afa
12 changed files with 434 additions and 35 deletions

View File

@ -28,6 +28,9 @@ class ChannelList {
this.listMode = 'grouped';
this.logicalChannels = [];
this.projectedLogicalChannels = null;
this.projectionPage = null;
this.projectionLimit = 100;
this.variantPages = new Map();
this.availableLanguages = [];
this.selectedLanguages = new Set();
this.selectedVariants = new Map();
@ -194,7 +197,8 @@ class ChannelList {
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.projectedLogicalChannels || this.channels.length) this.render();
if (this.projectionPage) this.loadChannels();
else if (this.projectedLogicalChannels || this.channels.length) this.render();
else this.loadChannels();
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
});
@ -215,7 +219,8 @@ class ChannelList {
if (this.searchInput?.value) {
this.searchInput.value = '';
clearTimeout(searchTimeout);
this.render();
if (this.projectionPage) this.loadChannels();
else this.render();
this.searchInput.focus();
}
});
@ -226,7 +231,10 @@ class ChannelList {
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
this.showHiddenCheckbox.addEventListener('change', () => {
if (this.projectionPage) this.loadChannels();
else this.render();
});
}
// Context menu handlers
@ -625,6 +633,7 @@ class ChannelList {
// Start observing loader for additional batches
this.observer.observe(this.loader);
this.appendProjectionPager();
}
renderFlatList(channels, searchTerm) {
@ -654,6 +663,41 @@ class ChannelList {
this.container.appendChild(this.loader);
this.renderNextFlatBatch();
this.observer.observe(this.loader);
this.appendProjectionPager();
}
appendProjectionPager() {
if (!this.projectionPage?.hasMore) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-ghost load-more-channels';
button.textContent = 'Load more channels';
button.addEventListener('click', async () => {
button.disabled = true;
await this.loadMoreProjectedChannels();
});
this.container.appendChild(button);
}
async loadMoreProjectedChannels() {
if (!this.projectionPage?.hasMore) return;
const sourceValue = this.sourceSelect.value;
const [, rawSourceId] = sourceValue ? sourceValue.split(':') : [];
const response = await API.live.getChannels({
sourceId: rawSourceId ? Number.parseInt(rawSourceId, 10) : null,
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
q: this.searchInput?.value || '',
languages: [...this.selectedLanguages],
limit: this.projectionPage.limit || this.projectionLimit,
offset: this.projectionPage.nextOffset
});
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
this.channels.push(...mapped.channels);
this.projectedLogicalChannels.push(...mapped.logicalChannels);
this.projectionPage = response.page || null;
mapped.favorites.forEach(key => this.visibleFavorites.add(key));
mapped.hiddenItems.forEach(key => this.hiddenItems.add(key));
this.render();
}
renderNextFlatBatch() {
@ -809,10 +853,20 @@ class ChannelList {
});
}
});
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', async (e) => {
e.stopPropagation();
const fingerprint = button.closest('.channel-item').dataset.logicalId;
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
const logicalId = button.closest('.channel-item').dataset.logicalId;
if (this.expandedVariants.has(logicalId)) {
this.expandedVariants.delete(logicalId);
} else {
this.expandedVariants.add(logicalId);
await this.loadChannelVariants(logicalId);
}
this.render();
}));
groupEl.querySelectorAll('.load-more-variants').forEach(button => button.addEventListener('click', async (e) => {
e.stopPropagation();
await this.loadChannelVariants(button.dataset.logicalId, true);
this.render();
}));
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
@ -825,7 +879,8 @@ class ChannelList {
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 variantState = this.variantPages?.get(channel.fingerprint);
const variants = expanded ? 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));
@ -844,7 +899,9 @@ class ChannelList {
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
</button>`;
}).join('');
}).join('') : '';
const variantCount = Number(channel.variantCount) || channel.variants.length;
const hasMoreVariants = expanded && variantState?.page?.hasMore;
const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' };
const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code);
const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase());
@ -859,13 +916,36 @@ class ChannelList {
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${variantCount} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
</div>
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
</div>`;
}
async loadChannelVariants(logicalId, append = false) {
const channel = this.projectedLogicalChannels?.find(item => item.fingerprint === logicalId);
if (!channel || !API.live.getChannelVariants) return;
this.variantPages ||= new Map();
const current = this.variantPages.get(logicalId);
if (!append && current?.loaded) return;
const offset = append ? (current?.page?.nextOffset || 0) : 0;
const response = await API.live.getChannelVariants(logicalId, {
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
limit: 100,
offset
});
const rawVariants = append ? [...(current?.rawVariants || []), ...(response.variants || [])] : (response.variants || []);
const mapped = LiveChannelProjection.mapResponse({
channels: [{ ...channel, variants: rawVariants }]
}, this.sources).logicalChannels?.[0];
if (!mapped) return;
channel.variants = mapped.variants;
channel.visibleVariants = mapped.visibleVariants;
channel.preferredVariant = mapped.preferredVariant;
this.variantPages.set(logicalId, { loaded: true, rawVariants, page: response.page || null });
}
/**
* Render channels for a specific group (called when expanding a collapsed group)
*/
@ -1060,9 +1140,11 @@ class ChannelList {
try {
const response = await API.live.getChannels({
sourceId,
includeHidden: true,
q: '',
languages: []
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
q: this.searchInput?.value || '',
languages: [...this.selectedLanguages],
limit: this.projectionLimit,
offset: 0
});
if (!LiveChannelProjection.canUse(response)) {
this.projectedLogicalChannels = null;
@ -1072,6 +1154,9 @@ class ChannelList {
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
this.channels = mapped.channels;
this.projectedLogicalChannels = mapped.logicalChannels;
this.projectionPage = response.page || null;
this.variantPages ||= new Map();
this.variantPages.clear();
this.availableLanguages = mapped.availableLanguages;
this.reconcileLanguageSelection();
this.renderLanguageFilter();
@ -1081,6 +1166,7 @@ class ChannelList {
return true;
} catch (err) {
this.projectedLogicalChannels = null;
this.projectionPage = null;
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
this.projectionNotice = '';
return false;
@ -1117,7 +1203,8 @@ class ChannelList {
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
} catch {}
this.renderLanguageFilter();
if (this.projectedLogicalChannels || this.channels.length) this.render();
if (this.projectionPage) this.loadChannels();
else if (this.projectedLogicalChannels || this.channels.length) this.render();
else this.loadChannels();
}