window.App = window.App || {};
App.videos = App.videos || {};
(function() {
const state = App.state;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) App.videos.loadVideos();
}, {
threshold: 1.0
});
const titleEnv = {
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
};
const titleVisibility = new Map();
let titleObserver = null;
if (!titleEnv.useHoverFocus) {
titleObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
titleVisibility.set(entry.target, entry.intersectionRatio || 0);
} else {
titleVisibility.delete(entry.target);
entry.target.dataset.titlePrimary = '0';
updateTitleActive(entry.target);
}
});
let topCard = null;
let topRatio = 0;
titleVisibility.forEach((ratio, card) => {
if (ratio > topRatio) {
topRatio = ratio;
topCard = card;
}
});
titleVisibility.forEach((ratio, card) => {
card.dataset.titlePrimary = card === topCard && ratio >= 0.55 ? '1' : '0';
updateTitleActive(card);
});
}, {
threshold: [0, 0.25, 0.55, 0.8, 1.0]
});
}
App.videos.observeSentinel = function() {
const sentinel = document.getElementById('sentinel');
if (sentinel) {
observer.observe(sentinel);
}
};
const updateTitleActive = function(card) {
const titleWrap = card && card.querySelector('.video-title');
if (!titleWrap || !titleWrap.classList.contains('has-marquee')) {
if (card) card.classList.remove('is-title-active');
return;
}
const hovered = card.dataset.titleHovered === '1';
const focused = card.dataset.titleFocused === '1';
const primary = card.dataset.titlePrimary === '1';
const active = titleEnv.useHoverFocus ? (hovered || focused) : (focused || primary);
card.classList.toggle('is-title-active', active);
};
const measureTitle = function(card) {
if (!card) return;
const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text');
if (!titleWrap || !titleText) return;
if (App.marquee.measure(titleWrap, titleText)) {
// Only marquee cards need the scroll-position observer that picks the
// centered card to animate; observing every card made scrolling a
// large grid needlessly expensive.
if (titleObserver) titleObserver.observe(card);
} else {
card.classList.remove('is-title-active');
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
}
}
updateTitleActive(card);
};
let titleMeasureRaf = null;
const scheduleTitleMeasure = function() {
if (titleMeasureRaf) return;
titleMeasureRaf = requestAnimationFrame(() => {
titleMeasureRaf = null;
document.querySelectorAll('.video-card').forEach((card) => {
measureTitle(card);
});
// The virtualizer re-packs and remounts on resize via its own
// listener; nothing else to do here.
});
};
window.addEventListener('resize', scheduleTitleMeasure);
App.videos.formatDuration = function(seconds) {
if (!seconds || seconds <= 0) return '';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
if (minutes > 0) {
return `${minutes}:${String(secs).padStart(2, '0')}`;
}
return `${secs}`;
};
App.videos.buildImageProxyUrl = function(imageUrl) {
if (!imageUrl) return '';
try {
return `/api/image?url=${encodeURIComponent(imageUrl)}`;
} catch (err) {
return '';
}
};
// ---------------------------------------------------------------------
// Thumbnails
//
// A thumbnail can come from two places: the provider's own CDN, or our
// /api/image proxy. Neither is reliably the faster one -- the CDN is a hop
// closer, but plenty of them hotlink-block or rate-limit, and learning that
// used to cost a whole failed request before the proxy was even asked. That
// serial retry is the wait worth removing.
//
// So the first thumbnail from a host is raced: both requests go out at once
// and whichever answers first is the one displayed. The winner is then
// remembered per host -- hotlink and CORS policy are origin-level, the same
// assumption the direct-playability probe below makes -- so every later
// thumbnail from that host goes straight down the route that already
// worked. One race per host, not one per image: racing every card would
// double the image traffic of a whole grid to learn something we already
// know by the second card.
// ---------------------------------------------------------------------
const IMAGE_DIRECT = 'direct';
const IMAGE_PROXY = 'proxy';
const imageRoutes = new Map(); // host -> winning route; absent = unknown
const imageRacing = new Set(); // hosts with a race already deciding
const imageWaiting = new Map(); // host -> images held until it decides
let thumbSeq = 0; // generation, so stale work can be dropped
// A page of cards is built in one go, so every thumbnail from a host is
// attached before the first one has come back. Sending them all down the
// optimistic route is how the old serial retry hurt: on a host that blocks
// us, each card paid its own failed request before asking the proxy. So
// while a host is being decided the rest of its images wait for the answer
// -- at most as long as the fastest route takes -- and then load once, the
// right way round. If the race somehow stalls they go anyway.
const RACE_PATIENCE_MS = 2500;
// Direct is the route we'd rather settle on, so when the proxy comes home
// first the held images give direct this much longer to answer before
// committing to the proxy. Long enough that a provider merely a little
// slower than same-origin still wins its hosts; short enough that one which
// hangs -- the case this whole thing exists for -- doesn't hold up a grid.
const DIRECT_GRACE_MS = 200;
const imageHostOf = function(url) {
try {
return new URL(url, window.location.href).host;
} catch (err) {
return '';
}
};
// Where a thumbnail from this host should be fetched from. Answers with the
// provider while the host is still unknown -- the optimistic route, and the
// one a race starts on anyway.
App.videos.thumbnailUrl = function(url) {
if (!url) return '';
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
? (App.videos.buildImageProxyUrl(url) || url)
: url;
};
// A src-less counts as "unavailable", and the browser paints its alt
// text across the thumbnail box. Since a thumbnail now waits for its host's
// route before it gets a src, the caption is held back in a data attribute
// and put on only once there is an image to caption -- otherwise every card
// spells out its own title over the placeholder while the host is being
// decided, and permanently for an item that has no thumbnail at all.
// `token` is the generation of the attachThumbnail call that started this
// work. A card can be recycled while its thumbnail is still being decided,
// and the callbacks that eventually fire still hold the old element -- so
// anything arriving for a generation the element has moved past is dropped
// rather than painted onto whatever video the card now shows.
const showThumbnail = function(img, url, token) {
if (token !== undefined && img.dataset.thumbToken !== token) return;
if (img.dataset.alt !== undefined) {
img.alt = img.dataset.alt;
delete img.dataset.alt;
}
img.src = url;
};
// Last resort on a route that normally works: one expired or missing image
// shouldn't be left broken just because its host is fine in general.
const attachProxyFallback = function(img, proxyUrl, token) {
if (!proxyUrl) return;
// Checked here too, not just in showThumbnail: this *replaces* whatever
// fallback the image currently has, so a call arriving for a generation
// the element has moved past would take away the live one and leave a
// dead one -- the recycled card's thumbnail would then have no fallback
// at all if it failed.
if (token !== undefined && img.dataset.thumbToken !== token) return;
// Held on the element so detachThumbnail can take it off again. On the
// happy path it never fires and `once` never collects it, so a pooled
// image would otherwise accumulate one closure per mount it has served.
detachProxyFallback(img);
const onError = () => { showThumbnail(img, proxyUrl, token); };
img._thumbFallback = onError;
img.addEventListener('error', onError, { once: true });
};
const detachProxyFallback = function(img) {
if (img && img._thumbFallback) {
img.removeEventListener('error', img._thumbFallback);
img._thumbFallback = null;
}
};
// Releases the images held for `host`. `route` is the winner, or null when
// the race told us nothing (both routes failed, or it stalled) -- in which
// case they take the optimistic route with the proxy behind it, exactly as
// an undecided host used to.
const releaseWaiting = function(host, route) {
const waiting = imageWaiting.get(host);
if (!waiting) return;
imageWaiting.delete(host);
waiting.forEach((entry) => {
if (route === IMAGE_PROXY) {
showThumbnail(entry.img, entry.proxyUrl, entry.token);
return;
}
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
showThumbnail(entry.img, entry.directUrl, entry.token);
});
};
// Two questions, and they don't have the same answer:
//
// which is quicker *now* -> what this image should display
// does direct work at all -> what the host is remembered as
//
// The proxy often wins the first question purely because it's same-origin:
// the browser already holds that connection, while the provider costs a
// fresh DNS lookup and TLS handshake. That says nothing about the provider,
// and pinning a host to the proxy over it would push every thumbnail on the
// page through our own server for no reason. So speed decides the pixels,
// and capability decides the memory: direct is remembered whenever it works
// at all, because it costs no server hop.
//
// Both routes are therefore fetched off-screen, and the visible image is
// pointed at the first one home. Racing on the visible element instead
// would abort the loser -- and the loser is the request that answers the
// second question.
const raceThumbnail = function(img, directUrl, proxyUrl, host, token) {
let shown = false;
let outstanding = 2;
let routeFinal = false; // the direct verdict is in; nothing can revise it
let abandoned = false; // took too long; a later race owns the host now
let directSettled = false;
let patience = null;
let grace = null;
const show = function(url) {
if (shown) return;
shown = true;
showThumbnail(img, url, token); // a cache hit; the probe has the bytes
};
// Records the host's route and lets go of everything held for it. The
// direct verdict is final; a route taken because direct was too slow to
// wait for is not, so a direct probe that comes home late still upgrades
// the host rather than leaving it on the proxy for the whole session.
//
// Crucially this happens the moment direct answers, not when both probes
// have finished: every thumbnail attached in the meantime is queued on
// exactly this answer, and making them wait on the *other* probe too
// leaves them blank for no reason.
const settleRoute = function(route, final) {
if (abandoned || routeFinal) return;
routeFinal = !!final;
if (final && patience) { clearTimeout(patience); patience = null; }
imageRoutes.set(host, route);
imageRacing.delete(host);
releaseWaiting(host, route);
};
// Nothing usable came home in time. Show whatever the host settled on --
// broken rather than blank, as it would have been without any of this --
// and keep the proxy behind an untested direct.
const giveUp = function() {
if (shown) return;
shown = true;
if (imageRoutes.get(host) === IMAGE_PROXY) {
showThumbnail(img, proxyUrl, token);
return;
}
attachProxyFallback(img, proxyUrl, token);
showThumbnail(img, directUrl, token);
};
const decide = function() {
if (--outstanding > 0) return;
if (patience) { clearTimeout(patience); patience = null; }
if (grace) { clearTimeout(grace); grace = null; }
giveUp(); // no-op if either route came home
};
// Off-screen, and low priority: neither may take bandwidth from anything
// the reader is already looking at.
const newProbe = function() {
const image = new Image();
image.decoding = 'async';
image.fetchPriority = 'low';
return image;
};
const directProbe = newProbe();
directProbe.referrerPolicy = 'no-referrer';
// The direct verdict alone decides the route -- either way round. One
// probe discovers the host's answer and every held image acts on it,
// instead of each rediscovering it at the cost of its own request.
directProbe.onload = function() {
directSettled = true;
if (grace) { clearTimeout(grace); grace = null; }
show(directUrl);
settleRoute(IMAGE_DIRECT, true);
decide();
};
directProbe.onerror = function() {
directSettled = true;
if (grace) { clearTimeout(grace); grace = null; }
// Direct is out for this host, so the proxy is the answer even if it
// hasn't reported yet -- there is nothing else left to be.
settleRoute(IMAGE_PROXY, true);
decide();
};
const proxyProbe = newProbe();
proxyProbe.onload = function() {
show(proxyUrl); // first one home gets the pixels on screen
// Don't strand anything behind a provider that may never answer:
// give it the grace window, then take the route that works. Marked
// provisional, so a slow-but-working provider still wins its host
// when it finally reports.
if (!directSettled && !grace) {
grace = setTimeout(function() {
grace = null;
settleRoute(IMAGE_PROXY, false);
}, DIRECT_GRACE_MS);
}
decide();
};
proxyProbe.onerror = function() { decide(); };
imageRacing.add(host);
// A probe that never answers -- a hung connection rather than a refused
// one -- must not leave the host mid-race forever, with every later
// thumbnail queueing behind a decision that will never come. Once that
// happens this race stops touching the shared maps entirely: the next
// thumbnail starts a fresh one, and a late answer here must not reach in
// and overwrite what *that* race decides.
patience = setTimeout(function() {
patience = null;
if (grace) { clearTimeout(grace); grace = null; }
if (!routeFinal) {
abandoned = true;
imageRacing.delete(host);
releaseWaiting(host, imageRoutes.get(host) || null);
}
giveUp();
}, RACE_PATIENCE_MS);
directProbe.src = directUrl;
proxyProbe.src = proxyUrl;
};
// Points `img` at `url` by whichever route is known to work for its host,
// racing the two the first time that host is seen.
App.videos.attachThumbnail = function(img, url) {
const directUrl = url || (img && img.dataset.thumb) || '';
if (!img) return;
// Held back until there is an image to caption -- see showThumbnail. An
// item with no thumbnail keeps an empty alt: the card's own title sits
// directly beneath the box, so there is nothing for it to add.
if (img.alt && img.dataset.alt === undefined) {
img.dataset.alt = img.alt;
img.alt = '';
}
if (!directUrl) return;
// A cross-origin Referer is what most hotlink protection keys on, and an
// image needs none. Sending none is what lets the direct route work at
// all on a fair number of providers -- and the direct route is the one
// that costs us no server hop.
img.referrerPolicy = 'no-referrer';
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
const host = imageHostOf(directUrl);
const route = imageRoutes.get(host);
// Every attach is a new generation, so work started for a previous one
// stops being able to touch this element.
const token = String(++thumbSeq);
img.dataset.thumbToken = token;
if (route === IMAGE_PROXY) {
showThumbnail(img, proxyUrl || directUrl, token);
return;
}
if (imageRacing.has(host)) {
// A race is already deciding for this host. Wait for it rather than
// guessing: guessing wrong costs this image a whole failed request
// before it even asks the route that was about to be proven.
const waiting = imageWaiting.get(host) || [];
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl, token: token });
imageWaiting.set(host, waiting);
return;
}
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
// Known good, or nothing to race against: take the provider and keep
// the proxy as this image's own fallback.
attachProxyFallback(img, proxyUrl, token);
showThumbnail(img, directUrl, token);
return;
}
raceThumbnail(img, directUrl, proxyUrl, host, token);
};
// Voids whatever is still in flight for this element's thumbnail. Its
// generation moves on, so a race that settles later, or a proxy fallback
// that fires later, finds a token that no longer matches and does nothing.
App.videos.detachThumbnail = function(img) {
if (!img) return;
img.dataset.thumbToken = String(++thumbSeq);
delete img.dataset.alt;
detachProxyFallback(img);
};
// 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.
const fetchGroupBatch = async function(session, signal) {
const group = session.channel;
const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : "";
// Honor the per-group "Channels" multi-select filter so disabled
// channels are skipped. Falls back to the full group when nothing is
// selected (e.g. older sessions without the filter).
const selectedChannels = session.options ? session.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
const enabledIds = (Array.isArray(selectedChannels) && selectedChannels.length > 0 ?
selectedChannels.map((opt) => opt.id) :
group.channelIds).filter((id) => group.channelIds.includes(id));
const signature = enabledIds.join(',');
if (!state.groupCursors || state.groupCursors.groupId !== group.id ||
state.groupCursors.query !== query || state.groupCursors.signature !== signature) {
state.groupCursors = {
groupId: group.id,
query: query,
signature: signature,
channels: enabledIds.map((id) => ({ id, page: 1, hasNextPage: true }))
};
}
const active = state.groupCursors.channels.filter((cursor) => cursor.hasNextPage);
if (active.length === 0) {
return { items: [], hasNextPage: false };
}
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: 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]);
});
}
return {
items: interleaved,
hasNextPage: state.groupCursors.channels.some((cursor) => cursor.hasNextPage)
};
};
const fetchChannelBatch = async function(session, signal) {
const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : "";
const body = {
channel: session.channel.id,
query: query || "",
page: state.currentPage,
perPage: state.perPage,
server: session.server
};
Object.entries(session.options).forEach(([key, value]) => {
if (Array.isArray(value)) {
body[key] = value.map((entry) => entry.id).join(", ");
} else if (value && value.id) {
body[key] = value.id;
}
});
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal
});
const videos = await response.json();
state.currentPage++;
return {
items: videos && Array.isArray(videos.items) ? videos.items : [],
hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true
};
};
const fetchNextBatch = function(session, signal) {
return session.channel.isGroup
? fetchGroupBatch(session, signal)
: fetchChannelBatch(session, signal);
};
// ---------------------------------------------------------------------
// Next-page prefetch
//
// The page after the visible one is fetched, and its thumbnails decoded,
// as soon as the current one renders -- but it is held back until the
// reader reaches the second-to-last row (see the virtualizer's update()).
// So the cards that come into view are complete on arrival instead of
// shimmering their way in while the request is still on the wire.
// ---------------------------------------------------------------------
const prefetch = {
batch: null, // fetched + warmed, waiting for the reader
inFlight: null, // promise of the fetch currently running
controller: null
};
// One slow thumbnail must not hold a whole page back.
const WARM_TIMEOUT_MS = 8000;
const idle = function(fn) {
if (typeof requestIdleCallback === 'function') requestIdleCallback(fn, { timeout: 1000 });
else setTimeout(fn, 0);
};
const warmThumbnails = function(items) {
// Warm down the route the host has already settled on -- warming a URL
// the cards won't ask for would leave them waiting anyway.
const urls = (items || [])
.map((v) => v && App.videos.thumbnailUrl(v.thumb))
.filter(Boolean);
if (!urls.length) return Promise.resolve();
const loads = urls.map((url) => new Promise((resolve) => {
// Off-screen and not needed yet: low priority, started when the
// browser is idle, so warming a page the reader hasn't reached
// never competes with the cards (or requests) in front of them.
idle(() => {
const img = new Image();
img.decoding = 'async';
img.fetchPriority = 'low';
// Same terms the card and the probe fetch on. Warming with a
// Referer the real request won't send would warm the wrong
// thing: on a hotlink-protecting host it earns a 403, which is
// both a wasted warm and a cached refusal the probe may then be
// handed -- pinning a host to the proxy that works direct.
img.referrerPolicy = 'no-referrer';
img.onload = resolve;
img.onerror = resolve; // the card's own retry handles failures
img.src = url;
});
}));
return Promise.race([
Promise.all(loads),
new Promise((resolve) => setTimeout(resolve, WARM_TIMEOUT_MS))
]);
};
const commitBatch = function(batch) {
if (!batch) return;
App.videos.renderVideos({ items: batch.items });
state.hasNextPage = batch.hasNextPage;
App.videos.updateLoadMoreState();
App.videos.ensureViewportFilled();
App.videos.prefetchNextBatch(); // stay one page ahead again
};
App.videos.prefetchNextBatch = function() {
if (prefetch.batch || prefetch.inFlight) return Promise.resolve();
if (!state.hasNextPage) return Promise.resolve();
const session = App.storage.getSession();
if (!session || !session.channel) return Promise.resolve();
prefetch.controller = new AbortController();
const signal = prefetch.controller.signal;
prefetch.inFlight = (async () => {
try {
const batch = await fetchNextBatch(session, signal);
await warmThumbnails(batch.items);
if (!signal.aborted) prefetch.batch = batch;
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Failed to prefetch videos:", err);
}
} finally {
prefetch.inFlight = null;
prefetch.controller = null;
}
})();
return prefetch.inFlight;
};
// Throws away a video's resolved formats and asks the server again. Media
// URLs are commonly signed with an expiry (`?secure=-`), so
// formats resolved earlier -- in a long-open tab, or held in the session
// cache -- eventually start returning 403 even though nothing is wrong with
// the video itself. The player calls this once before giving up.
App.videos.refreshFormats = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve(null);
const cacheKey = video.id || video.url;
if (cacheKey) metaCache.delete(cacheKey);
video.meta = null;
return App.videos.ensureFormats(video);
};
// Called by the virtualizer once the reader is within two rows of the end.
App.videos.releasePrefetched = function() {
if (!prefetch.batch || state.isLoading) return false;
const batch = prefetch.batch;
prefetch.batch = null;
commitBatch(batch);
return true;
};
// Drops anything in flight or held, for when the result set changes out
// from under it (new search, channel, or filters).
App.videos.resetPrefetch = function() {
if (prefetch.controller) prefetch.controller.abort();
prefetch.batch = null;
prefetch.inFlight = null;
prefetch.controller = null;
};
// Is the reader actually out of content? True when nothing is rendered yet,
// when the page is too short to scroll, or when they're already in the last
// two rows. Anything else -- the sentinel firing from a stale observation at
// startup, most of all -- means the prefetched page stays held.
const needsContentNow = function() {
if (!state.loadedVideos.length) return true;
const docHeight = document.documentElement.scrollHeight;
if (docHeight <= (window.innerHeight || 0) + 120) return true;
return !!(App.virtualGrid && App.virtualGrid.isNearEnd && App.virtualGrid.isNearEnd());
};
// Guards the whole of loadVideos, including the awaits before the fetch
// starts. state.isLoading can't do this job: it is the UI's "a request is
// out" signal and is only raised around the fetch itself, so every caller
// that arrived while we were waiting on the prefetch would sail past it and
// start a duplicate page load -- each of which renders, refills the
// viewport, and calls back in here. On a short desktop page that amplifies
// until the tab stops responding.
let loadRunning = false;
// Renders the next page: the prefetched one when it's ready (the common
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
// callers that know they need content and can't be judged by where the grid
// is scrolled -- the reels feed running out of slides, or the Load more
// button being pressed.
App.videos.loadVideos = async function(opts) {
const force = !!(opts && opts.force);
// The favorites grid pages out of localStorage, not the server, but
// rides the same sentinel and load-more button to get there.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.loadNext();
return;
}
const session = App.storage.getSession();
if (!session || !session.channel) return;
if (loadRunning || state.isLoading) return;
loadRunning = true;
try {
if (prefetch.batch || prefetch.inFlight) {
// A page is already here or on its way; revealing it before the
// reader gets near the end would defeat the point of holding it.
if (!force && !needsContentNow()) return;
if (App.videos.releasePrefetched()) return;
if (prefetch.inFlight) {
// Wait for it rather than asking for the same page twice --
// the fetch advances the page cursor, so a second request
// here would skip a page entirely.
await prefetch.inFlight;
if (App.videos.releasePrefetched()) return;
}
}
if (!state.hasNextPage) return;
state.isLoading = true;
App.videos.updateLoadMoreState();
state.currentLoadController = new AbortController();
const batch = await fetchNextBatch(session, state.currentLoadController.signal);
state.isLoading = false;
commitBatch(batch);
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Failed to load videos:", err);
}
} finally {
loadRunning = false;
state.isLoading = false;
state.currentLoadController = null;
App.videos.updateLoadMoreState();
}
};
// ---------------------------------------------------------------------
// Cards
//
// A card is built once and then reused: the virtualizer keeps a pool of
// them and rebinds one to a new video rather than constructing another
// (see acquire/release in App.virtualGrid). Two things follow from that,
// and both are load-bearing.
//
// Nothing on a card may close over the video it is currently showing --
// the card outlives the video. Every interaction is therefore handled by
// one delegated listener per event type on the grid, which resolves the
// video from the card under the pointer (see bindGridDelegation).
//
// And every card has the same shape whatever video it shows: the optional
// parts -- live badge, uploader, duration, tags -- are always present and
// hidden when unused, so any pooled card fits any video.
// ---------------------------------------------------------------------
const CARD_TEMPLATE_HTML = `
● LIVE
`;
// Parsed once. Cloning this is roughly six times cheaper than asking the
// parser to read the same markup again for every card.
let cardTemplate = null;
// The parts bindCard writes to, found once when the card is created rather
// than looked up again on every rebind. Searching the subtree seven times
// per mount was most of what rebinding cost.
const cardRefs = function(card) {
if (!card._refs) {
card._refs = {
live: card.querySelector('.live-badge'),
favorite: card.querySelector('.favorite-btn'),
title: card.querySelector('.video-title-text'),
uploader: card.querySelector('.video-uploader'),
duration: card.querySelector('.video-duration'),
tags: card.querySelector('.video-tags'),
img: card.querySelector('img')
};
}
return card._refs;
};
const createCardShell = function() {
if (!cardTemplate) {
cardTemplate = document.createElement('template');
cardTemplate.innerHTML = `
${CARD_TEMPLATE_HTML}
`;
}
const card = cardTemplate.content.firstElementChild.cloneNode(true);
cardRefs(card);
return card;
};
// Tag buttons are the only part whose *count* varies, so they are adjusted
// rather than rebuilt: usually the card already has the right number.
const bindTags = function(container, tags) {
const list = Array.isArray(tags) ? tags.filter((tag) => tag) : [];
container.hidden = list.length === 0;
while (container.childElementCount > list.length) {
container.removeChild(container.lastElementChild);
}
while (container.childElementCount < list.length) {
const button = document.createElement('button');
button.className = 'video-tag';
button.type = 'button';
button.dataset.action = 'tag';
container.appendChild(button);
}
// Only the label is written: the delegated handler reads the tag off
// the button's own text. Writing it a second time into a data attribute
// cost more than everything else in a rebind put together -- dataset is
// a proxy, and this runs once per tag per card.
list.forEach((tag, index) => {
const button = container.children[index];
if (button.textContent !== tag) button.textContent = tag;
});
};
// Points an existing card at `v`. This is the whole per-mount cost.
App.videos.bindCard = function(card, v) {
const refs = cardRefs(card);
card.dataset.videoId = v.id;
cardVideo.set(card, v);
refs.live.hidden = !v.isLive;
const favoriteKey = App.favorites.getKey(v);
refs.favorite.dataset.favKey = favoriteKey || '';
refs.favorite.dataset.favUrl = v.url || '';
// By either identity: a favorite imported from a backup is keyed by its
// URL, not by the id this card carries.
App.favorites.setButtonState(refs.favorite, !!favoriteKey && App.favorites.has(v));
refs.title.textContent = v.title || '';
const uploaderText = v.uploader || '';
refs.uploader.hidden = !uploaderText;
refs.uploader.textContent = uploaderText;
refs.uploader.dataset.uploader = uploaderText;
const durationText = App.videos.formatDuration(v.duration);
refs.duration.hidden = !durationText;
refs.duration.textContent = durationText;
bindTags(refs.tags, v.tags);
// Set before attachThumbnail, which holds the caption back until there
// is an image to caption.
refs.img.alt = v.title || '';
return card;
};
// A card ready to show `v`, thumbnail and all.
App.videos.buildCard = function(v, options) {
const card = App.videos.bindCard(createCardShell(), v);
// The layout probe (see shapeHeight) needs the card's shape, never its
// pixels: it measures against the CSS 16:9 placeholder and is removed in
// the same frame, so loading a thumbnail for it -- let alone racing one
// -- would be pure waste.
if (!(options && options.skipThumbnail)) {
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
}
return card;
};
// Returns a card to a state where it shows nothing and remembers nothing,
// ready to be bound to another video. Anything left behind here surfaces as
// one video's content on another video's card.
App.videos.resetCard = function(card) {
card.classList.remove('is-loading', 'is-title-active', 'is-revealing', 'is-previewing');
delete card.dataset.videoId;
delete card.dataset.titleFocused;
delete card.dataset.titleHovered;
delete card.dataset.titlePrimary;
// The player stamps this to recognise the card it was opened from; a
// recycled card must stop answering to it.
delete card.dataset.playerToken;
// Added by favorites.toggle and normally taken off by animationend --
// which never fires here, because release() detaches the card first and
// a detached element runs no animations. Left on, the next video this
// card shows replays a "just favourited" pop nobody asked for.
const favorite = cardRefs(card).favorite;
if (favorite) favorite.classList.remove('just-favorited');
const menu = card.querySelector('.video-menu');
if (menu) menu.classList.remove('open');
const menuBtn = card.querySelector('.video-menu-btn');
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
const titleWrap = card.querySelector('.video-title');
if (titleWrap) titleWrap.classList.remove('has-marquee');
const titleText = card.querySelector('.video-title-text');
if (titleText) {
titleText.style.removeProperty('--marquee-distance');
titleText.style.removeProperty('--marquee-duration');
}
// The hover preview (enhance.js) parks a