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 ? `
${tags.map(tag => ``).join('')}
` : ''; const liveBadge = v.isLive ? '● LIVE' : ''; card.innerHTML = ` ${liveBadge}
${v.title} ${uploaderText ? `` : ''} ${durationText ? `${durationText}` : ''}

${v.title}

${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