channel groups
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user