feat(live): paginate catalog and search server-side (#49)

Load Live channels from a filtered SQLite cursor API, keep navigation and guide consumers working across pages, and retain the QA reproduction coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 22:43:29 +02:00
parent 8239e6043b
commit e2249154a5
13 changed files with 1280 additions and 387 deletions

View File

@ -23,6 +23,20 @@ class ChannelList {
this.sources = [];
this.isLoading = false;
this.renderedChannels = [];
this.catalogVersion = 0;
this.hasMore = false;
this.catalogQuery = '';
this.catalogStatus = document.createElement('div');
this.catalogStatus.setAttribute('role', 'status');
this.catalogStatus.className = 'live-catalog-status';
this.container.after(this.catalogStatus);
this.loadMoreButton = document.createElement('button');
this.loadMoreButton.type = 'button';
this.loadMoreButton.className = 'btn btn-sm live-catalog-more';
this.loadMoreButton.textContent = 'Load more';
this.loadMoreButton.hidden = true;
this.catalogStatus.after(this.loadMoreButton);
this.loadMoreButton.addEventListener('click', () => this.loadCatalogPage(this.channels.length > 0));
this.loadCollapsedState();
this.init();
@ -145,21 +159,28 @@ class ChannelList {
init() {
// Search handler (debounced)
let searchTimeout;
const searchNow = () => {
clearTimeout(this.searchTimeout);
this.loadChannels();
};
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.render();
}, 300);
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.loadMoreButton.hidden = true;
this.searchTimeout = setTimeout(searchNow, 300);
});
// Source filter handler
this.sourceSelect.addEventListener('change', () => this.loadChannels());
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
}
this.searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); searchNow(); }
});
document.getElementById('channel-search-submit')?.addEventListener('click', searchNow);
this.searchInput.parentElement.querySelector('.search-clear')?.addEventListener('click', () => {
this.searchInput.value = '';
searchNow();
this.searchInput.focus();
});
this.sourceSelect.addEventListener('change', searchNow);
this.showHiddenCheckbox?.addEventListener('change', searchNow);
// Context menu handlers
document.addEventListener('click', (e) => {
@ -235,7 +256,6 @@ class ChannelList {
});
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* Get current program info string - cached for performance
@ -281,7 +301,7 @@ class ChannelList {
escapeHtml(text) {
if (!text) return '';
return text
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@ -289,62 +309,84 @@ class ChannelList {
.replace(/'/g, "&#039;");
}
compareBinary(left, right) {
const a = Array.from(String(left ?? ''));
const b = Array.from(String(right ?? ''));
for (let i = 0; i < Math.min(a.length, b.length); i++) {
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
if (difference) return difference;
}
return a.length - b.length;
}
catalogSortKey(value) {
return String(value ?? '').replace(/^ +| +$/g, '').replace(/[A-Z]/g, character => character.toLowerCase());
}
compareCatalogTitles(a, b) {
return this.compareBinary(this.catalogSortKey(a), this.catalogSortKey(b)) || this.compareBinary(a, b);
}
compareCatalogChannels(a, b) {
return this.compareCatalogTitles(a.name, b.name) || Number(a.sourceId) - Number(b.sourceId) ||
this.compareBinary(a.streamId ?? a.id, b.streamId ?? b.id);
}
/**
* Render channel list
*/
render() {
const searchTerm = this.searchInput.value.toLowerCase();
const searchTerm = this.catalogQuery;
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
// Reset batching
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.observer.disconnect();
this.container.innerHTML = ''; // Clear container
// Filter and Group channels
const groupedChannels = {};
const groupedChannels = Object.create(null);
// 1. Filter
this.filteredChannels = this.channels;
if (searchTerm) {
this.filteredChannels = this.channels.filter(ch =>
String(ch.name ?? "").toLowerCase().includes(searchTerm) ||
String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm)
);
}
let filteredChannels = this.filteredChannels;
// 2. Group
filteredChannels.forEach(ch => {
const groupKey = ch.groupTitle || 'Uncategorized';
const groupKey = ch.groupTitle ?? 'Uncategorized';
if (!groupedChannels[groupKey]) {
groupedChannels[groupKey] = [];
}
groupedChannels[groupKey].push(ch);
});
Object.values(groupedChannels).forEach(channels => channels.sort((a, b) => this.compareCatalogChannels(a, b)));
// Keep the synthetic Favorites group separate even from a provider's "Favorites" group.
this.favoriteGroupKey = '__nodecast_favorites__';
while (Object.hasOwn(groupedChannels, this.favoriteGroupKey)) this.favoriteGroupKey += '_';
// 3. Add Favorites
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
favoritedChannels.sort((a, b) => this.compareCatalogChannels(a, b));
groupedChannels[this.favoriteGroupKey] = favoritedChannels;
}
// 4. Sort Groups and filter to only those with visible channels
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
if (a === this.favoriteGroupKey) return -1;
if (b === this.favoriteGroupKey) return 1;
return this.compareCatalogTitles(a, b);
});
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
this.sortedGroups = allGroups.filter(groupName => {
if (groupName === 'Favorites') return true;
if (groupName === this.favoriteGroupKey) return true;
const channels = groupedChannels[groupName];
// Check if any channel in this group is visible
return channels.some(channel => {
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !isHidden || showHidden;
});
@ -357,7 +399,7 @@ class ChannelList {
// This prevents rendering 100K+ channel items on initial load
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
this.sortedGroups.forEach(groupName => {
if (groupName !== 'Favorites') {
if (groupName !== this.favoriteGroupKey) {
this.collapsedGroups.add(groupName);
}
});
@ -369,11 +411,11 @@ class ChannelList {
this.renderedChannels = [];
this.sortedGroups.forEach(groupName => {
const channels = this.groupedChannels[groupName];
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
});
@ -384,7 +426,7 @@ class ChannelList {
// ensuring no side effects on the main channel object
const renderedCh = {
...ch,
_renderId: `rid_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
_renderId: `rid_${encodeURIComponent(groupName)}_${ch.sourceId}_${encodeURIComponent(ch.id)}`,
_renderGroup: groupName // Track visual group for navigation
};
this.renderedChannels.push(renderedCh);
@ -443,18 +485,18 @@ class ChannelList {
this.loader.style.opacity = '1';
let html = '';
let renderIndex = start; // Keep track of global index for mapping to renderedChannels
let renderIndex = this.sortedGroups.slice(0, start).reduce((count, name) => count + this.renderedChannels.filter(c => c._renderGroup === name).length, 0); // Keep track of global index for mapping to renderedChannels
for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
});
@ -470,9 +512,9 @@ class ChannelList {
html += `
<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="${this.escapeHtml(groupName)}">
<span class="group-toggle">${Icons.chevronDown}</span>
<span class="group-name">${groupName}</span>
<span class="group-name">${this.escapeHtml(isFavoritesGroup ? 'Favorites' : groupName)}</span>
<span class="group-count">${visibleChannels.length}</span>
</div>
<div class="group-channels">
@ -481,6 +523,7 @@ 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)) {
renderIndex += visibleChannels.length;
html += '</div></div>';
continue;
}
@ -488,7 +531,7 @@ class ChannelList {
for (const channel of visibleChannels) {
// Check hidden again for styling (showHidden mode)
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
const isActive = this.currentChannel?.id === channel.id;
@ -502,13 +545,13 @@ class ChannelList {
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-channel-id="${this.escapeHtml(String(channel.id))}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@ -589,19 +632,19 @@ class ChannelList {
const channels = this.groupedChannels[groupName];
if (!channels || channels.length === 0) return;
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Filter visible channels
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
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 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);
@ -615,13 +658,13 @@ class ChannelList {
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-channel-id="${this.escapeHtml(String(channel.id))}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@ -699,163 +742,97 @@ class ChannelList {
* Load channels from selected source
*/
async loadChannels() {
if (this.isLoading) return;
this.isLoading = true;
this.currentRenderId = null; // Reset render tracking
const sourceValue = this.sourceSelect.value;
const self = this;
if (!sourceValue) {
// Load from all sources
await this.loadAllChannels();
this.isLoading = false;
return;
}
const [type, id] = sourceValue.split(':');
try {
this.container.innerHTML = '<div class="loading"></div>';
if (type === 'xtream') {
await this.loadXtreamChannels(parseInt(id));
} else if (type === 'm3u') {
await this.loadM3uChannels(parseInt(id));
}
// Load hidden items and favorites
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
} catch (err) {
console.error('Error loading channels:', err);
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
} finally {
this.isLoading = false;
}
}
/**
* Load channels from all enabled sources
*/
async loadAllChannels() {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.catalogScopeVersion = this.catalogVersion;
this.catalogQuery = this.searchInput.value.trim();
this.catalogParams = {
sourceId: this.sourceSelect.value.split(':')[1],
q: this.catalogQuery,
includeHidden: this.showHiddenCheckbox?.checked || false,
limit: 200
};
this.channels = [];
this.groups = [];
this.renderedChannels = [];
this.nextCursor = null;
this.hasMore = false;
this.currentRenderId = null;
return this.loadCatalogPage(false);
}
async loadAllChannels() { return this.loadChannels(); }
loadCatalogPage(append) {
if (append && this.catalogPagePromise?.version === this.catalogVersion) {
return this.catalogPagePromise.promise;
}
const version = this.catalogVersion;
const promise = this.fetchCatalogPage(append);
this.catalogPagePromise = { version, promise };
promise.finally(() => {
if (this.catalogPagePromise?.promise === promise) this.catalogPagePromise = null;
});
return promise;
}
async fetchCatalogPage(append) {
if (append && (this.isLoading || !this.hasMore)) return false;
const version = this.catalogVersion;
const controller = new AbortController();
this.catalogController = controller;
this.isLoading = true;
this.catalogStatus.textContent = this.catalogQuery ? 'Searching…' : 'Loading…';
this.container.setAttribute('aria-busy', 'true');
this.loadMoreButton.disabled = true;
if (!append) this.container.replaceChildren();
try {
this.container.innerHTML = '<div class="loading"></div>';
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
for (const source of xtreamSources) {
await this.loadXtreamChannels(source.id, true);
}
for (const source of m3uSources) {
await this.loadM3uChannels(source.id, true);
}
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
const [result] = await Promise.all([
API.channels.liveCatalog({ ...this.catalogParams, cursor: append ? this.nextCursor : undefined }, controller.signal),
...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()])
]);
if (version !== this.catalogVersion) return false;
if (result.page.hasMore && (!result.page.nextCursor || (append && result.page.nextCursor === this.nextCursor))) {
throw new Error('Invalid catalogue cursor');
}
this.channels = append ? this.channels.concat(result.items) : result.items;
this.nextCursor = result.page.nextCursor;
this.hasMore = result.page.hasMore;
const scrollTop = this.container.scrollTop;
const active = document.activeElement;
const item = active?.closest('.channel-item');
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
this.render();
if (item) {
const replacement = [...this.container.querySelectorAll('.channel-item')].find(el =>
el.dataset.channelId === item.dataset.channelId && el.dataset.sourceId === item.dataset.sourceId &&
el.closest('.channel-group')?.querySelector('.group-header')?.dataset.group === groupName);
(active.classList.contains('favorite-btn') ? replacement?.querySelector('.favorite-btn') : replacement)?.focus({ preventScroll: true });
}
this.container.scrollTop = append ? scrollTop : 0;
this.catalogStatus.textContent = '';
if (!this.hasMore && document.activeElement === this.loadMoreButton) {
this.catalogStatus.tabIndex = -1;
this.catalogStatus.textContent = 'All channels loaded';
this.catalogStatus.focus({ preventScroll: true });
}
this.loadMoreButton.textContent = 'Load more';
return true;
} catch (err) {
console.error('Error loading all channels:', err);
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
this.catalogStatus.textContent = 'Error loading channels';
this.loadMoreButton.textContent = 'Retry';
return false;
} finally {
if (version === this.catalogVersion) {
this.isLoading = false;
this.container.setAttribute('aria-busy', 'false');
this.loadMoreButton.disabled = false;
this.loadMoreButton.hidden = !this.hasMore && this.loadMoreButton.textContent !== 'Retry';
}
}
}
/**
* Load Xtream channels
*/
async loadXtreamChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups
const categoryGroups = categories.map(cat => ({
id: `xtream_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'xtream'
}));
this.groups = this.groups.concat(categoryGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `xtream_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
groupId: `xtream_${sourceId}_${stream.category_id}`,
// Use string comparison to handle type mismatches (number vs string category_id)
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'xtream'
}));
this.channels = this.channels.concat(channelList);
}
/**
* Load M3U channels
* Now uses unified Xtream-style API endpoints (backend supports both source types)
*/
async loadM3uChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const source = await API.sources.getById(sourceId);
// Use Xtream API endpoints - backend now supports M3U sources too
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups (keeping m3u sourceType for downstream compatibility)
const m3uGroups = categories.map(cat => ({
id: `m3u_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'm3u'
}));
this.groups = this.groups.concat(m3uGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `m3u_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
url: stream.stream_url, // M3U has direct URLs
program: stream.program || null,
zapBeforeStreaming: source?.zapBeforeStreaming || false,
zapDelay: source?.zapDelay ?? 1500,
groupId: `m3u_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'm3u'
}));
this.channels = this.channels.concat(channelList);
}
/**
* Load hidden items
*/
@ -907,7 +884,7 @@ class ChannelList {
const wasFavorite = this.visibleFavorites.has(key);
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
try {
// Optimistic update
@ -975,11 +952,11 @@ class ChannelList {
*/
updateFavoritesGroup(channel, isAdded) {
// 1. Update Data
if (!this.groupedChannels['Favorites']) {
this.groupedChannels['Favorites'] = [];
if (!this.groupedChannels[this.favoriteGroupKey]) {
this.groupedChannels[this.favoriteGroupKey] = [];
}
const favArray = this.groupedChannels['Favorites'];
const favArray = this.groupedChannels[this.favoriteGroupKey];
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
if (isAdded) {
@ -988,8 +965,10 @@ class ChannelList {
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
}
favArray.sort((a, b) => this.compareCatalogChannels(a, b));
// 2. Update DOM
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]');
const groupHeader = this.listContainer?.querySelector('.group-header.favorites-group');
if (!groupHeader) {
// If group doesn't exist and we're adding, we ideally should create it
@ -1006,13 +985,16 @@ class ChannelList {
if (isAdded) {
// Check if already in DOM (to avoid dupes)
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
if (!existingEl) {
const newEl = this.createChannelElement(channel);
groupChannels.appendChild(newEl);
const next = favArray[favArray.indexOf(channel) + 1];
const nextElement = next && [...groupChannels.children].find(element =>
element.dataset.channelId === String(next.id) && element.dataset.sourceId === String(next.sourceId));
groupChannels.insertBefore(newEl, nextElement || null);
}
} else {
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
if (existingEl) {
existingEl.remove();
}
@ -1041,7 +1023,7 @@ class ChannelList {
div.dataset.channelId = channel.id;
div.dataset.sourceId = channel.sourceId;
div.dataset.sourceType = channel.sourceType;
div.dataset.streamId = channel.streamId || '';
div.dataset.streamId = channel.streamId ?? '';
div.dataset.url = channel.url || '';
div.innerHTML = `
@ -1079,9 +1061,10 @@ class ChannelList {
* Select and play a channel
*/
async selectChannel(dataset) {
const channel = this.channels.find(c => c.id === dataset.channelId);
const channel = [...this.channels, ...(this.favoriteCatalog || []), ...(this.guideCatalog || [])].find(c => String(c.id) === String(dataset.channelId) && (dataset.sourceId === undefined || String(c.sourceId) === String(dataset.sourceId)));
if (!channel) return;
const previousChannel = this.currentChannel;
this.currentChannel = channel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
@ -1111,7 +1094,8 @@ class ChannelList {
// Fallback checks if still not found
if (!activeItem) {
activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
activeItem = [...this.container.querySelectorAll('.channel-item')].find(item =>
item.dataset.channelId === String(channel.id) && item.dataset.sourceId === String(channel.sourceId));
// If we fell back to channel ID, update currentRenderId to match what we found
if (activeItem && activeItem.dataset.renderId) {
this.currentRenderId = activeItem.dataset.renderId;
@ -1171,9 +1155,13 @@ class ChannelList {
streamUrl = channel.url;
}
if (dataset.catalogVersion !== undefined && dataset.catalogVersion !== this.catalogVersion) {
if (this.currentChannel === channel) this.currentChannel = previousChannel;
return;
}
// Play channel
if (window.app?.player) {
window.app.player.play(channel, streamUrl);
await window.app.player.play(channel, streamUrl);
}
}
@ -1331,7 +1319,7 @@ class ChannelList {
}
// Update DOM (All instances)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
btns.forEach(btn => {
if (isFavorite) {
@ -1355,88 +1343,66 @@ class ChannelList {
/**
* Select next channel in the current list
*/
selectNextChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
selectNextChannel() { return window.app?.player?.channelDown(); }
let currentIndex = -1;
selectPrevChannel() { return window.app?.player?.channelUp(); }
// Try to find by render ID first (strict visual order)
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
queueZap(direction) {
const version = this.catalogVersion;
const run = async () => {
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
// A page already requested by Load more belongs to this same scope.
if (this.catalogPagePromise?.version === version) {
if (!await this.catalogPagePromise.promise) return;
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (version !== this.catalogVersion || this.isLoading) return;
const current = window.app?.player?.currentChannel || this.currentChannel;
const selection = this.currentChannel;
const streamId = current?.streamId ?? current?.id;
if (current?.sourceId == null || streamId == null) return;
const matches = channel => String(channel.sourceId) === String(current.sourceId) &&
String(channel.streamId ?? channel.id) === String(streamId);
const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
(window.app?.player?.currentChannel || this.currentChannel) === current;
let channels = this.getVisibleChannels();
let index = channels.findIndex(matches);
// Home can start playback outside the loaded Live prefix. Resolve its
// source/stream identity in this exact scope before choosing any neighbour.
while (index === -1 && this.hasMore) {
if (!await this.loadCatalogPage(true) || !isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
}
}
if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % this.renderedChannels.length;
const nextChannel = this.renderedChannels[nextIndex];
this.selectChannel({
channelId: nextChannel.id,
sourceId: nextChannel.sourceId,
sourceType: nextChannel.sourceType,
streamId: nextChannel.streamId,
url: nextChannel.url,
renderId: nextChannel._renderId // Pass the unique render ID
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1;
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.renderedChannels.length) % this.renderedChannels.length;
const prevChannel = this.renderedChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url,
renderId: prevChannel._renderId
if (!isCurrent()) return;
if (index === -1) {
this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
return;
}
// Keep every fetched page: the previous neighbour is in the retained prefix.
// The API exposes a forward cursor only; wrapping backwards needs the true end.
while (this.hasMore && (direction < 0 ? index <= 0 : index >= channels.length - 1)) {
const cursor = this.nextCursor;
if (!await this.loadCatalogPage(true)) return;
if (!isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
if (index === -1 || (this.hasMore && this.nextCursor === cursor)) return;
}
if (!isCurrent() || index === -1 || !channels.length) return;
const neighbor = direction < 0
? channels[index === 0 ? channels.length - 1 : index - 1]
: channels[index >= channels.length - 1 ? 0 : index + 1];
try {
await this.selectChannel({ channelId: neighbor.id, sourceId: neighbor.sourceId, catalogVersion: version });
} catch (err) {
if (this.currentChannel === neighbor) this.currentChannel = current;
throw err;
}
};
this.zapQueue = (this.zapQueue || Promise.resolve()).then(run).catch(err => {
console.error('Error changing channel:', err);
});
return this.zapQueue;
}
/**
@ -1454,13 +1420,11 @@ class ChannelList {
* Get list of visible (non-hidden) channels in display order
*/
getVisibleChannels() {
const showHidden = this.showHiddenCheckbox?.checked ?? false;
return this.channels.filter(ch => {
if (showHidden) return true;
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
return !channelHidden && !groupHidden;
});
// Use the same groups and child order as render(), without the synthetic duplicates.
return (this.sortedGroups || []).filter(group => group !== this.favoriteGroupKey).flatMap(group =>
(this.groupedChannels[group] || []).filter(channel =>
this.showHidden || !this.isHidden('channel', channel.sourceId, channel.streamId ?? channel.id)));
}
}