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:
@ -884,10 +884,21 @@ h4 {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.live-analysis-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.live-analysis-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.live-analysis-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-model-panel [hidden] {
|
||||
|
||||
@ -489,9 +489,12 @@
|
||||
<strong>Channel analysis</strong>
|
||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="live-analysis-button">Analyze channels</button>
|
||||
<div class="live-analysis-actions">
|
||||
<button type="button" class="btn btn-primary" id="live-check-new-button">Check for new streams</button>
|
||||
<button type="button" class="btn btn-secondary" id="live-rebuild-catalog-button">Rebuild entire catalog</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-hint">Use this to analyze channels that were added before automatic analysis was enabled.</p>
|
||||
<p class="setting-hint">Check newly added streams, or rebuild the entire catalog if channel matching needs to be refreshed.</p>
|
||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||
|
||||
@ -90,14 +90,27 @@ const API = {
|
||||
},
|
||||
|
||||
live: {
|
||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => {
|
||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [], limit = 100, offset = 0 } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||
if (q) params.set('q', q);
|
||||
if (languages.length) params.set('languages', languages.join(','));
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
params.set('page', String(Math.floor(offset / limit) + 1));
|
||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||
},
|
||||
getChannelVariants: (logicalChannelId, { includeHidden = false, limit = 100, offset = 0 } = {}) => {
|
||||
const params = new URLSearchParams({
|
||||
includeHidden: String(Boolean(includeHidden)),
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
page: String(Math.floor(offset / limit) + 1)
|
||||
});
|
||||
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
||||
},
|
||||
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||
},
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -107,7 +107,12 @@ class SettingsPage {
|
||||
}
|
||||
|
||||
initLiveAnalysis() {
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
|
||||
document.getElementById('live-check-new-button')?.addEventListener('click', () => void this.runLiveAnalysis('check-new'));
|
||||
document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', () => {
|
||||
if (confirm('Rebuild the entire channel catalog? This can take a while.')) void this.runLiveAnalysis('rebuild');
|
||||
});
|
||||
// Compatibility with pages cached before the actions were split.
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis('rebuild'));
|
||||
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
||||
const button = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
@ -131,7 +136,11 @@ class SettingsPage {
|
||||
}
|
||||
|
||||
renderLiveAnalysisStatus(status = {}) {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
const buttons = [
|
||||
document.getElementById('live-check-new-button'),
|
||||
document.getElementById('live-rebuild-catalog-button'),
|
||||
document.getElementById('live-analysis-button')
|
||||
].filter(Boolean);
|
||||
const statusElement = document.getElementById('live-analysis-status');
|
||||
const progress = document.getElementById('live-analysis-progress');
|
||||
const track = document.getElementById('live-analysis-progress-track');
|
||||
@ -140,11 +149,10 @@ class SettingsPage {
|
||||
const fallback = document.getElementById('live-analysis-fallback');
|
||||
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
if (!button || !statusElement) return;
|
||||
if (!buttons.length || !statusElement) return;
|
||||
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
button.textContent = presentation.buttonLabel;
|
||||
button.disabled = presentation.busy;
|
||||
buttons.forEach(button => { button.disabled = presentation.busy; });
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
progress.hidden = !presentation.busy;
|
||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
@ -169,14 +177,19 @@ class SettingsPage {
|
||||
details.textContent = presentation.errorDetail;
|
||||
}
|
||||
|
||||
async runLiveAnalysis() {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
if (button?.disabled) return;
|
||||
async runLiveAnalysis(action = 'rebuild') {
|
||||
const buttons = ['live-check-new-button', 'live-rebuild-catalog-button', 'live-analysis-button']
|
||||
.map(id => document.getElementById(id)).filter(Boolean);
|
||||
if (buttons.some(button => button.disabled)) return;
|
||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
try {
|
||||
const response = await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || response;
|
||||
const response = action === 'check-new'
|
||||
? await API.live.checkNewStreams()
|
||||
: await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || (response.started
|
||||
? { status: 'queued', processed: 0 }
|
||||
: response);
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
this.startLiveAnalysisPolling();
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user