421 lines
23 KiB
JavaScript
Executable File
421 lines
23 KiB
JavaScript
Executable File
/** Public PeerTube publications. No cookies, embeds, HTML sinks or persistent storage. */
|
|
export const ORIGIN = 'https://peertube.eom.dev';
|
|
export const ORIGINALS_UUID = 'bde0cf4d-318f-4865-b205-b11104a7c46b';
|
|
export const MAX_BYTES = 2 * 1024 * 1024;
|
|
export const REFRESH_MS = 15 * 60 * 1000;
|
|
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
const uuidPattern = new RegExp(`^${UUID}$`);
|
|
const watchPattern = new RegExp(`^/(?:w/[A-Za-z0-9]+|videos/watch/${UUID})$`);
|
|
const CHANNEL_NAME = '[A-Za-z][A-Za-z0-9_.-]{0,99}';
|
|
const isChannelName = value => typeof value === 'string' && new RegExp(`^${CHANNEL_NAME}$`).test(value) && !/[\r\n]/.test(value);
|
|
|
|
/** Reject ambiguous input BEFORE URL normalization can erase traversal or an explicit port. */
|
|
export function validateOriginURL(value) {
|
|
if (typeof value !== 'string' || value.length > 2048 || /[\s\\%<>"'\x00-\x1f\x7f]/.test(value)) return null;
|
|
if (!value.startsWith('/') && !value.startsWith(`${ORIGIN}/`)) return null;
|
|
if (value.startsWith('//') || /(?:^|\/)\.{1,2}(?:\/|[?#]|$)/.test(value)) return null;
|
|
try {
|
|
const url = new URL(value, ORIGIN);
|
|
if (url.origin !== ORIGIN || url.username || url.password || url.port || url.hash) return null;
|
|
return url;
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function validateWatchURL(value) {
|
|
const url = validateOriginURL(value);
|
|
return url && !url.search && watchPattern.test(url.pathname) ? url.href : null;
|
|
}
|
|
|
|
export function validateThumbnailURL(value) {
|
|
const url = validateOriginURL(value);
|
|
return url && !url.search && /^\/(?:lazy-static|static)\/thumbnails\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jpg|jpeg|png|webp|avif)$/i.test(url.pathname) ? url.href : null;
|
|
}
|
|
|
|
function text(value, fallback = '') {
|
|
return typeof value === 'string' ? value.trim().slice(0, 500) : fallback;
|
|
}
|
|
|
|
export function normalizeDate(value, { now = Date.now(), maxFutureMs = Infinity } = {}) {
|
|
if (typeof value !== 'string' || value.length > 100 || !value.trim()) return null;
|
|
const timestamp = Date.parse(value);
|
|
if (!Number.isFinite(timestamp) || timestamp > now + maxFutureMs) return null;
|
|
// Date.parse normalizes impossible ISO/RFC calendar dates (e.g. February 30).
|
|
const calendar = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
const rfc = /^(?:[A-Za-z]{3},\s*)?(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})\s/i.exec(value);
|
|
if (!calendar && !rfc) return null;
|
|
const year = +(calendar ? calendar[1] : rfc[3]);
|
|
const month = calendar ? +calendar[2] - 1 : ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'].indexOf(rfc[2].toLowerCase());
|
|
const day = +(calendar ? calendar[3] : rfc[1]);
|
|
const check = new Date(0);
|
|
check.setUTCFullYear(year, month, day);
|
|
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month || check.getUTCDate() !== day) return null;
|
|
return new Date(timestamp).toISOString();
|
|
}
|
|
|
|
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
|
|
/** URL identity deliberately does not guess that a short ID equals a UUID. */
|
|
export function sortAndDedupe(items, limit = 3, dateOptions) {
|
|
if (!Array.isArray(items) || items.length > 1000) throw new Error('Too many publications');
|
|
if (!Number.isInteger(limit) || limit < 0 || limit > 1000) throw new Error('Invalid publication limit');
|
|
const normalized = items.flatMap(item => {
|
|
if (!item || typeof item !== 'object') return [];
|
|
const url = validateWatchURL(item.url);
|
|
const publishedAt = normalizeDate(item.publishedAt, dateOptions);
|
|
if (!url || !publishedAt) return [];
|
|
return [{ url, publishedAt, title: text(item.title, 'Untitled video') || 'Untitled video',
|
|
source: text(item.source, 'PeerTube'), thumbnail: validateThumbnailURL(item.thumbnail) }];
|
|
});
|
|
normalized.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt) || compareText(a.url, b.url) || compareText(a.title, b.title) || compareText(a.source, b.source));
|
|
const seen = new Set();
|
|
return normalized.filter(item => !seen.has(item.url) && seen.add(item.url)).slice(0, limit);
|
|
}
|
|
|
|
export function normalizeJSONVideos(entries, source = 'PeerTube', limit = 3, dateOptions) {
|
|
if (!Array.isArray(entries) || entries.length > 1000) throw new Error('Invalid video list');
|
|
return sortAndDedupe(entries.flatMap(entry => {
|
|
const video = entry?.video ?? entry;
|
|
if (!video || typeof video !== 'object' || (video.privacy !== undefined && video.privacy?.id !== 1)) return [];
|
|
// Validate the supplied URL first: UUID metadata must never rescue an unsafe origin/path.
|
|
const originalURL = validateWatchURL(video.url);
|
|
if (!originalURL) return [];
|
|
const url = typeof video.uuid === 'string' && uuidPattern.test(video.uuid)
|
|
? `${ORIGIN}/videos/watch/${video.uuid.toLowerCase()}` : originalURL;
|
|
return [{ url, title: video.name ?? video.title, publishedAt: video.publishedAt,
|
|
thumbnail: video.thumbnailPath, source }];
|
|
}), limit, dateOptions);
|
|
}
|
|
|
|
function abortError(message = 'Request aborted') { return new DOMException(message, 'AbortError'); }
|
|
|
|
/** The timer covers both headers and streamed body, including non-cooperative injected fetches. */
|
|
export async function fetchText(url, { fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES, shouldFetch = () => true } = {}) {
|
|
const safe = validateOriginURL(url);
|
|
if (!safe || !safe.pathname.startsWith('/api/v1/')) throw new Error('Invalid request');
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 || !Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
|
|
const controller = new AbortController();
|
|
let reader;
|
|
let timer;
|
|
let rejectAbort;
|
|
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
|
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
|
|
signal?.addEventListener('abort', cancel, { once: true });
|
|
timer = setTimeout(cancel, timeoutMs);
|
|
const work = async () => {
|
|
if (signal?.aborted || !shouldFetch()) throw abortError();
|
|
const response = await fetchImpl(safe.href, { signal: controller.signal, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
|
headers: { Accept: 'application/json' } });
|
|
if (controller.signal.aborted) throw abortError();
|
|
if (!response.ok || response.redirected || (response.url && response.url !== safe.href)) throw new Error('PeerTube request failed');
|
|
const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
if (mime !== 'application/json') throw new Error('Unexpected content type');
|
|
const length = response.headers.get('content-length');
|
|
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
|
|
// Refuse unstreamable bodies rather than allocate an unbounded response.text().
|
|
if (!response.body?.getReader) throw new Error('Streaming response required');
|
|
reader = response.body.getReader();
|
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
let result = '';
|
|
let bytes = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (controller.signal.aborted) throw abortError();
|
|
if (done) break;
|
|
bytes += value.byteLength;
|
|
if (bytes > maxBytes) throw new Error('Response too large');
|
|
result += decoder.decode(value, { stream: true });
|
|
}
|
|
return result + decoder.decode();
|
|
};
|
|
try { return await Promise.race([work(), aborted]); }
|
|
finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', cancel);
|
|
controller.abort();
|
|
if (reader) void reader.cancel().catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function fetchJSON(url, dependencies) {
|
|
return JSON.parse(await fetchText(url, dependencies));
|
|
}
|
|
|
|
export function channelVideosURL(name, { count = 100, start = 0 } = {}) {
|
|
if (!isChannelName(name)) throw new Error('Invalid channel name');
|
|
if (!Number.isInteger(count) || count < 1 || count > 100 || !Number.isSafeInteger(start) || start < 0 || start > 1000) throw new Error('Invalid channel pagination');
|
|
return `${ORIGIN}/api/v1/video-channels/${name}/videos?count=${count}&start=${start}&sort=-publishedAt`;
|
|
}
|
|
|
|
function validateAPIPage(result, count, maxTotal = Number.MAX_SAFE_INTEGER) {
|
|
if (!result || !Number.isSafeInteger(result.total) || result.total < 0 || result.total > maxTotal ||
|
|
!Array.isArray(result.data) || result.data.length > count || result.data.length > result.total ||
|
|
result.data.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))) throw new Error('Invalid API page');
|
|
}
|
|
|
|
export async function fetchChannel(name, source = 'PeerTube', dependencies = {}) {
|
|
// The server sorts before limiting: even a large channel needs just one latest page.
|
|
const result = await fetchJSON(channelVideosURL(name, { count: 3 }), dependencies);
|
|
validateAPIPage(result, 3);
|
|
if (result.data.length !== Math.min(result.total, 3)) throw new Error('Incomplete API page');
|
|
return normalizeJSONVideos(result.data, source, 3, dependencies.dateOptions);
|
|
}
|
|
|
|
/** Every page is checked. A changing/inconsistent total is a failure, not a partial library. */
|
|
export async function fetchPaginated(path, dependencies = {}) {
|
|
const channelPath = typeof path === 'string' && /^\/api\/v1\/video-channels\/([^/]+)\/videos$/.exec(path);
|
|
if (!/^\/api\/v1\/accounts\/eric\/(?:video-playlists|video-channels)$/.test(path) &&
|
|
!(channelPath && isChannelName(channelPath[1])) &&
|
|
!new RegExp(`^/api/v1/video-playlists/${UUID}/videos$`).test(path)) throw new Error('Invalid API endpoint');
|
|
const data = [];
|
|
let total;
|
|
for (let page = 0; page < 10; page++) {
|
|
const url = path.startsWith('/api/v1/video-channels/')
|
|
? channelVideosURL(path.split('/')[4], { start: data.length })
|
|
: `${ORIGIN}${path}?count=100&start=${data.length}`;
|
|
const result = await fetchJSON(url, dependencies);
|
|
validateAPIPage(result, 100, 1000);
|
|
if (total !== undefined && total !== result.total) throw new Error('API changed during pagination');
|
|
total = result.total;
|
|
if (data.length + result.data.length > total) throw new Error('Inconsistent API total');
|
|
data.push(...result.data);
|
|
if (data.length === total) return data;
|
|
if (!result.data.length) throw new Error('Incomplete API page');
|
|
}
|
|
throw new Error('API pagination limit exceeded');
|
|
}
|
|
|
|
export function normalizeCollections(channels, playlists) {
|
|
if (!Array.isArray(channels) || !Array.isArray(playlists) || channels.length > 1000 || playlists.length > 1000) throw new Error('Invalid collections');
|
|
const unique = values => {
|
|
const seen = new Set();
|
|
return values.filter(value => !seen.has(value.key) && seen.add(value.key))
|
|
.sort((a, b) => a.title.localeCompare(b.title) || a.key.localeCompare(b.key));
|
|
};
|
|
return {
|
|
channels: unique(channels.flatMap(channel => /^[1-9]\d{0,9}$/.test(String(channel?.id)) && isChannelName(channel?.name)
|
|
? [{ key: `channel:${channel.id}`, type: 'channel', id: channel.name, title: text(channel.displayName ?? channel.name), link: `${ORIGIN}/video-channels/${channel.name}/videos` }] : [])),
|
|
playlists: unique(playlists.flatMap(playlist => playlist?.privacy?.id === 1 && uuidPattern.test(playlist?.uuid) && typeof playlist.displayName === 'string'
|
|
? [{ key: `playlist:${playlist.uuid.toLowerCase()}`, type: 'playlist', id: playlist.uuid.toLowerCase(), title: text(playlist.displayName), link: `${ORIGIN}/video-playlists/${playlist.uuid.toLowerCase()}` }] : []))
|
|
};
|
|
}
|
|
|
|
export async function discoverCollections(dependencies = {}) {
|
|
const [channels, playlists] = await Promise.all([
|
|
fetchPaginated('/api/v1/accounts/eric/video-channels', dependencies),
|
|
fetchPaginated('/api/v1/accounts/eric/video-playlists', dependencies)
|
|
]);
|
|
return normalizeCollections(channels, playlists);
|
|
}
|
|
|
|
export async function fetchCollection(collection, dependencies = {}) {
|
|
if (collection?.type === 'channel') return fetchChannel(collection.id, collection.title, dependencies);
|
|
if (collection?.type !== 'playlist' || !uuidPattern.test(collection.id)) throw new Error('Invalid collection');
|
|
return normalizeJSONVideos(await fetchPaginated(`/api/v1/video-playlists/${collection.id}/videos`, dependencies), collection.title);
|
|
}
|
|
|
|
export async function loadRegion(names, source, dependencies = {}) {
|
|
if (!Array.isArray(names) || !names.length || names.length > 20 || names.some(name => !isChannelName(name))) throw new Error('Invalid feed channels');
|
|
const results = await Promise.allSettled(names.map(name => fetchChannel(name, source, dependencies)));
|
|
const successes = results.filter(result => result.status === 'fulfilled');
|
|
return { ok: successes.length > 0, unavailable: results.length - successes.length,
|
|
items: sortAndDedupe(successes.flatMap(result => result.value), 3) };
|
|
}
|
|
|
|
function element(document, tag, className, content) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (content !== undefined) node.textContent = content;
|
|
return node;
|
|
}
|
|
|
|
export function createCard(document, item) {
|
|
const url = validateWatchURL(item.url);
|
|
const publishedAt = normalizeDate(item.publishedAt);
|
|
if (!url || !publishedAt) throw new Error('Invalid card');
|
|
const title = text(item.title, 'Untitled video');
|
|
const card = element(document, 'article', 'project-card media-card');
|
|
const media = element(document, 'a', 'media-image');
|
|
media.href = url;
|
|
media.setAttribute('aria-label', `Watch ${title}`);
|
|
const thumbnail = validateThumbnailURL(item.thumbnail);
|
|
if (thumbnail) {
|
|
const image = element(document, 'img');
|
|
image.src = thumbnail;
|
|
image.alt = '';
|
|
image.loading = 'lazy';
|
|
image.width = 560;
|
|
image.height = 315;
|
|
image.referrerPolicy = 'no-referrer';
|
|
image.addEventListener('error', () => { image.hidden = true; }, { once: true });
|
|
media.append(image);
|
|
}
|
|
media.append(element(document, 'span', 'media-label', 'Watch ↗'));
|
|
const copy = element(document, 'div', 'media-copy');
|
|
const date = element(document, 'time', '', new Date(publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }));
|
|
date.dateTime = publishedAt;
|
|
const link = element(document, 'a', 'text-link', 'Watch on PeerTube →');
|
|
link.href = url;
|
|
copy.append(element(document, 'p', 'card-kicker', text(item.source, 'PeerTube')), element(document, 'h3', '', title), date, link);
|
|
card.append(media, copy);
|
|
return card;
|
|
}
|
|
|
|
export function renderItems(document, container, items) {
|
|
const cards = items.map(item => createCard(document, item));
|
|
container.replaceChildren(...(cards.length ? cards : [element(document, 'p', 'feed-empty', 'No public uploads in this collection yet.')]));
|
|
}
|
|
|
|
function checkedStatus(now) { return `Latest uploads · checked ${new Date(now()).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; }
|
|
|
|
/** Selection content has its own abort controller AND generation guard; select stays enabled. */
|
|
export function createLibraryController(document, root, dependencies = {}) {
|
|
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
|
const select = root.querySelector('#peertube-collection');
|
|
const items = root.querySelector('[data-library-items]');
|
|
const status = root.querySelector('[data-library-status]');
|
|
const link = root.querySelector('[data-library-link]');
|
|
const now = dependencies.now ?? Date.now;
|
|
let collections = new Map();
|
|
let generation = 0;
|
|
let contentController;
|
|
let discoveryController;
|
|
let discoveryBusy = false;
|
|
let stopped = false;
|
|
let contentBusy = false;
|
|
const selectCollection = async key => {
|
|
if (stopped || document.hidden) return;
|
|
const collection = collections.get(key);
|
|
if (!collection) return;
|
|
contentController?.abort();
|
|
contentController = new AbortController();
|
|
const version = ++generation;
|
|
contentBusy = true;
|
|
// Do not show cards from a previous collection under a new collection label.
|
|
items.replaceChildren();
|
|
link.href = collection.link;
|
|
link.hidden = false;
|
|
link.textContent = collection.type === 'playlist' ? 'Browse playlist on PeerTube →' : 'Browse channel on PeerTube →';
|
|
status.textContent = `Loading ${collection.title}…`;
|
|
try {
|
|
const publications = await (dependencies.fetchCollection ?? fetchCollection)(collection, { ...dependencies, signal: contentController.signal });
|
|
if (stopped || document.hidden || version !== generation) return;
|
|
renderItems(document, items, publications);
|
|
status.textContent = publications.length ? checkedStatus(now) : 'No public uploads in this collection yet.';
|
|
} catch {
|
|
if (!stopped && version === generation) status.textContent = 'Live publications unavailable. Browse this collection on PeerTube.';
|
|
} finally { if (version === generation) contentBusy = false; }
|
|
};
|
|
const discover = async () => {
|
|
if (stopped || document.hidden || discoveryBusy) return;
|
|
discoveryBusy = true;
|
|
discoveryController = new AbortController();
|
|
select.disabled = true;
|
|
status.textContent = 'Loading channels and playlists…';
|
|
try {
|
|
const result = await (dependencies.discoverCollections ?? discoverCollections)({ ...dependencies, signal: discoveryController.signal });
|
|
if (stopped || document.hidden) return;
|
|
const previous = select.value;
|
|
collections = new Map([...result.channels, ...result.playlists].map(collection => [collection.key, collection]));
|
|
const groups = [];
|
|
for (const [label, values] of [['Channels', result.channels], ['Playlists', result.playlists]]) {
|
|
if (!values.length) continue;
|
|
const group = element(document, 'optgroup');
|
|
group.label = label;
|
|
for (const collection of values) {
|
|
const option = element(document, 'option', '', collection.title);
|
|
option.value = collection.key;
|
|
group.append(option);
|
|
}
|
|
groups.push(group);
|
|
}
|
|
select.replaceChildren(...groups);
|
|
if (!collections.size) {
|
|
contentController?.abort();
|
|
generation++;
|
|
items.replaceChildren(element(document, 'p', 'feed-empty', 'No public channels or playlists found.'));
|
|
status.textContent = 'No public channels or playlists found.';
|
|
link.href = `${ORIGIN}/accounts/eric/videos`;
|
|
link.textContent = 'Browse all publications';
|
|
link.hidden = false;
|
|
return;
|
|
}
|
|
select.value = collections.has(previous) ? previous : collections.has(`playlist:${ORIGINALS_UUID}`) ? `playlist:${ORIGINALS_UUID}` : result.playlists[0]?.key ?? result.channels[0].key;
|
|
select.disabled = false;
|
|
await selectCollection(select.value);
|
|
} catch {
|
|
if (!stopped) {
|
|
select.disabled = collections.size === 0;
|
|
status.textContent = 'Live library discovery unavailable. Browse publications on PeerTube.';
|
|
link.hidden = false;
|
|
}
|
|
} finally { discoveryBusy = false; }
|
|
};
|
|
const onChange = () => { void selectCollection(select.value); };
|
|
select.addEventListener('change', onChange);
|
|
return { discover, selectCollection, refresh: async () => { if (!discoveryBusy && !contentBusy) await discover(); },
|
|
pause() { generation++; contentBusy = false; contentController?.abort(); discoveryController?.abort(); },
|
|
stop() { stopped = true; generation++; contentController?.abort(); discoveryController?.abort(); select.removeEventListener('change', onChange); } };
|
|
}
|
|
|
|
/** Returns {ready, refresh, stop, library}; all timers/listeners are removable for tests. */
|
|
export function initPeerTubeFeeds(document, dependencies = {}) {
|
|
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
|
const now = dependencies.now ?? Date.now;
|
|
const regions = Array.from(document.querySelectorAll('[data-peertube-feed]')).map(root => ({ root, busy: false, controller: null, hasLive: false }));
|
|
const libraryRoot = document.querySelector('#peertube-library');
|
|
const library = libraryRoot ? createLibraryController(document, libraryRoot, dependencies) : null;
|
|
let stopped = false;
|
|
let lastRefresh = -Infinity;
|
|
const refreshRegion = async region => {
|
|
if (region.busy || stopped || document.hidden) return;
|
|
const status = region.root.querySelector('[data-feed-status]');
|
|
const items = region.root.querySelector('[data-feed-items]');
|
|
if (!status || !items) return;
|
|
region.busy = true;
|
|
const controller = region.controller = new AbortController();
|
|
status.textContent = 'Checking latest uploads…';
|
|
try {
|
|
const names = (region.root.getAttribute('data-channels') ?? '').split(',').map(name => name.trim());
|
|
const result = await (dependencies.loadRegion ?? loadRegion)(names, region.root.getAttribute('data-title') ?? 'PeerTube', { ...dependencies, signal: controller.signal });
|
|
if (stopped || document.hidden || controller.signal.aborted) return;
|
|
if (!result.ok) { status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; return; }
|
|
renderItems(document, items, result.items);
|
|
region.hasLive = true;
|
|
status.textContent = result.unavailable ? 'Some channels unavailable · showing available uploads.' : result.items.length ? checkedStatus(now) : 'No public uploads in these channels yet.';
|
|
} catch { if (!stopped && !document.hidden && !controller.signal.aborted) status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; }
|
|
finally { if (region.controller === controller) region.busy = false; }
|
|
};
|
|
const refresh = async ({ force = false } = {}) => {
|
|
if (stopped || document.hidden || (!force && now() - lastRefresh < REFRESH_MS)) return;
|
|
lastRefresh = now();
|
|
await Promise.all([...regions.map(refreshRegion), library?.refresh()]);
|
|
};
|
|
const onVisibility = () => {
|
|
if (!document.hidden) void refresh();
|
|
else {
|
|
for (const region of regions) {
|
|
if (region.busy) lastRefresh = -Infinity;
|
|
region.controller?.abort();
|
|
region.busy = false;
|
|
}
|
|
library?.pause();
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
const interval = (dependencies.setIntervalImpl ?? setInterval)(() => { void refresh(); }, REFRESH_MS);
|
|
const ready = refresh();
|
|
return { ready, refresh, library, stop() {
|
|
stopped = true;
|
|
(dependencies.clearIntervalImpl ?? clearInterval)(interval);
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
for (const region of regions) region.controller?.abort();
|
|
library?.stop();
|
|
} };
|
|
}
|
|
|
|
export const boot = initPeerTubeFeeds;
|
|
if (typeof globalThis.document !== 'undefined') {
|
|
if (globalThis.document.readyState === 'loading') globalThis.document.addEventListener('DOMContentLoaded', () => boot(globalThis.document), { once: true });
|
|
else boot(globalThis.document);
|
|
}
|