Files
jacuzzi/frontend/js/videos.js
Simon 451bf0f983 Pick formats by decode cost, and fix auto picture-in-picture
Two things, both about playing several videos at once.

Capping the resolution per panel wasn't enough, because pixel count isn't
the only cost. A split panel now also prefers a progressive file over HLS
-- every HLS panel runs its own JavaScript demuxer over every segment, so
four panels means four media pipelines doing work a plain MP4 skips
entirely -- and H.264 over AV1 or VP9, which are often decoded in software
and are a cliff rather than a gradient, and 30fps over 60. The height
ceiling still comes first, so cheapness cannot argue a panel into a bigger
picture than it should have, and every format stays reachable as fallback.
The preloaded step's hls.js instances now park after buffering one
fragment and resume when the reader swipes to them, instead of fetching
and demuxing ahead for a step nobody reached.

Auto picture-in-picture had been implemented since the custom player was
written and had never worked. requestPictureInPicture() from a
visibilitychange handler carries no user activation, browsers refuse those,
and .catch(() => {}) swallowed the refusal -- so it failed silently every
time, in the reels feed and the standalone player alike. The declarative
autoPictureInPicture attribute is the form made for this: the browser is
told in advance which video should follow the reader out. The imperative
call stays as a fallback.

With panels there are several candidates and only one window, so binding
every pane made them race for it. The feed picks one deliberately -- the
panel you can hear, or the first if they are all muted -- re-picks when the
step or a mute switch changes, and releases it on close.

Whether a window actually opens is browser policy, not ours: Safari honours
the attribute, Chrome honours it for installed apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:16:08 +00:00

