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:
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
@ -289,62 +309,84 @@ class ChannelList {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user