feat(live): remove pagination from Live catalog (#51)

Keep server-side search and cursor pagination for Home and Guide while allowing Live to load the complete filtered catalogue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 23:51:02 +02:00
parent ccdd3cb027
commit d64a902155
4 changed files with 197 additions and 176 deletions

View File

@ -24,19 +24,18 @@ class ChannelList {
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.catalogRetryButton = document.createElement('button');
this.catalogRetryButton.type = 'button';
this.catalogRetryButton.className = 'btn btn-sm live-catalog-retry';
this.catalogRetryButton.textContent = 'Retry';
this.catalogRetryButton.hidden = true;
this.catalogStatus.after(this.catalogRetryButton);
this.catalogRetryButton.addEventListener('click', () => this.loadChannels());
this.loadCollapsedState();
this.init();
@ -167,7 +166,7 @@ class ChannelList {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.loadMoreButton.hidden = true;
this.catalogRetryButton.hidden = true;
this.searchTimeout = setTimeout(searchNow, 300);
});
this.searchInput.addEventListener('keydown', e => {
@ -741,7 +740,7 @@ class ChannelList {
/**
* Load channels from selected source
*/
async loadChannels() {
loadChannels() {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
@ -751,84 +750,72 @@ class ChannelList {
sourceId: this.sourceSelect.value.split(':')[1],
q: this.catalogQuery,
includeHidden: this.showHiddenCheckbox?.checked || false,
limit: 200
full: true
};
this.channels = [];
this.renderedChannels = [];
this.nextCursor = null;
this.hasMore = false;
this.sortedGroups = [];
this.groupedChannels = Object.create(null);
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 };
const promise = this.fetchCatalog();
this.catalogRequest = { version, promise };
promise.finally(() => {
if (this.catalogPagePromise?.promise === promise) this.catalogPagePromise = null;
if (this.catalogRequest?.promise === promise) this.catalogRequest = null;
});
return promise;
}
async fetchCatalogPage(append) {
if (append && (this.isLoading || !this.hasMore)) return false;
async loadAllChannels() { return this.loadChannels(); }
async fetchCatalog() {
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();
const active = document.activeElement;
const item = active?.closest('.channel-item');
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
const scrollTop = this.container.scrollTop;
const retryHadFocus = active === this.catalogRetryButton;
this.catalogRetryButton.hidden = true;
this.container.replaceChildren();
try {
const [result] = await Promise.all([
API.channels.liveCatalog({ ...this.catalogParams, cursor: append ? this.nextCursor : undefined }, controller.signal),
...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()])
API.channels.liveCatalog(this.catalogParams, controller.signal),
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.channels = result.items;
this.render();
if (item) {
if (item && document.activeElement === document.body) {
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 });
const focusTarget = active.classList.contains('favorite-btn') ? replacement?.querySelector('.favorite-btn') : replacement;
if (focusTarget) {
focusTarget.focus({ preventScroll: true });
this.container.scrollTop = scrollTop;
} else {
this.searchInput.focus({ preventScroll: true });
}
} else if (retryHadFocus && (document.activeElement === this.catalogRetryButton || document.activeElement === document.body)) {
this.searchInput.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) {
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
this.catalogStatus.textContent = 'Error loading channels';
this.loadMoreButton.textContent = 'Retry';
this.catalogRetryButton.hidden = false;
if (item && document.activeElement === document.body) this.catalogRetryButton.focus({ preventScroll: true });
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';
}
}
}
@ -1351,9 +1338,8 @@ class ChannelList {
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;
if (this.catalogRequest?.version === version) {
if (!await this.catalogRequest.promise) return;
}
if (version !== this.catalogVersion || this.isLoading) return;
const current = window.app?.player?.currentChannel || this.currentChannel;
@ -1364,30 +1350,13 @@ class ChannelList {
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);
}
const channels = this.getVisibleChannels();
const index = channels.findIndex(matches);
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]