Files
jacuzzi/frontend/js/videos.js
Simon d4ed9dce5d Browse favorites like a listing, sorted, and page them as you scroll
Favorites now carry `favoriteDate`. Ones saved before this had no way of
knowing when they were saved, so they are all stamped with the moment the
client first reads them -- they sort together as one batch, at the point
favorites learned to keep dates. An import brings the date the other client
recorded instead, so a restored library keeps its history.

The bar used to build a card per favorite, which an import of several
hundred made an expensive way to open the app. It now renders a screenful
and appends more as the strip is scrolled.

"Browse all" turns the whole grid into favorites: the same cards, the same
virtualized masonry, the same infinite scroll and reels mode as a channel
listing -- App.videos.loadVideos simply pages out of localStorage instead of
the server while that view is open. Sort applies to the bar and the grid
together: recently added (default), oldest, title, longest, shortest, and a
shuffle for rediscovering a long list.

Tests (scratchpad): dates backfilled onto undated favorites, the bar paging
as it scrolls rather than building every card, the grid paging to the full
list with zero server calls, each sort order reordering it, and the way back
to the channel listing. Also measured: 515 imported favorites load with the
page responsive and 24 bar cards built.

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

1601 lines
72 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) {
if (!card || !card.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;
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
if (overflow > 4) {
card.classList.add('has-marquee');
const distance = overflow + 12;
titleText.style.setProperty('--marquee-distance', `${distance}px`);
// Drive the duration off the distance so every title scrolls at the
// same gentle speed (px/sec) instead of a fixed duration that made
// longer titles whip past. A floor keeps short titles from snapping.
const MARQUEE_SPEED = 28; // px per second
const MARQUEE_MIN_DURATION = 6; // seconds
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
// 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('has-marquee', 'is-title-active');
titleText.style.removeProperty('--marquee-distance');
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 '';
}
};
App.videos.attachNoReferrerRetry = function(img) {
if (!img) return;
if (!img.dataset.originalSrc) {
img.dataset.originalSrc = img.currentSrc || img.src || '';
}
img.dataset.noReferrerRetry = '0';
img.addEventListener('error', () => {
if (img.dataset.noReferrerRetry === '1') return;
img.dataset.noReferrerRetry = '1';
img.referrerPolicy = 'no-referrer';
img.removeAttribute('crossorigin');
const original = img.dataset.originalSrc || img.currentSrc || img.src || '';
const proxyUrl = App.videos.buildImageProxyUrl(original);
if (proxyUrl) {
img.src = proxyUrl;
} else if (original) {
img.src = original;
}
});
};
// 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) {
const urls = (items || []).map((v) => v && 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';
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();
}
};
// 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) {
const favoritesSet = App.favorites.getSet();
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
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
: '';
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.url || ''}">♡</button>
<button class="video-menu-btn" type="button" 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 src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
</div>
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
${tagsMarkup}
`;
const thumb = card.querySelector('img');
App.videos.attachNoReferrerRetry(thumb);
const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn && favoriteKey) {
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
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.showInfo(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.resolveAndProbe(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 : [];
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);
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); });
probeObserver.observe(card);
};
const unmount = function(i) {
const card = mounted.get(i);
if (!card) return;
probeObserver.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 <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. If
// the response is readable and successful, CORS + reachability
// are both proven; we abort immediately so the body isn't
// downloaded (it can be a whole video file).
const res = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal: controller.signal
});
ok = res.ok || res.status === 206;
detail = `HTTP ${res.status}`;
controller.abort();
} catch (err) {
ok = false;
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed';
} 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;
};
// Kicks off a background probe of a video's best (first-played) source so a
// later playback can skip the proxy if its host is proven reachable. Only
// runs once the video has resolved formats (see resolveAndProbe): those are
// real media URLs (or redirects to them), whereas a bare listing item only
// carries a page URL that the player can't use directly.
App.videos.probeVideoSources = function(video) {
if (!video || typeof video !== 'object') return;
const meta = video.meta || video;
if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return;
let sources;
try {
sources = App.videos.resolveStreamSources(video);
} catch (err) {
return;
}
const best = sources && sources[0];
if (!best || !best.url || best.isLive) return;
// Sources that require a specific upstream Referer can't be fetched
// directly by the browser (it can't forge a cross-origin Referer), so a
// probe would always fail -- leave them to the proxy.
if (best.refererRequired) return;
App.videos.probeDirect(best.url);
};
// Listing items arrive without formats (meta is null) -- only a page URL --
// so there's nothing direct-playable to probe up front. This resolves a
// video's real media formats via the backend (yt-dlp), attaches them as
// `video.meta` so the player and probe can use them, then probes the best
// source. 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.
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;
});
};
const probeObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
probeObserver.unobserve(entry.target);
const video = cardVideo.get(entry.target);
if (video) App.videos.resolveAndProbe(video);
});
}, { rootMargin: '200px' });
App.videos.resolveAndProbe = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve();
return App.videos.ensureFormats(video).then((meta) => {
if (meta) App.videos.probeVideoSources(video);
});
};
// 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();
};
})();