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 ''; } }; 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=-`), 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 ? `
${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'); 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.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.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