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:
@ -4826,7 +4826,7 @@ kbd {
|
||||
}
|
||||
|
||||
.live-catalog-status,
|
||||
.live-catalog-more {
|
||||
.live-catalog-retry {
|
||||
flex-shrink: 0;
|
||||
margin: var(--space-sm);
|
||||
}
|
||||
@ -4836,7 +4836,7 @@ kbd {
|
||||
}
|
||||
|
||||
.channel-sidebar.collapsed .live-catalog-status,
|
||||
.channel-sidebar.collapsed .live-catalog-more {
|
||||
.channel-sidebar.collapsed .live-catalog-retry {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -4857,3 +4857,7 @@ kbd {
|
||||
.channel-tile-action[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.live-catalog-retry[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user