diff --git a/frontend/js/state.js b/frontend/js/state.js index 08bcf20..d6c8037 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -18,7 +18,8 @@ App.state = { feedOpen: false, feedMuted: true, feedRenderedIds: new Set(), - feedActiveSlide: null + feedActiveSlide: null, + groupCursors: null }; // Local storage keys used across modules. diff --git a/frontend/js/storage.js b/frontend/js/storage.js index 1da8b1c..1ff329f 100644 --- a/frontend/js/storage.js +++ b/frontend/js/storage.js @@ -82,6 +82,38 @@ App.session = App.session || {}; return hydrated; }; + // Builds a pseudo-channel representing a whole channel group, used so the + // rest of the app (session, filters, video loading) can treat a selected + // group the same way it treats a single channel. + App.session.buildGroupChannel = function(group, channels) { + if (!group) return null; + const knownIds = new Set((channels || []).map((channel) => channel.id)); + const channelIds = Array.isArray(group.channelIds) ? + group.channelIds.filter((id) => knownIds.has(id)) : + []; + return { + id: `group:${group.id}`, + name: group.title || group.id, + isGroup: true, + groupId: group.id, + channelIds: channelIds + }; + }; + + // Resolves a stored channel id (which may reference a single channel or a + // "group:" pseudo-channel) against a server's status payload. + App.session.resolveChannelById = function(serverData, channelId) { + if (!serverData || !channelId) return null; + const channels = Array.isArray(serverData.channels) ? serverData.channels : []; + if (channelId.startsWith('group:')) { + const groupId = channelId.slice('group:'.length); + const groups = Array.isArray(serverData.channelGroups) ? serverData.channelGroups : []; + const group = groups.find((g) => g.id === groupId); + return App.session.buildGroupChannel(group, channels); + } + return channels.find((channel) => channel.id === channelId) || null; + }; + App.session.savePreference = function(session) { if (!session || !session.server || !session.channel) return; const prefs = App.storage.getPreferences(); @@ -176,7 +208,7 @@ App.session = App.session || {}; const prefs = App.storage.getPreferences(); const serverPrefs = prefs[selectedServerKey] || {}; const preferredChannelId = serverPrefs.channelId; - const channel = serverData.channels.find((ch) => ch.id === preferredChannelId) || serverData.channels[0]; + const channel = App.session.resolveChannelById(serverData, preferredChannelId) || serverData.channels[0]; const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null; const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel); diff --git a/frontend/js/ui.js b/frontend/js/ui.js index cb9b6b0..97f1312 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -166,12 +166,13 @@ App.ui = App.ui || {}; sourceSelect.onchange = () => { const selectedServerUrl = sourceSelect.value; const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl); - const channels = selectedServer && selectedServer.data && selectedServer.data.channels ? - selectedServer.data.channels : - []; + const selectedServerData = selectedServer && selectedServer.data ? selectedServer.data : null; + const channels = selectedServerData && selectedServerData.channels ? selectedServerData.channels : []; const prefs = App.storage.getPreferences(); const serverPrefs = prefs[selectedServerUrl] || {}; - const preferredChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || null; + const preferredChannel = selectedServerData ? + App.session.resolveChannelById(selectedServerData, serverPrefs.channelId) : + null; const nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null); const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : @@ -188,8 +189,9 @@ App.ui = App.ui || {}; }; const activeServer = serverEntries.find((entry) => entry.url === (session && session.server)); - const availableChannels = activeServer && activeServer.data && activeServer.data.channels ? - [...activeServer.data.channels] : + const activeServerData = activeServer && activeServer.data ? activeServer.data : null; + const availableChannels = activeServerData && activeServerData.channels ? + [...activeServerData.channels] : []; availableChannels.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase(); @@ -197,21 +199,54 @@ App.ui = App.ui || {}; return nameA.localeCompare(nameB); }); + const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ? + activeServerData.channelGroups : + []; + channelSelect.innerHTML = ""; - availableChannels.forEach((channel) => { - const option = document.createElement('option'); - option.value = channel.id; - option.textContent = channel.name || channel.id; - channelSelect.appendChild(option); + const groupedChannelIds = new Set(); + channelGroups.forEach((group) => { + const channelIds = Array.isArray(group.channelIds) ? + group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) : + []; + if (channelIds.length === 0) return; + channelIds.forEach((id) => groupedChannelIds.add(id)); + + const optgroup = document.createElement('optgroup'); + optgroup.label = group.title || group.id; + + const groupOption = document.createElement('option'); + groupOption.value = `group:${group.id}`; + groupOption.textContent = `All ${group.title || group.id}`; + optgroup.appendChild(groupOption); + + channelIds.forEach((id) => { + const channel = availableChannels.find((ch) => ch.id === id); + const option = document.createElement('option'); + option.value = channel.id; + option.textContent = channel.name || channel.id; + optgroup.appendChild(option); + }); + + channelSelect.appendChild(optgroup); }); + availableChannels + .filter((channel) => !groupedChannelIds.has(channel.id)) + .forEach((channel) => { + const option = document.createElement('option'); + option.value = channel.id; + option.textContent = channel.name || channel.id; + channelSelect.appendChild(option); + }); + if (session && session.channel) { channelSelect.value = session.channel.id; } channelSelect.onchange = () => { const selectedId = channelSelect.value; - const nextChannel = availableChannels.find((channel) => channel.id === selectedId) || null; + const nextChannel = activeServerData ? App.session.resolveChannelById(activeServerData, selectedId) : null; const prefs = App.storage.getPreferences(); const serverPrefs = prefs[session.server] || {}; const savedOptions = nextChannel && serverPrefs.optionsByChannel ? @@ -286,8 +321,9 @@ App.ui = App.ui || {}; const nextServerUrl = remaining[0].url; const nextServer = remaining[0]; const serverPrefs = prefs[nextServerUrl] || {}; - const channels = nextServer.data && nextServer.data.channels ? nextServer.data.channels : []; - const nextChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || channels[0] || null; + const nextServerData = nextServer.data || null; + const channels = nextServerData && nextServerData.channels ? nextServerData.channels : []; + const nextChannel = (nextServerData && App.session.resolveChannelById(nextServerData, serverPrefs.channelId)) || channels[0] || null; const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null; const nextSession = { server: nextServerUrl, @@ -360,7 +396,9 @@ App.ui = App.ui || {}; if (!session || !session.channel || !Array.isArray(session.channel.options)) { const empty = document.createElement('div'); empty.className = 'filters-empty'; - empty.textContent = 'No filters available for this channel.'; + empty.textContent = session && session.channel && session.channel.isGroup ? + 'No filters available when browsing a whole channel group.' : + 'No filters available for this channel.'; container.appendChild(empty); return; } diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 5d27f9a..e6fe33f 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -137,12 +137,91 @@ App.videos = App.videos || {}; }); }; + // Each channel in a group sends back a different number of videos per + // page, so a small per-channel count keeps any one channel from + // dominating a single interleaved batch. + const GROUP_CHANNEL_PAGE_SIZE = 4; + + // Fetches one page from every channel in a group and zips the results + // together round-robin so the feed alternates between sources instead of + // running through one channel's videos before moving to the next. + App.videos.loadGroupVideos = async function(session) { + const group = session.channel; + const searchInput = document.getElementById('search-input'); + const query = searchInput ? searchInput.value : ""; + + if (!state.groupCursors || state.groupCursors.groupId !== group.id || state.groupCursors.query !== query) { + state.groupCursors = { + groupId: group.id, + query: query, + channels: group.channelIds.map((id) => ({ id, page: 1, hasNextPage: true })) + }; + } + + const active = state.groupCursors.channels.filter((cursor) => cursor.hasNextPage); + if (active.length === 0) { + state.hasNextPage = false; + App.videos.updateLoadMoreState(); + return; + } + + try { + state.isLoading = true; + App.videos.updateLoadMoreState(); + state.currentLoadController = new AbortController(); + + const results = await Promise.all(active.map(async (cursor) => { + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel: cursor.id, + query: query || "", + page: cursor.page, + perPage: GROUP_CHANNEL_PAGE_SIZE, + server: session.server + }), + signal: state.currentLoadController.signal + }); + const data = await response.json(); + const items = data && Array.isArray(data.items) ? data.items : []; + cursor.page++; + cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true); + return items; + })); + + const interleaved = []; + const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0); + for (let i = 0; i < maxLen; i++) { + results.forEach((items) => { + if (items[i]) interleaved.push(items[i]); + }); + } + + App.videos.renderVideos({ items: interleaved }); + state.hasNextPage = state.groupCursors.channels.some((cursor) => cursor.hasNextPage); + App.videos.ensureViewportFilled(); + } catch (err) { + if (err.name !== 'AbortError') { + console.error("Failed to load group videos:", err); + } + } finally { + state.isLoading = false; + state.currentLoadController = null; + App.videos.updateLoadMoreState(); + } + }; + // Fetches the next page of videos and renders them into the grid. App.videos.loadVideos = async function() { const session = App.storage.getSession(); - if (!session) return; + if (!session || !session.channel) return; if (state.isLoading || !state.hasNextPage) return; + if (session.channel.isGroup) { + return App.videos.loadGroupVideos(session); + } + const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; @@ -338,6 +417,7 @@ App.videos = App.videos || {}; state.hasNextPage = true; state.renderedVideoIds.clear(); state.loadedVideos = []; + state.groupCursors = null; const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; if (App.feed && typeof App.feed.reset === 'function') { @@ -357,6 +437,7 @@ App.videos = App.videos || {}; state.hasNextPage = true; state.renderedVideoIds.clear(); state.loadedVideos = []; + state.groupCursors = null; const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; if (App.feed && typeof App.feed.reset === 'function') {