2409 lines
110 KiB
JavaScript

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 <img> 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=<unix ts>-<token>`), 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 = `
<span class="live-badge" hidden>● LIVE</span>
<button class="favorite-btn" type="button" data-action="favorite" aria-pressed="false" aria-label="Add to favorites">♡</button>
<button class="video-menu-btn" type="button" data-action="menu" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div>
<div class="video-thumb">
<img alt="" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
<button class="video-uploader uploader-link" type="button" data-action="uploader" hidden></button>
<span class="video-duration" hidden></span>
</div>
<h4 class="video-title"><span class="video-title-text"></span></h4>
<div class="video-tags" hidden></div>
`;
// 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 = `<div class="video-card">${CARD_TEMPLATE_HTML}</div>`;
}
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 <video> inside the card.
const preview = card.querySelector('.card-preview');
if (preview) {
try { preview.pause(); } catch (err) { /* ignore */ }
preview.remove();
}
const img = cardRefs(card).img;
if (img) {
// Voids anything still in flight for the old thumbnail -- a pending
// race, a proxy fallback -- so it cannot land on the next video.
App.videos.detachThumbnail(img);
if (img._revealHandler) {
img.removeEventListener('load', img._revealHandler);
img._revealHandler = null;
}
img.classList.remove('is-loaded');
img.style.removeProperty('aspect-ratio');
img.removeAttribute('src');
img.alt = '';
}
return card;
};
// One listener per event type, on the grid, for every card in it.
let gridDelegated = false;
const cardFor = function(target) {
const card = target && target.closest ? target.closest('.video-card') : null;
return card && cardVideo.has(card) ? card : null;
};
const bindGridDelegation = function() {
if (gridDelegated) return;
const grid = document.getElementById('video-grid');
if (!grid) return;
gridDelegated = true;
grid.addEventListener('click', (event) => {
const card = cardFor(event.target);
if (!card) return;
const v = cardVideo.get(card);
const actionEl = event.target.closest('[data-action]');
const action = actionEl && card.contains(actionEl) ? actionEl.dataset.action : '';
if (action) {
// The document-level handler closes every open menu; stopping
// here is what lets the menu this click just opened stay open.
event.stopPropagation();
}
switch (action) {
case 'favorite':
App.favorites.toggle(v);
return;
case 'uploader':
App.videos.handleSearch(actionEl.dataset.uploader || actionEl.textContent || '');
return;
case 'tag':
App.videos.handleSearch(actionEl.textContent || '');
return;
case 'menu':
App.videos.toggleMenu(card.querySelector('.video-menu'), actionEl);
return;
case 'info':
App.videos.closeAllMenus();
App.ui.openInfo(v);
return;
case 'download':
App.videos.closeAllMenus();
App.videos.downloadVideo(v);
return;
}
if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading');
App.player.open(v, { originEl: card });
});
const setTitleFlag = function(event, flag, value) {
const card = cardFor(event.target);
if (!card) return;
// pointerover/pointerout also fire while moving between a card's own
// children; only a crossing of the card's own edge counts.
if (event.relatedTarget && card.contains(event.relatedTarget)) return;
card.dataset[flag] = value;
updateTitleActive(card);
};
grid.addEventListener('focusin', (event) => setTitleFlag(event, 'titleFocused', '1'));
grid.addEventListener('focusout', (event) => setTitleFlag(event, 'titleFocused', '0'));
if (titleEnv.useHoverFocus) {
// mouseenter/mouseleave don't bubble, so they can't be delegated;
// pointerover/pointerout do, and the containment check above turns
// them into the same thing.
grid.addEventListener('pointerover', (event) => setTitleFlag(event, 'titleHovered', '1'));
grid.addEventListener('pointerout', (event) => setTitleFlag(event, 'titleHovered', '0'));
}
};
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
// we only grow the data buffer, extend the masonry layout for the new
// items, then let the virtualizer mount whatever currently falls inside the
// viewport window.
App.videos.renderVideos = function(videos) {
const grid = document.getElementById('video-grid');
if (!grid) return;
App.virtualGrid.ensureInit();
const items = videos && Array.isArray(videos.items) ? videos.items : [];
// The server's copy of a video it already has saved is the better one,
// so bring the favorites up to date from it. Skipped while the favorites
// grid is the thing being rendered -- those items *are* the favorites,
// reshaped for the grid, and reconciling them against themselves would
// only write back what they came from.
if (App.favorites && typeof App.favorites.reconcile === 'function' &&
!(App.favoritesView && App.favoritesView.isActive())) {
App.favorites.reconcile(items);
}
const startLen = state.loadedVideos.length;
items.forEach((v) => {
if (state.renderedVideoIds.has(v.id)) return;
state.renderedVideoIds.add(v.id);
state.loadedVideos.push(v);
});
if (state.loadedVideos.length > startLen) {
App.virtualGrid.packFrom(startLen);
}
App.virtualGrid.update();
if (App.feed && typeof App.feed.renderSlides === 'function') {
App.feed.renderSlides();
}
App.videos.ensureViewportFilled();
};
// Finds whichever rendered video card is closest to the viewport's
// vertical center, used to open feed mode on the video the user is
// currently looking at instead of always starting from the top.
App.videos.getFocusedVideoId = function() {
const grid = document.getElementById('video-grid');
if (!grid) return null;
const cards = Array.from(grid.querySelectorAll('.video-card'));
if (!cards.length) return null;
const viewportCenter = window.innerHeight / 2;
let best = null;
let bestDistance = Infinity;
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
if (rect.bottom <= 0 || rect.top >= window.innerHeight) return;
const distance = Math.abs((rect.top + rect.height / 2) - viewportCenter);
if (distance < bestDistance) {
bestDistance = distance;
best = card;
}
});
return (best || cards[0]).dataset.videoId || null;
};
App.videos.handleSearch = function(value) {
if (typeof value === 'string') {
const searchInput = document.getElementById('search-input');
if (searchInput && searchInput.value !== value) {
searchInput.value = value;
}
// Keep the clear button in sync without re-dispatching an `input`
// event (which would re-trigger the debounced reload listener).
const clearBtn = document.getElementById('search-clear-btn');
if (searchInput && clearBtn) {
const hasValue = searchInput.value.trim().length > 0;
clearBtn.classList.toggle('is-visible', hasValue);
clearBtn.disabled = !hasValue;
}
}
// A search is a new result set, so it leaves the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.resetGrid();
App.videos.loadVideos();
};
// Empties the grid back to "nothing loaded yet", without deciding what
// fills it next -- the caller does that.
App.videos.resetGrid = function() {
// The held/in-flight page belongs to the old result set.
App.videos.resetPrefetch();
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
}
App.videos.updateLoadMoreState();
};
App.videos.resetAndReload = function() {
if (state.currentLoadController) {
state.currentLoadController.abort();
state.currentLoadController = null;
state.isLoading = false;
}
// Switching source/channel/filters means leaving the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.resetGrid();
App.videos.loadVideos();
};
App.videos.ensureViewportFilled = function() {
if (!state.hasNextPage || state.isLoading) return;
const grid = document.getElementById('video-grid');
if (!grid) return;
const docHeight = document.documentElement.scrollHeight;
if (docHeight <= window.innerHeight + 120) {
window.setTimeout(() => App.videos.loadVideos(), 0);
}
};
// ---------------------------------------------------------------------
// Virtualized masonry grid
//
// The full set of loaded videos lives in state.loadedVideos. Only the cards
// whose computed position falls within the viewport (plus an overscan
// buffer) are kept in the DOM; the rest are unmounted. The container is
// given an explicit pixel height and every card is absolutely positioned at
// a precomputed (top,left), so:
// * the scrollbar and scroll position are identical to a fully-rendered
// grid, and
// * mounting/unmounting a card never moves any other card -- positions are
// assigned once and never change -- so there is no scroll jank.
//
// Thumbnails keep their natural aspect ratio (not cropped), so a card's real
// height isn't known until its image loads. We place each card with a 16:9
// estimate first, then once the image loads we correct that card's height
// and shift only the cards below it *in the same column* (each column is an
// independent vertical stack), so a correction never disturbs other columns
// or anything above it. Columns are assigned once and never change.
// ---------------------------------------------------------------------
App.virtualGrid = (function() {
const mounted = new Map(); // loadedVideos index -> card element
const revealed = new Set(); // indices that have played their entrance once
const layout = []; // index -> { top, left, width, height, col, posInCol }
const heightCache = new Map(); // shape signature -> estimated px height
const aspectCache = new Map(); // video id -> thumbnail width/height, once loaded
let colItems = []; // col -> ordered list of item indices in that column
let colBottoms = []; // running bottom y of each column
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
let initialized = false;
let rafPending = false;
let lastAnchor = null; // reader's place, refreshed on every scroll pass
let heldAnchor = null; // anchor frozen while a resize settles
// Viewports of cards kept mounted off-screen. Narrowing this during a
// fling looks like an obvious saving and measures as the opposite: a
// tight window means cards leave and re-enter it as the viewport moves,
// and the remounting costs far more than the painting it avoids. Tried
// at 0.25 -- mounts went from 88 to 155 and blocked time from 3.6s to
// 4.3s. It stays generous on purpose.
const OVERSCAN = 1.2;
const grid = () => document.getElementById('video-grid');
const measureMetrics = function() {
const el = grid();
if (!el) return false;
const phone = window.matchMedia('(max-width: 480px)').matches;
const mobile = window.matchMedia('(max-width: 768px)').matches;
const large = window.matchMedia('(min-width: 1600px)').matches;
gap = mobile ? 12 : (large ? 24 : 16);
padX = mobile ? 16 : (large ? 48 : 24);
padY = mobile ? 16 : (large ? 32 : 24);
// We own positioning, so neutralize the CSS grid + padding and read
// the resulting content width (which still honors max-width:auto
// centering).
el.style.display = 'block';
el.style.position = 'relative';
el.style.padding = '0';
const inner = el.clientWidth - padX * 2;
if (inner <= 0) return false;
// Density toggle (see enhance.js / settings) tunes the minimum card
// width, so "compact" packs more columns at the same viewport width.
// The user's Card Size setting scales that minimum on top of density.
const cardScale = (App.storage && App.storage.getCardScale) ? App.storage.getCardScale() : 1;
const minCardW = (document.body.dataset.density === 'compact' ? 210 : 260) * cardScale;
// One card per row on phones, two on larger handsets/tablets, and a
// size-driven count on desktop.
cols = phone ? 1 : (mobile ? 2 : Math.max(1, Math.floor((inner + gap) / (minCardW + gap))));
colWidth = (inner - gap * (cols - 1)) / cols;
return true;
};
const signatureOf = function(v) {
const hasTags = Array.isArray(v.tags) && v.tags.some((t) => t);
return [Math.round(colWidth), v.isLive ? 1 : 0, hasTags ? 1 : 0,
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
};
// Height of a card of `v`'s shape at the current column width, using the
// CSS 16:9 thumbnail placeholder (the image isn't loaded in the probe).
// Measured once per shape, then cached.
const shapeHeight = function(v) {
const sig = signatureOf(v);
const cached = heightCache.get(sig);
if (cached != null) return cached;
const el = grid();
if (!el) return 240;
const probe = App.videos.buildCard(v, { skipThumbnail: true });
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.left = '-99999px';
probe.style.top = '0';
probe.style.width = colWidth + 'px';
el.appendChild(probe);
const h = probe.getBoundingClientRect().height;
el.removeChild(probe);
heightCache.set(sig, h || 240);
return h || 240;
};
// Estimated height of item `v`. Once a thumbnail has loaded we know its
// real aspect ratio (recorded in aspectCache by mount()), which is
// column-width independent -- so we swap the shape probe's 16:9
// placeholder for the real thumbnail height instead of falling back to
// the 16:9 guess. That matters most on a re-pack (orientation change):
// without it every card above the viewport reverts to a guess, and the
// corrections trickling back in as images reload drag the page out from
// under the reader. The per-card correct() below still fixes whatever is
// left (title wrapping, tag rows).
const heightOf = function(v) {
const base = shapeHeight(v);
const aspect = (v && v.id != null) ? aspectCache.get(String(v.id)) : null;
if (!aspect) return base;
return Math.max(1, base - colWidth * 9 / 16 + colWidth / aspect);
};
const setContainerHeight = function() {
const el = grid();
if (!el) return;
const maxBottom = colBottoms.length ? Math.max.apply(null, colBottoms) : padY;
el.style.height = Math.max(0, maxBottom - gap + padY) + 'px';
};
// Assigns positions to items [start, end). Earlier items keep their
// positions because colBottoms carries forward unchanged. Each item is
// assigned to the currently-shortest column and stays there for good.
const packFrom = function(start) {
if (!cols) { if (!measureMetrics()) return; }
if (!colBottoms.length) colBottoms = new Array(cols).fill(padY);
if (!colItems.length) colItems = Array.from({ length: cols }, () => []);
for (let i = start; i < state.loadedVideos.length; i++) {
const v = state.loadedVideos[i];
const h = heightOf(v);
let col = 0;
for (let c = 1; c < cols; c++) {
if (colBottoms[c] < colBottoms[col]) col = c;
}
const top = colBottoms[col];
layout[i] = {
top,
left: padX + col * (colWidth + gap),
width: colWidth,
height: h,
col,
posInCol: colItems[col].length
};
colItems[col].push(i);
colBottoms[col] = top + h + gap;
}
setContainerHeight();
// A page has just landed -- its JSON and thumbnails were prefetched,
// so build its cards too while there is time, rather than when the
// reader arrives at them.
prepareAhead();
};
// Replaces item i's estimated height with its real (post-image-load)
// height and slides every card below it in the same column by the delta.
// Other columns and everything above are untouched -> no global reflow.
const correct = function(i) {
const card = mounted.get(i);
const l = layout[i];
if (!card || !l) return;
const real = card.getBoundingClientRect().height;
if (!real || Math.abs(real - l.height) < 1) return;
const delta = real - l.height;
l.height = real;
const list = colItems[l.col];
for (let k = l.posInCol + 1; k < list.length; k++) {
const j = list[k];
layout[j].top += delta;
const mc = mounted.get(j);
if (mc) mc.style.top = layout[j].top + 'px';
}
colBottoms[l.col] += delta;
setContainerHeight();
scheduleUpdate();
};
const place = function(card, l) {
card.style.position = 'absolute';
card.style.top = l.top + 'px';
card.style.left = l.left + 'px';
card.style.width = l.width + 'px';
};
// Cards are pooled, not thrown away: building one costs ~250us of
// parsing and allocation, rebinding an existing one ~4us, and scrolling
// a grid does nothing but mount and unmount. Bounded at roughly twice
// the mounted window so a long session can't grow the pool without end.
const pool = [];
const POOL_MAX = 60;
let builtCards = 0;
let recycledCards = 0;
let preparedCards = 0;
// ---------------------------------------------------------------
// Flinging
//
// A card that flies past costs far more than it costs to build. Its
// thumbnail is a request, a decode and a height correction; its title
// is a forced layout; its formats are a yt-dlp extraction on the
// server. Every one of those is work for a video the reader has not
// looked at, and during a fast scroll there are dozens of them.
//
// So a mount while flinging only *places* the card -- it takes its
// space in the layout, at the right size, with its text. Everything
// that costs is deferred to the moment the scroll settles, and only
// for the cards still on screen by then. Anything scrolled past in the
// meantime is unmounted having cost almost nothing.
// ---------------------------------------------------------------
const FLING_PX_PER_MS = 1.6; // ~1600px/s, well past a deliberate scroll
const SETTLE_MS = 140;
let flinging = false;
let lastScrollY = 0;
let lastScrollAt = 0;
let settleTimer = null;
const unfilled = new Set(); // mounted indices still waiting to be filled
const noteScroll = function() {
const now = performance.now();
const y = window.scrollY;
const dt = now - lastScrollAt;
if (dt > 0 && lastScrollAt) {
flinging = (Math.abs(y - lastScrollY) / dt) > FLING_PX_PER_MS;
}
lastScrollY = y;
lastScrollAt = now;
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(settle, SETTLE_MS);
};
// DOM work cannot leave the main thread -- no worker can touch it -- so
// "smooth" is not a matter of doing it elsewhere but of never holding
// the thread long enough to miss a frame. Both queues below are
// therefore drained against a budget: a few cards per frame, the rest
// carried to the next one. Total work is unchanged; what changes is
// that it stops arriving in one lump.
const FRAME_BUDGET_MS = 4;
const settle = function() {
settleTimer = null;
flinging = false;
drainFills();
warmPool();
prepareAhead();
};
// Filling every deferred card at once -- which is what settling used to
// do -- just moves the stall from during the fling to the end of it.
const drainFills = function() {
const started = performance.now();
const queued = Array.from(unfilled);
for (let k = 0; k < queued.length; k++) {
if (performance.now() - started >= FRAME_BUDGET_MS) break;
const i = queued[k];
const card = mounted.get(i);
if (card) fill(i, card, state.loadedVideos[i]);
else unfilled.delete(i);
}
if (unfilled.size) requestAnimationFrame(drainFills);
};
// Cards entering the window, mounted a few per frame. A fast scroll can
// bring a dozen or more into range at once, and mounting them in one
// callback is a frame missed no matter how cheap each card is.
let mountQueue = [];
const drainMounts = function() {
const started = performance.now();
// One insertion for the whole frame's worth of cards rather than one
// per card: the browser gets a single subtree to take in.
const batch = document.createDocumentFragment();
const placed = [];
while (mountQueue.length && performance.now() - started < FRAME_BUDGET_MS) {
const i = mountQueue.shift();
const card = mount(i, batch);
if (card) placed.push([i, card]);
}
if (placed.length) grid().appendChild(batch);
// Filling reads layout, so it happens after the batch is in.
placed.forEach(([i, card]) => {
const v = state.loadedVideos[i];
if (flinging) unfilled.add(i);
else fill(i, card, v);
});
if (mountQueue.length) requestAnimationFrame(drainMounts);
};
// Cards built ahead of being needed, while nothing else is happening,
// so a mount during a scroll is always the cheap rebind and never the
// expensive construction. The page already prefetches the next page's
// JSON and thumbnails (see prefetchNextBatch); this is the same idea
// applied to the DOM those items will need.
const POOL_WARM_TARGET = 24;
const warmPool = function() {
if (pool.length >= POOL_WARM_TARGET) return;
idle(() => {
const deadline = performance.now() + 3;
while (pool.length < POOL_WARM_TARGET && performance.now() < deadline) {
pool.push(createCardShell());
}
if (pool.length < POOL_WARM_TARGET) warmPool();
});
};
// Cards for videos just past the window, built and bound to their video
// ahead of time. This is as close to "prepare the next page off-thread"
// as a browser allows: the DOM itself can only ever be built on the main
// thread, so the saving comes from doing it while nothing else is
// happening rather than during the scroll that needs it.
const PREPARE_AHEAD = 12;
const prepared = new Map(); // video id -> card, ready to place
let preparing = false;
const prepareAhead = function() {
if (preparing) return; // one idle pass at a time, not one per call
preparing = true;
idle(() => {
preparing = false;
const videos = state.loadedVideos;
if (!videos.length) return;
// Start from the last mounted index: the cards after it are the
// ones the reader is heading towards.
let highest = -1;
mounted.forEach((card, i) => { if (i > highest) highest = i; });
const deadline = performance.now() + 3;
for (let i = highest + 1; i < videos.length && i <= highest + PREPARE_AHEAD; i++) {
if (performance.now() >= deadline) break;
const v = videos[i];
if (!v || mounted.has(i) || prepared.has(String(v.id))) continue;
const card = pool.pop() || createCardShell();
App.videos.bindCard(card, v);
prepared.set(String(v.id), card);
}
});
};
// Hands back a card already bound to this video, if one was prepared.
const takePrepared = function(v) {
const key = String(v.id);
const card = prepared.get(key);
if (!card) return null;
prepared.delete(key);
// Favouriting may have happened since it was prepared, and the heart
// is the one part of a card that changes without the video changing.
App.favorites.setButtonState(cardRefs(card).favorite,
!!App.favorites.getKey(v) && App.favorites.has(v));
return card;
};
const dropPrepared = function() {
prepared.forEach((card) => {
App.videos.resetCard(card);
if (pool.length < POOL_MAX) pool.push(card);
});
prepared.clear();
};
const acquire = function(v) {
const ready = takePrepared(v);
if (ready) {
preparedCards++;
return ready;
}
const card = pool.pop();
if (!card) {
builtCards++;
return App.videos.buildCard(v, { skipThumbnail: true });
}
recycledCards++;
App.videos.bindCard(card, v);
return card;
};
const release = function(card) {
resolveObserver.unobserve(card);
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
}
if (card._revealAnim) {
card.removeEventListener('animationend', card._revealAnim);
card._revealAnim = null;
}
card.remove();
cardVideo.delete(card);
App.videos.resetCard(card);
if (pool.length < POOL_MAX) pool.push(card);
};
const mount = function(i, batch) {
if (mounted.has(i)) return null;
const v = state.loadedVideos[i];
const l = layout[i];
if (!v || !l) return null;
const card = acquire(v);
place(card, l);
// Entrance animation only the first time an index appears, so cards
// don't re-animate every time they scroll back into the window.
if (!revealed.has(i)) {
revealed.add(i);
card.classList.add('is-revealing');
// Kept so release can take it off again: a card recycled before
// the animation ends would otherwise carry the listener for the
// rest of its life.
const onRevealEnd = () => card.classList.remove('is-revealing');
card._revealAnim = onRevealEnd;
card.addEventListener('animationend', onRevealEnd, { once: true });
}
(batch || grid()).appendChild(card);
mounted.set(i, card);
if (batch) return card; // the caller fills, once the batch is in
if (flinging) unfilled.add(i);
else fill(i, card, v);
return card;
};
// The part of a mount that costs: the thumbnail, the title measurement
// and the format resolve the observer triggers.
const fill = function(i, card, v) {
if (!card || !v) return;
unfilled.delete(i);
const img = cardRefs(card).img;
App.videos.attachThumbnail(img, v.thumb);
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
// its true aspect ratio, clear the shimmer, then correct the height.
if (img) {
const reveal = () => {
// Release nulls this, so a reveal already queued for a frame
// that lands after the card was rebound does nothing --
// covers the requestAnimationFrame path, which unlike the
// load listener cannot simply be removed.
if (img._revealHandler !== reveal) return;
img.style.aspectRatio = 'auto';
img.classList.add('is-loaded');
// Remember the real aspect ratio so any later re-pack (see
// heightOf) can place this card at its true height straight
// away instead of re-guessing 16:9.
if (v.id != null && img.naturalWidth && img.naturalHeight) {
aspectCache.set(String(v.id), img.naturalWidth / img.naturalHeight);
}
if (mounted.get(i) === card) correct(i);
};
// Held on the element so release can remove it: this closes
// over `v` and `i`, and a recycled image firing it again would
// file the new picture's dimensions under the old video's id.
img._revealHandler = reveal;
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
else img.addEventListener('load', reveal);
}
// Marquee + direct-playability probe only matter for on-screen cards.
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
resolveObserver.observe(card);
};
const unmount = function(i) {
const card = mounted.get(i);
if (!card) return;
mounted.delete(i);
unfilled.delete(i);
release(card);
};
// Mounts cards intersecting the viewport window, unmounts the rest.
const update = function() {
const el = grid();
if (!el || !state.loadedVideos.length) return;
const rectTop = el.getBoundingClientRect().top; // container top vs viewport
const vh = window.innerHeight || 800;
const viewTop = -rectTop; // viewport top in container space
const start = viewTop - vh * OVERSCAN;
const end = viewTop + vh * (1 + OVERSCAN);
let anchorIndex = -1;
let anchorTop = Infinity;
// Everything leaving the window is released before anything entering
// it is mounted, so the cards just freed are available to the cards
// arriving. Mounting first -- which is what walking the list in
// index order does when scrolling up -- finds the pool empty and
// builds a card it was about to be handed.
const entering = [];
for (let i = 0; i < layout.length; i++) {
const l = layout[i];
if (!l) continue;
const visible = l.top < end && (l.top + l.height) > start;
if (visible) {
if (!mounted.has(i)) entering.push(i);
} else if (mounted.has(i)) {
unmount(i);
}
// Topmost card still crossing the viewport top: the reader's
// place in the list, kept fresh on every scroll pass (see
// lastAnchor).
if (l.top + l.height > viewTop && l.top < anchorTop) {
anchorTop = l.top;
anchorIndex = i;
}
}
// Unmounting already happened above, so the pool is stocked before
// anything asks it for a card.
mountQueue = entering;
drainMounts();
// Frozen while a resize settles: the browser moves the scroll
// position itself during a rotation, and tracking that would replace
// the reader's real place with wherever the browser landed.
if (!heldAnchor) {
lastAnchor = anchorIndex >= 0 ? { index: anchorIndex, offsetTop: anchorTop - viewTop } : null;
}
// Hand the prefetched page over once the reader reaches the
// second-to-last row: it was fetched and its thumbnails decoded a
// page ago, so the new cards appear finished rather than loading.
if (App.videos.releasePrefetched && isNearEnd(viewTop, vh)) {
App.videos.releasePrefetched();
}
};
// True once the second-to-last row has come into view. Deliberately not
// "the topmost visible card is in the last two rows": a desktop viewport
// shows several rows at once, so by that measure the reader would hit the
// bottom of the list without the test ever passing.
const isNearEnd = function(viewTop, vh) {
const el = grid();
if (!el || !cols || !layout.length) return false;
if (viewTop == null) viewTop = -el.getBoundingClientRect().top;
if (vh == null) vh = window.innerHeight || 800;
let tailTop = Infinity;
for (let i = Math.max(0, layout.length - cols * 2); i < layout.length; i++) {
const l = layout[i];
if (l && l.top < tailTop) tailTop = l.top;
}
return tailTop !== Infinity && viewTop + vh >= tailTop;
};
const onScroll = function() {
noteScroll();
scheduleUpdate();
};
const scheduleUpdate = function() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; update(); });
};
// Full re-pack + remount, used when the column geometry changes.
const relayout = function() {
if (!measureMetrics()) return;
// Every card is about to move, so the raw scroll position stops
// meaning anything: hold the reader's place and put them back on it
// once the new positions exist. Applies to every re-pack, not just
// rotation -- changing card size or density moves the grid too.
const anchor = heldAnchor || lastAnchor;
heightCache.clear(); // colWidth changed -> heights differ
mounted.forEach((card, i) => unmount(i));
layout.length = 0;
colBottoms = new Array(cols).fill(padY);
colItems = Array.from({ length: cols }, () => []);
packFrom(0);
restoreAnchor(anchor);
update();
};
// Scrolls so the anchored card sits at the same viewport offset it had
// before the re-pack, keeping the user's place across the layout change.
// The anchor is the topmost card crossing the viewport top (rather than
// the centred one), which is independent of the viewport height -- and
// that height has already changed by the time we hear about a rotation.
const restoreAnchor = function(anchor) {
if (!anchor) return;
const el = grid();
const l = layout[anchor.index];
if (!el || !l) return;
const gridTopDoc = el.getBoundingClientRect().top + window.scrollY;
const target = gridTopDoc + l.top - anchor.offsetTop;
const clamped = Math.max(0, Math.min(target,
Math.max(0, document.documentElement.scrollHeight - (window.innerHeight || 0))));
if (Math.abs(clamped - window.scrollY) > 1) window.scrollTo(0, clamped);
};
// A rotation re-packs every column, so the raw scrollTop afterwards
// points at a different video. Restoring the anchor once, in the frame
// after `resize`, isn't enough on a phone: the browser fires `resize`
// partway through the rotation animation (with metrics that are still
// changing) and then adjusts the scroll position itself *after* our
// handler has run, overwriting our restore. So we hold the anchor taken
// *before* the change and re-assert it across a short settling window,
// re-packing on each tick only if the column geometry actually moved.
// The window ends early the moment the user scrolls, so we never fight
// a real gesture.
const SETTLE_TICKS = [0, 60, 150, 300, 500];
let settleTimers = [];
const endSettle = function() {
settleTimers.forEach(clearTimeout);
settleTimers = [];
heldAnchor = null;
};
const settleTick = function() {
const prevCols = cols;
const prevWidth = colWidth;
if (measureMetrics() && (cols !== prevCols || Math.abs(colWidth - prevWidth) > 0.5)) {
relayout(); // re-packs and restores the anchor
}
if (heldAnchor) restoreAnchor(heldAnchor);
};
// A soft keyboard opening also fires `resize` (and moves the scroll
// position deliberately, to reveal the focused field): leave that alone.
const isTypingTarget = function() {
const el = document.activeElement;
if (!el) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
};
let settleWidth = 0;
const beginSettle = function(event) {
if (isTypingTarget()) return;
// Only a width change moves cards: their positions are absolute
// pixels in the grid's own space, so a height-only resize -- the
// mobile URL bar collapsing as you scroll, most of all -- leaves the
// layout (and therefore the scroll position) exactly right. Settling
// on those was actively harmful: a URL-bar resize landing mid-flick
// re-asserted an anchor captured before the flick and yanked the
// page back, killing the momentum scroll.
const width = window.innerWidth || 0;
const widthChanged = width !== settleWidth;
settleWidth = width;
const rotating = !!(event && event.type === 'orientationchange');
if (!widthChanged && !rotating) {
// One exception: a scrollbar appearing/disappearing changes the
// grid's inner width while window.innerWidth stays put. Re-pack
// if the columns really moved (relayout keeps the reader's
// place); otherwise leave the scroll completely alone.
const prevCols = cols;
const prevColWidth = colWidth;
if (measureMetrics() && (cols !== prevCols || Math.abs(colWidth - prevColWidth) > 0.5)) {
relayout();
}
return;
}
if (!heldAnchor) heldAnchor = lastAnchor;
settleTimers.forEach(clearTimeout);
settleTimers = SETTLE_TICKS.map((ms) => setTimeout(settleTick, ms));
settleTimers.push(setTimeout(endSettle, SETTLE_TICKS[SETTLE_TICKS.length - 1] + 100));
};
const ensureInit = function() {
if (!cols) measureMetrics();
bindGridDelegation();
if (initialized) return;
initialized = true;
settleWidth = window.innerWidth || 0;
warmPool();
window.addEventListener('scroll', onScroll, { passive: true });
// orientationchange fires first (before the viewport metrics change),
// which is exactly when the pre-rotation anchor is still valid.
window.addEventListener('orientationchange', beginSettle);
window.addEventListener('resize', beginSettle);
// Deliberately not on visualViewport: that also fires for pinch-zoom
// and keyboard insets, where pinning the scroll position would fight
// the user. A rotation always fires the window events above.
// Any real scroll gesture (including a pinch) ends the window early.
['wheel', 'touchmove', 'keydown'].forEach((evt) => {
window.addEventListener(evt, endSettle, { passive: true });
});
};
const reset = function() {
// Releases rather than discards: a new result set is about to mount
// its own cards, and these are exactly the right shape for it.
mounted.forEach((card, i) => unmount(i));
mounted.clear();
unfilled.clear();
dropPrepared();
mountQueue = [];
flinging = false;
revealed.clear(); // new result set should animate in again
layout.length = 0;
colBottoms = [];
colItems = [];
const el = grid();
if (el) { el.style.height = '0px'; }
};
// Removes a single video's card from the grid: its DOM element is
// unmounted directly (so it's gone even if the re-pack below can't run),
// then the remaining cards are re-packed against the now-shorter queue.
// The caller must have already removed the video from state.loadedVideos.
const removeVideo = function(videoId) {
const id = String(videoId);
mounted.forEach((card, i) => {
if (card.dataset.videoId === id) unmount(i);
});
relayout();
};
const stats = function() {
return { built: builtCards, recycled: recycledCards, prepared: preparedCards,
pooled: pool.length, readied: prepared.size,
unfilled: unfilled.size, flinging: flinging };
};
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
})();
App.videos.updateLoadMoreState = function() {
const loadMoreBtn = document.getElementById('load-more-btn');
if (!loadMoreBtn) return;
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
loadMoreBtn.style.display = state.hasNextPage ? 'flex' : 'none';
};
// Context menu helpers for per-card actions.
App.videos.closeAllMenus = function() {
document.querySelectorAll('.video-menu.open').forEach((menu) => {
menu.classList.remove('open');
});
document.querySelectorAll('.video-menu-btn[aria-expanded="true"]').forEach((btn) => {
btn.setAttribute('aria-expanded', 'false');
});
};
App.videos.toggleMenu = function(menu, button) {
const isOpen = menu.classList.contains('open');
App.videos.closeAllMenus();
if (!isOpen) {
menu.classList.add('open');
if (button) {
button.setAttribute('aria-expanded', 'true');
}
}
};
App.videos.coerceNumber = function(value) {
if (value === null || value === undefined) return 0;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
if (typeof value === 'string') {
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
const headerValue = function(headers, name) {
if (!headers) return '';
return headers[name] || headers[name.toLowerCase()] || '';
};
// Merge the resource-level (meta) and format-level http_headers into a single
// map so every upstream header the extractor attached (Referer, User-Agent,
// Cookie, etc.) can be relayed to the stream proxy. Format-level headers win
// on conflict since they describe the specific media URL.
const mergeHeaders = function(metaHeaders, fmtHeaders) {
const merged = {};
[metaHeaders, fmtHeaders].forEach((headers) => {
if (!headers || typeof headers !== 'object') return;
Object.keys(headers).forEach((name) => {
const value = headers[name];
if (value === undefined || value === null || value === '') return;
merged[name] = String(value);
});
});
return merged;
};
const deriveReferer = function(url) {
if (!url) return '';
try {
return `${new URL(url).origin}/`;
} catch (err) {
return '';
}
};
// Ranks the playable formats best-first so callers can fall back to the
// next candidate when a URL fails. Quality is the primary key; when several
// formats share the same quality the one that appears later in the source
// list is preferred (start with the last one). When a preferred height is
// set, formats at or below it come first (best of those first), followed by
// anything above it ordered closest-to-preferred first as a last resort.
// How expensive a format is to decode, beyond its pixel count. Lower is
// cheaper, and these are ordered by how much they actually cost:
//
// HLS means hls.js demuxes every segment in JavaScript before the browser
// sees it. A progressive file skips that entirely -- and with several
// panels each running their own instance, it is the difference between
// one media pipeline and four.
//
// AV1 and VP9 are frequently decoded in software; H.264 has hardware
// support almost everywhere. That is a cliff, not a gradient.
//
// 60fps is twice the frames of 30fps for the same picture.
const decodeCost = function(fmt) {
const protocol = String(fmt.protocol || '').toLowerCase();
const ext = String(fmt.ext || '').toLowerCase();
const vcodec = String(fmt.vcodec || '').toLowerCase();
const streaming = protocol.indexOf('m3u8') >= 0 || protocol.indexOf('dash') >= 0
|| ext === 'm3u8' || ext === 'mpd' ? 1 : 0;
const software = (vcodec.indexOf('av01') === 0 || vcodec.indexOf('vp9') === 0
|| vcodec.indexOf('vp09') === 0) ? 1 : 0;
const highFps = App.videos.coerceNumber(fmt.fps) > 35 ? 1 : 0;
return streaming * 4 + software * 2 + highFps;
};
// `options.cheapest` ranks by what a machine has to do to play the format,
// once the height ceiling has been applied. It is for showing several
// videos at once, where the limit is the decoder rather than the picture.
App.videos.rankFormats = function(formats, preferredHeight, options) {
if (!Array.isArray(formats) || formats.length === 0) return [];
const candidates = formats
.map((fmt, index) => ({ fmt, index }))
.filter((entry) => entry.fmt && entry.fmt.url);
if (!candidates.length) return [];
const videoCandidates = candidates.filter((entry) => {
const videoExt = String(entry.fmt.video_ext || '').toLowerCase();
const vcodec = String(entry.fmt.vcodec || '').toLowerCase();
if (videoExt && videoExt !== 'none') return true;
if (vcodec && vcodec !== 'none') return true;
return false;
});
const pool = videoCandidates.length ? videoCandidates : candidates;
const cheapest = !!(options && options.cheapest);
const score = (fmt) => {
const height = App.videos.coerceNumber(fmt.height || fmt.quality);
const width = App.videos.coerceNumber(fmt.width);
const size = height || width;
const bitrate = App.videos.coerceNumber(fmt.tbr || fmt.bitrate);
const fps = App.videos.coerceNumber(fmt.fps);
// Cost is negated so that the same descending comparison puts the
// cheaper format first, and it outranks bitrate: a slightly softer
// picture that plays is worth more than a sharper one that stutters.
if (cheapest) return [size, -decodeCost(fmt), bitrate, fps];
return [size, bitrate, fps];
};
// Tie-break on the original index so equal-quality formats start with
// the last one in the source list.
const compare = (a, b, descending) => {
const sa = score(a.fmt);
const sb = score(b.fmt);
for (let i = 0; i < sa.length; i++) {
if (sa[i] !== sb[i]) return descending ? sb[i] - sa[i] : sa[i] - sb[i];
}
return b.index - a.index;
};
if (preferredHeight) {
const atOrBelow = [];
const above = [];
pool.forEach((entry) => {
const size = score(entry.fmt)[0];
if (size > 0 && size <= preferredHeight) {
atOrBelow.push(entry);
} else {
above.push(entry);
}
});
atOrBelow.sort((a, b) => compare(a, b, true));
above.sort((a, b) => compare(a, b, false));
return atOrBelow.concat(above).map((entry) => entry.fmt);
}
return pool.slice().sort((a, b) => compare(a, b, true)).map((entry) => entry.fmt);
};
App.videos.pickBestFormat = function(formats, preferredHeight) {
const ranked = App.videos.rankFormats(formats, preferredHeight);
return ranked.length ? ranked[0] : null;
};
// Resolves an ordered list of stream source candidates (best first). The
// player walks this list and falls back to the next entry when a URL fails.
App.videos.resolveStreamSources = function(videoOrUrl, options) {
const applyPreferredQuality = !options || options.applyPreferredQuality !== false;
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
if (typeof videoOrUrl === 'string') {
return videoOrUrl ? [{ url: videoOrUrl, referer: deriveReferer(videoOrUrl), userAgent: '', isLive, refererRequired: false }] : [];
}
if (!videoOrUrl || typeof videoOrUrl !== 'object') return [];
const meta = videoOrUrl.meta || videoOrUrl;
const metaReferer = headerValue(meta.http_headers, 'Referer');
const metaUserAgent = headerValue(meta.http_headers, 'User-Agent');
let preferredHeight = null;
if (applyPreferredQuality) {
const preferredQuality = App.storage.getPreferredQuality();
preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality);
// A caller showing this in a fraction of the screen -- a split reels
// panel -- caps it further. Decoding a 1080p stream into a quarter
// of a phone screen costs the same as decoding it full size, and
// four of those at once is what makes a split view stutter.
const maxHeight = App.videos.coerceNumber(options && options.maxHeight);
if (maxHeight > 0) {
preferredHeight = preferredHeight ? Math.min(preferredHeight, maxHeight) : maxHeight;
}
}
const ranking = { cheapest: !!(options && options.cheapest) };
const sources = App.videos.rankFormats(meta.formats, preferredHeight, ranking).map((fmt) => {
// An *explicit* Referer (from the extractor) signals the upstream
// enforces it; deriveReferer is only a best-effort fallback. The
// browser can't set a cross-origin Referer, so refererRequired tells
// callers (the probe) that direct playback can't work.
const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer;
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
// `protocol` is the extractor's own word for how this format is
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
// the player skip its content-type sniff -- a full round trip
// through the proxy -- for URLs whose extension gives nothing away.
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
});
if (!sources.length) {
const fallbackUrl = meta.url || videoOrUrl.url || '';
if (fallbackUrl) {
sources.push({
url: fallbackUrl,
referer: metaReferer || deriveReferer(fallbackUrl),
userAgent: metaUserAgent,
headers: mergeHeaders(meta.http_headers, null),
isLive,
refererRequired: !!metaReferer
});
}
}
return sources;
};
App.videos.resolveStreamSource = function(videoOrUrl, options) {
const sources = App.videos.resolveStreamSources(videoOrUrl, options);
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false, refererRequired: false };
};
// Resolves one specific format (as picked from a format-switcher menu, see
// App.customPlayer.buildFormatOptions) into a playable source, using the
// same header-merging rules as the automatic ranked path above.
App.videos.resolveSourceForFormat = function(videoOrUrl, fmt) {
if (!fmt || !fmt.url) return null;
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
const meta = (videoOrUrl && typeof videoOrUrl === 'object' && (videoOrUrl.meta || videoOrUrl)) || {};
const metaReferer = headerValue(meta.http_headers, 'Referer');
const metaUserAgent = headerValue(meta.http_headers, 'User-Agent');
const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer;
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
};
// How does this source play -- an HLS manifest, or a media file the <video>
// element can take directly? Answered from the strongest evidence at hand:
// a live stream is always a manifest here, then the extractor's `protocol`,
// then the URL's extension. When none of them says (a signed CDN link with
// no extension and no protocol), the caller is left to sniff the content
// type over the network, which is why `protocol` is worth carrying around.
App.videos.classifySource = function(resolved) {
const url = (resolved && resolved.url) || '';
let isHls = /\.m3u8($|\?)/i.test(url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
if (protocol.indexOf('m3u8') >= 0) {
isHls = true;
isDirectMedia = false;
} else if (protocol === 'https' || protocol === 'http') {
// A plain HTTP(S) download: one file, played as-is. Anything else
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
isDirectMedia = true;
}
if (resolved && resolved.isLive) {
isHls = true;
isDirectMedia = false;
}
return { isHls, isDirectMedia };
};
// Background "direct playability" probe. The backend proxy exists to work
// around CORS, hotlink (403) protection, and TLS fingerprinting. When the
// browser can fetch a media URL cross-origin and actually read the response
// (CORS allowed, not blocked/403), playing it directly works and the proxy
// is pure overhead. CORS is an origin-level policy, so the answer is the
// same for every media URL served by a given host: we probe (and cache)
// once per host and let the player skip the proxy for any URL on a host
// that's been proven.
const DIRECT_PROBE_TIMEOUT_MS = 8000;
const directHostOf = (url) => {
try { return new URL(url).host; } catch (err) { return ''; }
};
// host -> true (proven directly playable) | false (proven not). Absent
// means unknown/unprobed, in which case the proxy is used.
App.videos._directStatus = new Map();
const directPending = new Map();
App.videos.isDirectProven = function(url) {
return App.videos._directStatus.get(directHostOf(url)) === true;
};
App.videos.probeDirect = function(url) {
if (!url) return Promise.resolve(false);
const host = directHostOf(url);
if (!host) return Promise.resolve(false);
if (App.videos._directStatus.has(host)) {
return Promise.resolve(App.videos._directStatus.get(host));
}
if (directPending.has(host)) {
return directPending.get(host);
}
const promise = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS);
let ok = false;
let detail = '';
try {
// A simple GET (no custom headers) avoids a CORS preflight.
const res = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal: controller.signal
});
if (!(res.ok || res.status === 206)) {
ok = false;
detail = `HTTP ${res.status}`;
} else if (res.body && typeof res.body.getReader === 'function') {
// Run it until actual media bytes arrive. A readable status
// line is weaker evidence than it looks: the question is
// whether this origin will hand *the player* video data
// cross-origin, and that isn't settled until some has
// arrived. Then stop -- the rest of the file is not our
// business, and it can be a whole film.
const reader = res.body.getReader();
const chunk = await reader.read();
const bytes = (!chunk.done && chunk.value && chunk.value.length) || 0;
ok = bytes > 0;
detail = `HTTP ${res.status}, ${bytes} bytes`;
reader.cancel().catch(() => {});
} else {
// No readable stream to sample (an old browser): the status
// line is all the evidence on offer.
ok = true;
detail = `HTTP ${res.status}, headers only`;
}
controller.abort();
} catch (err) {
// A CORS refusal lands here as a TypeError with no status --
// the browser won't say more than "failed" about a response it
// wouldn't let us read.
ok = false;
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
} finally {
clearTimeout(timer);
}
App.videos._directStatus.set(host, ok);
directPending.delete(host);
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${host}`);
return ok;
})();
directPending.set(host, promise);
return promise;
};
// Listing items arrive without formats (meta is null) -- only a page URL --
// so this resolves a video's real media formats via the backend (yt-dlp) and
// attaches them as `video.meta`, which is what playback, the quality menu
// and the hover preview all need. Resolution is per-video and deduped: it
// runs at most once per video, triggered lazily by hover/scroll so we don't
// resolve cards the user never looks at.
//
// It deliberately does *not* test direct playability. That question belongs
// to the video actually being played (see raceDirect in player.js): a
// provider can spread its media over several CDNs, so an answer taken from
// whichever card happened to scroll past need not hold for the one the
// reader picks.
const cardVideo = new WeakMap();
// Session cache of `/api/resolve` results, keyed by video id (falling back
// to the page URL). Keyed rather than per-object so any *other* object
// describing the same video -- a favorites entry rebuilt from localStorage,
// a feed slide, a re-rendered card -- gets the formats attached too instead
// of silently skipping the fetch because some earlier object already ran it.
// Failures resolve to null and are cached the same way, so a broken video is
// attempted once per session.
const metaCache = new Map();
const hasFormats = (meta) => !!(meta && Array.isArray(meta.formats) && meta.formats.length);
// Resolves a video's real media formats (once per session) and attaches them
// as `video.meta`. Returns the resolved meta, or null when it can't be had.
// Mirrors the backend's is_direct_media(): a URL whose path (query ignored,
// trailing slash tolerated) already names a media file.
const DIRECT_MEDIA_RE = /\.(mp4|m4v|m4s|webm|mov|ts|m3u8|mpd)$/i;
const isDirectMediaUrl = function(url) {
if (!url) return false;
try {
return DIRECT_MEDIA_RE.test(new URL(url, window.location.href).pathname.replace(/\/+$/, ''));
} catch (err) {
return false;
}
};
App.videos.ensureFormats = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve(null);
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
const cacheKey = video.id || video.url;
if (!cacheKey || !video.url) return Promise.resolve(null);
// Some channels hand back the media URL itself as the item URL. Sending
// that to /api/resolve makes yt-dlp fetch the media just to tell us what
// we already know: slow, and on a signed URL it's a second request
// against a link that may be single-use or IP-bound -- after which the
// one that matters, the actual playback fetch, is refused. Play it as-is.
if (isDirectMediaUrl(video.url)) {
const meta = { url: video.url, formats: [{ url: video.url }], http_headers: {} };
video.meta = meta;
return Promise.resolve(meta);
}
let promise = metaCache.get(cacheKey);
if (!promise) {
promise = (async () => {
try {
const response = await fetch('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: video.url })
});
if (!response.ok) return null;
const data = await response.json();
return hasFormats(data) ? data : null;
} catch (err) {
// Best-effort: playback still works through the proxy.
return null;
}
})();
metaCache.set(cacheKey, promise);
}
return promise.then((data) => {
if (data) video.meta = data;
return data;
});
};
// Everything the extractor knows about a video, for the Show info panel:
// description, dates, counts, categories, thumbnails, every field of every
// format. Deliberately not `ensureFormats`' payload and deliberately not
// attached to `video.meta` -- meta is fetched for every card that scrolls
// past and is kept small on purpose, and it names things playback's way
// (`isLive`) rather than the extractor's (`is_live`). Cached per video for
// the session, like meta, so reopening the panel is free.
const fullInfoCache = new Map();
App.videos.fetchFullInfo = function(video) {
if (!video || typeof video !== 'object' || !video.url) return Promise.resolve(null);
// Same reasoning as ensureFormats: a URL that already names a media file
// has nothing to extract, and re-fetching a signed one can burn a
// single-use link that playback still needs.
if (isDirectMediaUrl(video.url)) return Promise.resolve(null);
const cacheKey = video.id || video.url;
let promise = fullInfoCache.get(cacheKey);
if (!promise) {
promise = (async () => {
try {
const response = await fetch('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: video.url, full: '1' })
});
if (!response.ok) return null;
const data = await response.json();
if (!data || typeof data !== 'object' || !Object.keys(data).length) return null;
return data;
} catch (err) {
// Best-effort: the panel still shows what the client holds.
return null;
}
})();
fullInfoCache.set(cacheKey, promise);
}
return promise;
};
const resolveObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
resolveObserver.unobserve(entry.target);
const video = cardVideo.get(entry.target);
if (video) App.videos.ensureFormats(video);
});
}, { rootMargin: '200px' });
// Builds a proxied stream URL. Extra params other than `url` are forwarded
// by the backend as request headers, so use real header names here.
App.videos.buildStreamUrlFromSource = function(resolved) {
if (!resolved || !resolved.url) return '';
const params = [];
// Referer keeps its dedicated lowercase param (the backend maps it back to
// `Referer`) so the derived-referer fallback in resolveStreamSources is
// honoured even when no explicit Referer header was present.
if (resolved.referer) params.push(`referer=${encodeURIComponent(resolved.referer)}`);
if (resolved.userAgent) params.push(`User-Agent=${encodeURIComponent(resolved.userAgent)}`);
// Relay every other upstream header the extractor attached (e.g. Cookie).
// Referer/User-Agent are already emitted above, so skip them here to avoid
// duplicating the same header under two query keys.
const headers = resolved.headers;
if (headers && typeof headers === 'object') {
Object.keys(headers).forEach((name) => {
const lower = name.toLowerCase();
if (lower === 'referer' || lower === 'user-agent') return;
const value = headers[name];
if (value === undefined || value === null || value === '') return;
params.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
});
}
if (resolved.isLive) params.push('live=1');
const query = params.length ? `&${params.join('&')}` : '';
return `/api/stream?url=${encodeURIComponent(resolved.url)}${query}`;
};
App.videos.buildStreamUrl = function(videoOrUrl, options) {
return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options));
};
// Lets enhancement layers (e.g. hover preview) recover the video object that
// backs a mounted card without reaching into the virtualizer internals.
App.videos.getVideoForCard = function(card) {
return cardVideo.get(card);
};
App.videos.downloadVideo = function(video) {
if (!video) return;
const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false });
if (!streamUrl) return;
const link = document.createElement('a');
link.href = streamUrl;
const rawName = (video.title || video.id || 'video').toString();
const safeName = rawName.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80);
link.download = safeName ? `${safeName}.mp4` : 'video.mp4';
document.body.appendChild(link);
link.click();
link.remove();
};
})();