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:
@ -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