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:
@ -4819,4 +4819,41 @@ kbd {
|
||||
font-size: 0.7rem;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Keep Live catalogue controls reachable alongside the scrolling list. */
|
||||
.sidebar-header-row .search-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-catalog-status,
|
||||
.live-catalog-more {
|
||||
flex-shrink: 0;
|
||||
margin: var(--space-sm);
|
||||
}
|
||||
|
||||
.live-catalog-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.channel-sidebar.collapsed .live-catalog-status,
|
||||
.channel-sidebar.collapsed .live-catalog-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.channel-tile-action {
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.channel-tile:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.channel-tile-action[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -127,8 +127,9 @@
|
||||
<div class="sidebar-header-row">
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<button type="button" class="search-clear" title="Clear search" aria-label="Clear search">×</button>
|
||||
</div>
|
||||
<button type="button" id="channel-search-submit" class="btn btn-sm">Search</button>
|
||||
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
||||
title="Collapse sidebar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
|
||||
@ -6,9 +6,10 @@ const API = {
|
||||
/**
|
||||
* Make API request
|
||||
*/
|
||||
async request(method, endpoint, data = null) {
|
||||
async request(method, endpoint, data = null, signal = undefined) {
|
||||
const options = {
|
||||
method,
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -40,6 +41,10 @@ const API = {
|
||||
throw new Error(result.error || `Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
if (method !== 'GET' && /^\/(sources|channels)(\/|$)/.test(endpoint)) {
|
||||
API.liveCatalogRevision = (API.liveCatalogRevision || 0) + 1;
|
||||
if (window.app?.channelList) window.app.channelList.guideCatalog = null;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
@ -61,6 +66,13 @@ const API = {
|
||||
|
||||
// Channels (hidden items)
|
||||
channels: {
|
||||
liveCatalog: (params = {}, signal) => {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') query.set(key, value);
|
||||
});
|
||||
return API.request('GET', `/channels/live-catalog?${query}`, null, signal);
|
||||
},
|
||||
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
||||
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
|
||||
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
|
||||
|
||||
@ -130,6 +130,7 @@ class App {
|
||||
|
||||
// Search clear buttons (global handler for all)
|
||||
document.querySelectorAll('.search-clear').forEach(btn => {
|
||||
if (btn.parentElement.querySelector('#channel-search')) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const wrapper = btn.closest('.search-wrapper');
|
||||
const input = wrapper?.querySelector('.search-input');
|
||||
|
||||
@ -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)));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -348,7 +348,7 @@ class EpgGuide {
|
||||
// Get channels and filter out hidden ones (always enforce hidden in EPG)
|
||||
// Note: We only check individual channel visibility, not group visibility
|
||||
// A group is implicitly visible if it has any visible children
|
||||
const playableChannels = (channelList.channels || []).filter(ch => {
|
||||
const playableChannels = (channelList.guideCatalog || channelList.channels || []).filter(ch => {
|
||||
// Use streamId (raw ID) for hidden check since that's what SourceManager stores
|
||||
const rawChannelId = ch.streamId || ch.id;
|
||||
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
|
||||
@ -915,7 +915,7 @@ class EpgGuide {
|
||||
async playChannel(channelName) {
|
||||
// Find channel in channel list and play
|
||||
if (window.app?.channelList) {
|
||||
const channel = window.app.channelList.channels.find(c =>
|
||||
const channel = (window.app.channelList.guideCatalog || window.app.channelList.channels).find(c =>
|
||||
c.name === channelName || c.tvgName === channelName
|
||||
);
|
||||
if (channel) {
|
||||
|
||||
@ -1259,32 +1259,12 @@ class VideoPlayer {
|
||||
* Go to previous channel
|
||||
*/
|
||||
channelUp() {
|
||||
if (!window.app?.channelList) return;
|
||||
const channels = window.app.channelList.getVisibleChannels();
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
: -1;
|
||||
|
||||
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
|
||||
return window.app?.channelList?.queueZap(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to next channel
|
||||
*/
|
||||
/** Go to the next channel, waiting for a catalogue page when needed. */
|
||||
channelDown() {
|
||||
if (!window.app?.channelList) return;
|
||||
const channels = window.app.channelList.getVisibleChannels();
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
: -1;
|
||||
|
||||
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
||||
return window.app?.channelList?.queueZap(1);
|
||||
}
|
||||
|
||||
async zapBeforeStreaming(channel, streamUrl, signal) {
|
||||
|
||||
@ -11,28 +11,71 @@ class GuidePage {
|
||||
// EPG guide will lazy load when shown
|
||||
}
|
||||
|
||||
async show() {
|
||||
// Ensure channel data is loaded before rendering EPG
|
||||
// This fixes a race condition where navigating directly to the Guide page
|
||||
// before visiting Live TV would result in an empty EPG.
|
||||
async loadCatalog() {
|
||||
const channelList = this.app.channelList;
|
||||
if (!channelList.channels || channelList.channels.length === 0) {
|
||||
await channelList.loadSources();
|
||||
await channelList.loadChannels();
|
||||
const revision = API.liveCatalogRevision || 0;
|
||||
if (channelList.guideCatalog && channelList.guideCatalogRevision === revision) return;
|
||||
if (this.catalogLoad?.revision === revision) return this.catalogLoad.promise;
|
||||
const promise = (async () => {
|
||||
const channels = [];
|
||||
let cursor;
|
||||
do {
|
||||
// Guide ignores group visibility; EpgGuide filters individually hidden channels.
|
||||
const result = await API.channels.liveCatalog({ limit: 500, cursor, includeHidden: true });
|
||||
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
|
||||
channels.push(...result.items);
|
||||
const next = result.page.hasMore ? result.page.nextCursor : null;
|
||||
if (result.page.hasMore && (!next || next === cursor)) throw new Error('Invalid catalogue cursor');
|
||||
cursor = next;
|
||||
} while (cursor);
|
||||
await channelList.loadHiddenItems();
|
||||
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
|
||||
channelList.guideCatalog = channels;
|
||||
channelList.guideCatalogRevision = revision;
|
||||
})();
|
||||
this.catalogLoad = { revision, promise };
|
||||
try { await promise; }
|
||||
finally {
|
||||
if (this.catalogLoad?.promise === promise) this.catalogLoad = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only load EPG data if not already loaded
|
||||
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
|
||||
await this.app.epgGuide.loadEpg();
|
||||
} else {
|
||||
// Just re-render with existing data (updates time position)
|
||||
this.app.epgGuide.render();
|
||||
async show() {
|
||||
const visit = this.visit = (this.visit || 0) + 1;
|
||||
const container = this.app.epgGuide.container;
|
||||
const channelList = this.app.channelList;
|
||||
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
|
||||
if (needsCatalog) {
|
||||
container.textContent = 'Loading…';
|
||||
container.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
try {
|
||||
await this.loadCatalog();
|
||||
if (visit !== this.visit) return;
|
||||
if (!this.app.epgGuide.programmes?.length) {
|
||||
await this.app.epgGuide.loadEpg();
|
||||
} else {
|
||||
this.app.epgGuide.render();
|
||||
}
|
||||
} catch (err) {
|
||||
if (visit !== this.visit) return;
|
||||
container.textContent = 'Error loading channels';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'btn btn-sm';
|
||||
retry.textContent = 'Retry';
|
||||
retry.addEventListener('click', () => this.show());
|
||||
container.appendChild(retry);
|
||||
} finally {
|
||||
if (visit === this.visit) container.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Page is hidden
|
||||
this.visit = (this.visit || 0) + 1;
|
||||
this.app.epgGuide.container.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
window.GuidePage = GuidePage;
|
||||
|
||||
@ -195,34 +195,13 @@ class HomePage {
|
||||
const section = document.getElementById('favorite-channels-section');
|
||||
if (!list || !section) return;
|
||||
|
||||
const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1;
|
||||
try {
|
||||
// Fetch favorite channels for current user
|
||||
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
|
||||
|
||||
if (!favorites || favorites.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure channel list is loaded to resolve channel details
|
||||
const channelList = this.app.channelList;
|
||||
if (!channelList.channels || channelList.channels.length === 0) {
|
||||
await channelList.loadSources();
|
||||
await channelList.loadChannels();
|
||||
}
|
||||
|
||||
// Match favorites to channel data
|
||||
const channels = [];
|
||||
for (const fav of favorites) {
|
||||
// Find channel in loaded channel list
|
||||
const channel = channelList.channels.find(ch =>
|
||||
String(ch.sourceId) === String(fav.source_id) &&
|
||||
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
|
||||
);
|
||||
if (channel) {
|
||||
channels.push({ ...channel, favoriteId: fav.id });
|
||||
}
|
||||
}
|
||||
const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 });
|
||||
if (version !== this.favoriteCatalogVersion) return;
|
||||
const channels = result.items;
|
||||
channelList.favoriteCatalog = channels;
|
||||
|
||||
if (channels.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
||||
@ -234,6 +213,7 @@ class HomePage {
|
||||
|
||||
// Attach click handlers
|
||||
list.querySelectorAll('.channel-tile').forEach(tile => {
|
||||
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId));
|
||||
tile.addEventListener('click', () => {
|
||||
const channelId = tile.dataset.channelId;
|
||||
const sourceId = tile.dataset.sourceId;
|
||||
@ -241,6 +221,48 @@ class HomePage {
|
||||
});
|
||||
});
|
||||
|
||||
let nextCursor = result.page.nextCursor;
|
||||
if (result.page.hasMore) {
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'channel-tile channel-tile-action';
|
||||
more.textContent = 'Load more';
|
||||
list.appendChild(more);
|
||||
more.addEventListener('click', async event => {
|
||||
const keyboard = event.detail === 0;
|
||||
const scrollLeft = list.scrollLeft;
|
||||
more.disabled = true;
|
||||
more.textContent = 'Loading…';
|
||||
let firstNewTile;
|
||||
try {
|
||||
const page = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200, cursor: nextCursor });
|
||||
if (version !== this.favoriteCatalogVersion) return;
|
||||
channelList.favoriteCatalog.push(...page.items);
|
||||
for (const channel of page.items) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = this.createChannelTile(channel);
|
||||
const tile = wrapper.firstElementChild;
|
||||
tile.addEventListener('click', () => this.playChannel(channel.id, channel.sourceId));
|
||||
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, channel.id, channel.sourceId));
|
||||
firstNewTile ||= tile;
|
||||
list.insertBefore(tile, more);
|
||||
}
|
||||
nextCursor = page.page.nextCursor;
|
||||
if (keyboard && firstNewTile) {
|
||||
firstNewTile.focus({ preventScroll: true });
|
||||
firstNewTile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
} else {
|
||||
list.scrollLeft = scrollLeft;
|
||||
}
|
||||
more.hidden = !page.page.hasMore;
|
||||
more.textContent = 'Load more';
|
||||
this.updateScrollArrows();
|
||||
} catch (err) {
|
||||
more.textContent = 'Retry';
|
||||
} finally { more.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// Update scroll arrows after content renders
|
||||
this.updateScrollArrows();
|
||||
|
||||
@ -254,9 +276,11 @@ class HomePage {
|
||||
const logo = channel.tvgLogo || '/img/placeholder.png';
|
||||
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
|
||||
const name = channel.name || 'Unknown';
|
||||
const channelId = this.app.channelList.escapeHtml(String(channel.id));
|
||||
const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId));
|
||||
|
||||
return `
|
||||
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
|
||||
<div class="channel-tile" role="button" tabindex="0" data-channel-id="${channelId}" data-source-id="${sourceId}">
|
||||
<div class="tile-logo">
|
||||
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||
</div>
|
||||
@ -265,29 +289,21 @@ class HomePage {
|
||||
`;
|
||||
}
|
||||
|
||||
playChannel(channelId, sourceId) {
|
||||
// Navigate to Live TV and select the channel
|
||||
this.app.navigateTo('live');
|
||||
activateFavoriteTile(event, channelId, sourceId) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.playChannel(channelId, sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure page is ready
|
||||
setTimeout(() => {
|
||||
const channelList = this.app.channelList;
|
||||
if (channelList) {
|
||||
// Find and select the channel
|
||||
const channel = channelList.channels.find(ch =>
|
||||
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
|
||||
);
|
||||
if (channel) {
|
||||
channelList.selectChannel({
|
||||
channelId: channel.id,
|
||||
sourceId: channel.sourceId,
|
||||
sourceType: channel.sourceType,
|
||||
streamId: channel.streamId || '',
|
||||
url: channel.url || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
playChannel(channelId, sourceId) {
|
||||
const channelList = this.app.channelList;
|
||||
const channel = channelList.favoriteCatalog?.find(ch =>
|
||||
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
|
||||
if (!channel) return;
|
||||
this.app.navigateTo('live');
|
||||
// selectChannel resolves Home DTOs independently of the active Live filters.
|
||||
channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
|
||||
}
|
||||
|
||||
renderHistory(items) {
|
||||
|
||||
Reference in New Issue
Block a user