channel groups

This commit is contained in:
Simon
2026-06-18 11:19:15 +00:00
parent 08c96d5903
commit 1b6fa5f924
4 changed files with 170 additions and 18 deletions

View File

@@ -18,7 +18,8 @@ App.state = {
feedOpen: false, feedOpen: false,
feedMuted: true, feedMuted: true,
feedRenderedIds: new Set(), feedRenderedIds: new Set(),
feedActiveSlide: null feedActiveSlide: null,
groupCursors: null
}; };
// Local storage keys used across modules. // Local storage keys used across modules.

View File

@@ -82,6 +82,38 @@ App.session = App.session || {};
return hydrated; 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:<id>" 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) { App.session.savePreference = function(session) {
if (!session || !session.server || !session.channel) return; if (!session || !session.server || !session.channel) return;
const prefs = App.storage.getPreferences(); const prefs = App.storage.getPreferences();
@@ -176,7 +208,7 @@ App.session = App.session || {};
const prefs = App.storage.getPreferences(); const prefs = App.storage.getPreferences();
const serverPrefs = prefs[selectedServerKey] || {}; const serverPrefs = prefs[selectedServerKey] || {};
const preferredChannelId = serverPrefs.channelId; 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 savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel); const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);

View File

@@ -166,12 +166,13 @@ App.ui = App.ui || {};
sourceSelect.onchange = () => { sourceSelect.onchange = () => {
const selectedServerUrl = sourceSelect.value; const selectedServerUrl = sourceSelect.value;
const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl); const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl);
const channels = selectedServer && selectedServer.data && selectedServer.data.channels ? const selectedServerData = selectedServer && selectedServer.data ? selectedServer.data : null;
selectedServer.data.channels : const channels = selectedServerData && selectedServerData.channels ? selectedServerData.channels : [];
[];
const prefs = App.storage.getPreferences(); const prefs = App.storage.getPreferences();
const serverPrefs = prefs[selectedServerUrl] || {}; 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 nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null);
const savedOptions = nextChannel && serverPrefs.optionsByChannel ? const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
serverPrefs.optionsByChannel[nextChannel.id] : serverPrefs.optionsByChannel[nextChannel.id] :
@@ -188,8 +189,9 @@ App.ui = App.ui || {};
}; };
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server)); const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
const availableChannels = activeServer && activeServer.data && activeServer.data.channels ? const activeServerData = activeServer && activeServer.data ? activeServer.data : null;
[...activeServer.data.channels] : const availableChannels = activeServerData && activeServerData.channels ?
[...activeServerData.channels] :
[]; [];
availableChannels.sort((a, b) => { availableChannels.sort((a, b) => {
const nameA = (a.name || a.id || '').toLowerCase(); const nameA = (a.name || a.id || '').toLowerCase();
@@ -197,21 +199,54 @@ App.ui = App.ui || {};
return nameA.localeCompare(nameB); return nameA.localeCompare(nameB);
}); });
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
activeServerData.channelGroups :
[];
channelSelect.innerHTML = ""; channelSelect.innerHTML = "";
availableChannels.forEach((channel) => { const groupedChannelIds = new Set();
const option = document.createElement('option'); channelGroups.forEach((group) => {
option.value = channel.id; const channelIds = Array.isArray(group.channelIds) ?
option.textContent = channel.name || channel.id; group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
channelSelect.appendChild(option); [];
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) { if (session && session.channel) {
channelSelect.value = session.channel.id; channelSelect.value = session.channel.id;
} }
channelSelect.onchange = () => { channelSelect.onchange = () => {
const selectedId = channelSelect.value; 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 prefs = App.storage.getPreferences();
const serverPrefs = prefs[session.server] || {}; const serverPrefs = prefs[session.server] || {};
const savedOptions = nextChannel && serverPrefs.optionsByChannel ? const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
@@ -286,8 +321,9 @@ App.ui = App.ui || {};
const nextServerUrl = remaining[0].url; const nextServerUrl = remaining[0].url;
const nextServer = remaining[0]; const nextServer = remaining[0];
const serverPrefs = prefs[nextServerUrl] || {}; const serverPrefs = prefs[nextServerUrl] || {};
const channels = nextServer.data && nextServer.data.channels ? nextServer.data.channels : []; const nextServerData = nextServer.data || null;
const nextChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || channels[0] || 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 savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null;
const nextSession = { const nextSession = {
server: nextServerUrl, server: nextServerUrl,
@@ -360,7 +396,9 @@ App.ui = App.ui || {};
if (!session || !session.channel || !Array.isArray(session.channel.options)) { if (!session || !session.channel || !Array.isArray(session.channel.options)) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'filters-empty'; 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); container.appendChild(empty);
return; return;
} }

View File

@@ -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. // Fetches the next page of videos and renders them into the grid.
App.videos.loadVideos = async function() { App.videos.loadVideos = async function() {
const session = App.storage.getSession(); const session = App.storage.getSession();
if (!session) return; if (!session || !session.channel) return;
if (state.isLoading || !state.hasNextPage) return; if (state.isLoading || !state.hasNextPage) return;
if (session.channel.isGroup) {
return App.videos.loadGroupVideos(session);
}
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : ""; const query = searchInput ? searchInput.value : "";
@@ -338,6 +417,7 @@ App.videos = App.videos || {};
state.hasNextPage = true; state.hasNextPage = true;
state.renderedVideoIds.clear(); state.renderedVideoIds.clear();
state.loadedVideos = []; state.loadedVideos = [];
state.groupCursors = null;
const grid = document.getElementById('video-grid'); const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = ""; if (grid) grid.innerHTML = "";
if (App.feed && typeof App.feed.reset === 'function') { if (App.feed && typeof App.feed.reset === 'function') {
@@ -357,6 +437,7 @@ App.videos = App.videos || {};
state.hasNextPage = true; state.hasNextPage = true;
state.renderedVideoIds.clear(); state.renderedVideoIds.clear();
state.loadedVideos = []; state.loadedVideos = [];
state.groupCursors = null;
const grid = document.getElementById('video-grid'); const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = ""; if (grid) grid.innerHTML = "";
if (App.feed && typeof App.feed.reset === 'function') { if (App.feed && typeof App.feed.reset === 'function') {