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
// A page of cards is built in one go, so every thumbnail from a host is
// attached before the first one has come back. Sending them all down the
// optimistic route is how the old serial retry hurt: on a host that blocks
// us, each card paid its own failed request before asking the proxy. So
// while a host is being decided the rest of its images wait for the answer
// -- at most as long as the fastest route takes -- and then load once, the
// right way round. If the race somehow stalls they go anyway.
const RACE_PATIENCE_MS = 2500;
// Direct is the route we'd rather settle on, so when the proxy comes home
// first the held images give direct this much longer to answer before
// committing to the proxy. Long enough that a provider merely a little
// slower than same-origin still wins its hosts; short enough that one which
// hangs -- the case this whole thing exists for -- doesn't hold up a grid.
const DIRECT_GRACE_MS = 200;
const imageHostOf = function(url) {
try {
return new URL(url, window.location.href).host;
} catch (err) {
return '';
}
};
// Where a thumbnail from this host should be fetched from. Answers with the
// provider while the host is still unknown -- the optimistic route, and the
// one a race starts on anyway.
App.videos.thumbnailUrl = function(url) {
if (!url) return '';
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
? (App.videos.buildImageProxyUrl(url) || url)
: url;
};
// A src-less counts as "unavailable", and the browser paints its alt
// text across the thumbnail box. Since a thumbnail now waits for its host's
// route before it gets a src, the caption is held back in a data attribute
// and put on only once there is an image to caption -- otherwise every card
// spells out its own title over the placeholder while the host is being
// decided, and permanently for an item that has no thumbnail at all.
const showThumbnail = function(img, url) {
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) {
if (!proxyUrl) return;
img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true });
};
// 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);
return;
}
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
showThumbnail(entry.img, entry.directUrl);
});
};
// 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) {
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); // 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);
return;
}
attachProxyFallback(img, proxyUrl);
showThumbnail(img, directUrl);
};
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);
if (route === IMAGE_PROXY) {
showThumbnail(img, proxyUrl || directUrl);
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 });
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);
showThumbnail(img, directUrl);
return;
}
raceThumbnail(img, directUrl, proxyUrl, host);
};
// Each channel in a group sends back a different number of videos per
// page, so a small per-channel count keeps any one channel from
// dominating a single interleaved batch.
const GROUP_CHANNEL_PAGE_SIZE = 4;
// Fetches one page from every channel in a group and zips the results
// together round-robin so the feed alternates between sources instead of
// running through one channel's videos before moving to the next.
const fetchGroupBatch = async function(session, signal) {
const group = session.channel;
const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : "";
// Honor the per-group "Channels" multi-select filter so disabled
// channels are skipped. Falls back to the full group when nothing is
// selected (e.g. older sessions without the filter).
const selectedChannels = session.options ? session.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
const enabledIds = (Array.isArray(selectedChannels) && selectedChannels.length > 0 ?
selectedChannels.map((opt) => opt.id) :
group.channelIds).filter((id) => group.channelIds.includes(id));
const signature = enabledIds.join(',');
if (!state.groupCursors || state.groupCursors.groupId !== group.id ||
state.groupCursors.query !== query || state.groupCursors.signature !== signature) {
state.groupCursors = {
groupId: group.id,
query: query,
signature: signature,
channels: enabledIds.map((id) => ({ id, page: 1, hasNextPage: true }))
};
}
const active = state.groupCursors.channels.filter((cursor) => cursor.hasNextPage);
if (active.length === 0) {
return { items: [], hasNextPage: false };
}
const results = await Promise.all(active.map(async (cursor) => {
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel: cursor.id,
query: query || "",
page: cursor.page,
perPage: GROUP_CHANNEL_PAGE_SIZE,
server: session.server
}),
signal: signal
});
const data = await response.json();
const items = data && Array.isArray(data.items) ? data.items : [];
cursor.page++;
cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true);
return items;
}));
const interleaved = [];
const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0);
for (let i = 0; i < maxLen; i++) {
results.forEach((items) => {
if (items[i]) interleaved.push(items[i]);
});
}
return {
items: interleaved,
hasNextPage: state.groupCursors.channels.some((cursor) => cursor.hasNextPage)
};
};
const fetchChannelBatch = async function(session, signal) {
const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : "";
const body = {
channel: session.channel.id,
query: query || "",
page: state.currentPage,
perPage: state.perPage,
server: session.server
};
Object.entries(session.options).forEach(([key, value]) => {
if (Array.isArray(value)) {
body[key] = value.map((entry) => entry.id).join(", ");
} else if (value && value.id) {
body[key] = value.id;
}
});
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal
});
const videos = await response.json();
state.currentPage++;
return {
items: videos && Array.isArray(videos.items) ? videos.items : [],
hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true
};
};
const fetchNextBatch = function(session, signal) {
return session.channel.isGroup
? fetchGroupBatch(session, signal)
: fetchChannelBatch(session, signal);
};
// ---------------------------------------------------------------------
// Next-page prefetch
//
// The page after the visible one is fetched, and its thumbnails decoded,
// as soon as the current one renders -- but it is held back until the
// reader reaches the second-to-last row (see the virtualizer's update()).
// So the cards that come into view are complete on arrival instead of
// shimmering their way in while the request is still on the wire.
// ---------------------------------------------------------------------
const prefetch = {
batch: null, // fetched + warmed, waiting for the reader
inFlight: null, // promise of the fetch currently running
controller: null
};
// One slow thumbnail must not hold a whole page back.
const WARM_TIMEOUT_MS = 8000;
const idle = function(fn) {
if (typeof requestIdleCallback === 'function') requestIdleCallback(fn, { timeout: 1000 });
else setTimeout(fn, 0);
};
const warmThumbnails = function(items) {
// Warm down the route the host has already settled on -- warming a URL
// the cards won't ask for would leave them waiting anyway.
const urls = (items || [])
.map((v) => v && App.videos.thumbnailUrl(v.thumb))
.filter(Boolean);
if (!urls.length) return Promise.resolve();
const loads = urls.map((url) => new Promise((resolve) => {
// Off-screen and not needed yet: low priority, started when the
// browser is idle, so warming a page the reader hasn't reached
// never competes with the cards (or requests) in front of them.
idle(() => {
const img = new Image();
img.decoding = 'async';
img.fetchPriority = 'low';
// Same terms the card and the probe fetch on. Warming with a
// Referer the real request won't send would warm the wrong
// thing: on a hotlink-protecting host it earns a 403, which is
// both a wasted warm and a cached refusal the probe may then be
// handed -- pinning a host to the proxy that works direct.
img.referrerPolicy = 'no-referrer';
img.onload = resolve;
img.onerror = resolve; // the card's own retry handles failures
img.src = url;
});
}));
return Promise.race([
Promise.all(loads),
new Promise((resolve) => setTimeout(resolve, WARM_TIMEOUT_MS))
]);
};
const commitBatch = function(batch) {
if (!batch) return;
App.videos.renderVideos({ items: batch.items });
state.hasNextPage = batch.hasNextPage;
App.videos.updateLoadMoreState();
App.videos.ensureViewportFilled();
App.videos.prefetchNextBatch(); // stay one page ahead again
};
App.videos.prefetchNextBatch = function() {
if (prefetch.batch || prefetch.inFlight) return Promise.resolve();
if (!state.hasNextPage) return Promise.resolve();
const session = App.storage.getSession();
if (!session || !session.channel) return Promise.resolve();
prefetch.controller = new AbortController();
const signal = prefetch.controller.signal;
prefetch.inFlight = (async () => {
try {
const batch = await fetchNextBatch(session, signal);
await warmThumbnails(batch.items);
if (!signal.aborted) prefetch.batch = batch;
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Failed to prefetch videos:", err);
}
} finally {
prefetch.inFlight = null;
prefetch.controller = null;
}
})();
return prefetch.inFlight;
};
// Throws away a video's resolved formats and asks the server again. Media
// URLs are commonly signed with an expiry (`?secure=-`), so
// formats resolved earlier -- in a long-open tab, or held in the session
// cache -- eventually start returning 403 even though nothing is wrong with
// the video itself. The player calls this once before giving up.
App.videos.refreshFormats = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve(null);
const cacheKey = video.id || video.url;
if (cacheKey) metaCache.delete(cacheKey);
video.meta = null;
return App.videos.ensureFormats(video);
};
// Called by the virtualizer once the reader is within two rows of the end.
App.videos.releasePrefetched = function() {
if (!prefetch.batch || state.isLoading) return false;
const batch = prefetch.batch;
prefetch.batch = null;
commitBatch(batch);
return true;
};
// Drops anything in flight or held, for when the result set changes out
// from under it (new search, channel, or filters).
App.videos.resetPrefetch = function() {
if (prefetch.controller) prefetch.controller.abort();
prefetch.batch = null;
prefetch.inFlight = null;
prefetch.controller = null;
};
// Is the reader actually out of content? True when nothing is rendered yet,
// when the page is too short to scroll, or when they're already in the last
// two rows. Anything else -- the sentinel firing from a stale observation at
// startup, most of all -- means the prefetched page stays held.
const needsContentNow = function() {
if (!state.loadedVideos.length) return true;
const docHeight = document.documentElement.scrollHeight;
if (docHeight <= (window.innerHeight || 0) + 120) return true;
return !!(App.virtualGrid && App.virtualGrid.isNearEnd && App.virtualGrid.isNearEnd());
};
// Guards the whole of loadVideos, including the awaits before the fetch
// starts. state.isLoading can't do this job: it is the UI's "a request is
// out" signal and is only raised around the fetch itself, so every caller
// that arrived while we were waiting on the prefetch would sail past it and
// start a duplicate page load -- each of which renders, refills the
// viewport, and calls back in here. On a short desktop page that amplifies
// until the tab stops responding.
let loadRunning = false;
// Renders the next page: the prefetched one when it's ready (the common
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
// callers that know they need content and can't be judged by where the grid
// is scrolled -- the reels feed running out of slides, or the Load more
// button being pressed.
App.videos.loadVideos = async function(opts) {
const force = !!(opts && opts.force);
// The favorites grid pages out of localStorage, not the server, but
// rides the same sentinel and load-more button to get there.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.loadNext();
return;
}
const session = App.storage.getSession();
if (!session || !session.channel) return;
if (loadRunning || state.isLoading) return;
loadRunning = true;
try {
if (prefetch.batch || prefetch.inFlight) {
// A page is already here or on its way; revealing it before the
// reader gets near the end would defeat the point of holding it.
if (!force && !needsContentNow()) return;
if (App.videos.releasePrefetched()) return;
if (prefetch.inFlight) {
// Wait for it rather than asking for the same page twice --
// the fetch advances the page cursor, so a second request
// here would skip a page entirely.
await prefetch.inFlight;
if (App.videos.releasePrefetched()) return;
}
}
if (!state.hasNextPage) return;
state.isLoading = true;
App.videos.updateLoadMoreState();
state.currentLoadController = new AbortController();
const batch = await fetchNextBatch(session, state.currentLoadController.signal);
state.isLoading = false;
commitBatch(batch);
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Failed to load videos:", err);
}
} finally {
loadRunning = false;
state.isLoading = false;
state.currentLoadController = null;
App.videos.updateLoadMoreState();
}
};
// Builds a fully-wired video card element for `v`. Kept separate from
// mounting so the virtualizer can create a card the moment it needs to be
// on screen and throw it away once it scrolls out of the window.
App.videos.buildCard = function(v, options) {
const card = document.createElement('div');
card.className = 'video-card';
card.dataset.videoId = v.id;
const durationText = App.videos.formatDuration(v.duration);
const favoriteKey = App.favorites.getKey(v);
const uploaderText = v.uploader || '';
const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
const tagsMarkup = tags.length
? `
${tagsMarkup}
`;
const thumb = card.querySelector('img');
// 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(thumb, v.thumb);
}
const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn && favoriteKey) {
// By either identity: a favorite imported from a backup is keyed
// by its URL, not by the id this card carries.
App.favorites.setButtonState(favoriteBtn, App.favorites.has(v));
favoriteBtn.onclick = (event) => {
event.stopPropagation();
App.favorites.toggle(v);
};
}
const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text');
if (titleWrap && titleText) {
card.addEventListener('focusin', () => {
card.dataset.titleFocused = '1';
updateTitleActive(card);
});
card.addEventListener('focusout', () => {
card.dataset.titleFocused = '0';
updateTitleActive(card);
});
if (titleEnv.useHoverFocus) {
card.addEventListener('mouseenter', () => {
card.dataset.titleHovered = '1';
updateTitleActive(card);
});
card.addEventListener('mouseleave', () => {
card.dataset.titleHovered = '0';
updateTitleActive(card);
});
}
// On touch devices the marquee observer is attached lazily by
// measureTitle (called on mount), and only for overflowing titles.
}
const uploaderBtn = card.querySelector('.uploader-link');
if (uploaderBtn) {
uploaderBtn.onclick = (event) => {
event.stopPropagation();
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
App.videos.handleSearch(uploader);
};
}
const tagButtons = card.querySelectorAll('.video-tag');
if (tagButtons.length) {
tagButtons.forEach((tagBtn) => {
tagBtn.onclick = (event) => {
event.stopPropagation();
const tag = tagBtn.dataset.tag || tagBtn.textContent || '';
App.videos.handleSearch(tag);
};
});
}
const menuBtn = card.querySelector('.video-menu-btn');
const menu = card.querySelector('.video-menu');
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
if (menuBtn && menu) {
menuBtn.onclick = (event) => {
event.stopPropagation();
App.videos.toggleMenu(menu, menuBtn);
};
}
if (showInfoBtn) {
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.ui.openInfo(v);
App.videos.closeAllMenus();
};
}
if (downloadBtn) {
downloadBtn.onclick = (event) => {
event.stopPropagation();
App.videos.downloadVideo(v);
App.videos.closeAllMenus();
};
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading');
App.player.open(v, { originEl: card });
};
cardVideo.set(card, v);
card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true });
return card;
};
// 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
const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen
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();
};
// 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';
};
const mount = function(i) {
if (mounted.has(i)) return;
const v = state.loadedVideos[i];
const l = layout[i];
if (!v || !l) return;
const card = App.videos.buildCard(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');
card.addEventListener('animationend', () => card.classList.remove('is-revealing'), { once: true });
}
grid().appendChild(card);
mounted.set(i, card);
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
// its true aspect ratio, clear the shimmer, then correct the height.
const img = card.querySelector('img');
if (img) {
const reveal = () => {
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);
};
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;
resolveObserver.unobserve(card);
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
}
cardVideo.delete(card);
card.remove();
mounted.delete(i);
};
// 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;
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) mount(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;
}
}
// 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 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();
if (initialized) return;
initialized = true;
settleWidth = window.innerWidth || 0;
window.addEventListener('scroll', scheduleUpdate, { 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() {
mounted.forEach((card, i) => unmount(i));
mounted.clear();
revealed.clear(); // new result set should animate in again
layout.length = 0;
colBottoms = [];
colItems = [];
const el = grid();
if (el) { el.innerHTML = ''; 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();
};
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd };
})();
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.
App.videos.rankFormats = function(formats, preferredHeight) {
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 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);
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);
}
const sources = App.videos.rankFormats(meta.formats, preferredHeight).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