diff --git a/public/css/main.css b/public/css/main.css
index 46070f9..e8b4da3 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -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] {
diff --git a/public/index.html b/public/index.html
index 493807b..2b38544 100644
--- a/public/index.html
+++ b/public/index.html
@@ -489,9 +489,12 @@
Channel analysis
Group matching streams into channels and update detected languages.
- Use this to analyze channels that were added before automatic analysis was enabled.
+ Check newly added streams, or rebuild the entire catalog if channel matching needs to be refreshed.
diff --git a/public/js/api.js b/public/js/api.js
index be078bc..3cf8047 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -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')
},
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
index 73825e1..3cb5ea4 100644
--- a/public/js/components/ChannelList.js
+++ b/public/js/components/ChannelList.js
@@ -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 {
${this.escapeHtml(source?.name || variant.sourceType)}
${this.escapeHtml(variant.name)}
`;
- }).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 {
${this.escapeHtml(languageBadge)}
${this.escapeHtml(channel.displayName)}
${this.escapeHtml(programLabel)}
- ${channel.variants.length > 1 ? `
` : ''}
+ ${variantCount > 1 ? `
` : ''}
- ${channel.variants.length > 1 ? `