window.App = window.App || {}; App.videos = App.videos || {}; (function() { const state = App.state; const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) App.videos.loadVideos(); }, { threshold: 1.0 }); const titleEnv = { useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches }; const titleVisibility = new Map(); let titleObserver = null; if (!titleEnv.useHoverFocus) { titleObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { titleVisibility.set(entry.target, entry.intersectionRatio || 0); } else { titleVisibility.delete(entry.target); entry.target.dataset.titlePrimary = '0'; updateTitleActive(entry.target); } }); let topCard = null; let topRatio = 0; titleVisibility.forEach((ratio, card) => { if (ratio > topRatio) { topRatio = ratio; topCard = card; } }); titleVisibility.forEach((ratio, card) => { card.dataset.titlePrimary = card === topCard && ratio >= 0.55 ? '1' : '0'; updateTitleActive(card); }); }, { threshold: [0, 0.25, 0.55, 0.8, 1.0] }); } App.videos.observeSentinel = function() { const sentinel = document.getElementById('sentinel'); if (sentinel) { observer.observe(sentinel); } }; const updateTitleActive = function(card) { if (!card || !card.classList.contains('has-marquee')) { if (card) card.classList.remove('is-title-active'); return; } const hovered = card.dataset.titleHovered === '1'; const focused = card.dataset.titleFocused === '1'; const primary = card.dataset.titlePrimary === '1'; const active = titleEnv.useHoverFocus ? (hovered || focused) : (focused || primary); card.classList.toggle('is-title-active', active); }; const measureTitle = function(card) { if (!card) return; const titleWrap = card.querySelector('.video-title'); const titleText = card.querySelector('.video-title-text'); if (!titleWrap || !titleText) return; const overflow = titleText.scrollWidth - titleWrap.clientWidth; if (overflow > 4) { card.classList.add('has-marquee'); const distance = overflow + 12; titleText.style.setProperty('--marquee-distance', `${distance}px`); // Drive the duration off the distance so every title scrolls at the // same gentle speed (px/sec) instead of a fixed duration that made // longer titles whip past. A floor keeps short titles from snapping. const MARQUEE_SPEED = 28; // px per second const MARQUEE_MIN_DURATION = 6; // seconds const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED); titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); // Only marquee cards need the scroll-position observer that picks the // centered card to animate; observing every card made scrolling a // large grid needlessly expensive. if (titleObserver) titleObserver.observe(card); } else { card.classList.remove('has-marquee', 'is-title-active'); titleText.style.removeProperty('--marquee-distance'); if (titleObserver) { titleObserver.unobserve(card); titleVisibility.delete(card); } } updateTitleActive(card); }; let titleMeasureRaf = null; const scheduleTitleMeasure = function() { if (titleMeasureRaf) return; titleMeasureRaf = requestAnimationFrame(() => { titleMeasureRaf = null; document.querySelectorAll('.video-card').forEach((card) => { measureTitle(card); }); // Column width may have changed, re-wrapping titles and changing // card heights, so re-run the (batched) full masonry pass. App.videos.applyMasonryLayout(); }); }; 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. App.videos.loadGroupVideos = async function(session) { 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) { state.hasNextPage = false; App.videos.updateLoadMoreState(); return; } try { state.isLoading = true; App.videos.updateLoadMoreState(); state.currentLoadController = new AbortController(); 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: state.currentLoadController.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]); }); } App.videos.renderVideos({ items: interleaved }); state.hasNextPage = state.groupCursors.channels.some((cursor) => cursor.hasNextPage); App.videos.ensureViewportFilled(); } catch (err) { if (err.name !== 'AbortError') { console.error("Failed to load group videos:", err); } } finally { state.isLoading = false; state.currentLoadController = null; App.videos.updateLoadMoreState(); } }; // Fetches the next page of videos and renders them into the grid. App.videos.loadVideos = async function() { const session = App.storage.getSession(); if (!session || !session.channel) return; if (state.isLoading || !state.hasNextPage) return; if (session.channel.isGroup) { return App.videos.loadGroupVideos(session); } const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; let 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; } }); try { state.isLoading = true; App.videos.updateLoadMoreState(); state.currentLoadController = new AbortController(); const response = await fetch('/api/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: state.currentLoadController.signal }); const videos = await response.json(); App.videos.renderVideos(videos); state.hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true; state.currentPage++; App.videos.ensureViewportFilled(); } catch (err) { if (err.name !== 'AbortError') { console.error("Failed to load videos:", err); } } finally { state.isLoading = false; state.currentLoadController = null; App.videos.updateLoadMoreState(); } }; // Renders new cards for videos, wiring favorites + playback behavior. App.videos.renderVideos = function(videos) { const grid = document.getElementById('video-grid'); if (!grid) return; const items = videos && Array.isArray(videos.items) ? videos.items : []; const favoritesSet = App.favorites.getSet(); items.forEach(v => { if (state.renderedVideoIds.has(v.id)) return; state.loadedVideos.push(v); 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}

${v.title}

${tagsMarkup} ${uploaderText ? `

` : ''} ${durationText ? `

${durationText}

` : ''} `; const thumb = card.querySelector('img'); App.videos.attachNoReferrerRetry(thumb); if (thumb) { // Only this card's height can change when its own thumbnail // loads, so reposition just this card instead of the whole grid. thumb.addEventListener('load', () => App.videos.layoutCard(card)); } 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) { requestAnimationFrame(() => { measureTitle(card); App.videos.layoutCard(card); }); 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, but only for cards whose title actually overflows. } const uploaderBtn = card.querySelector('.uploader-link'); if (uploaderBtn) { uploaderBtn.onclick = (event) => { event.stopPropagation(); const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || ''; App.videos.handleSearch(uploader); }; } const tagButtons = card.querySelectorAll('.video-tag'); if (tagButtons.length) { tagButtons.forEach((tagBtn) => { tagBtn.onclick = (event) => { event.stopPropagation(); const tag = tagBtn.dataset.tag || tagBtn.textContent || ''; App.videos.handleSearch(tag); }; }); } const menuBtn = card.querySelector('.video-menu-btn'); const menu = card.querySelector('.video-menu'); const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]'); const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]'); if (menuBtn && menu) { menuBtn.onclick = (event) => { event.stopPropagation(); App.videos.toggleMenu(menu, menuBtn); }; } if (showInfoBtn) { showInfoBtn.onclick = (event) => { event.stopPropagation(); App.ui.showInfo(v); App.videos.closeAllMenus(); }; } if (downloadBtn) { downloadBtn.onclick = (event) => { event.stopPropagation(); App.videos.downloadVideo(v); App.videos.closeAllMenus(); }; } card.onclick = () => { if (card.classList.contains('is-loading')) return; card.classList.add('is-loading'); App.player.open(v, { originEl: card }); }; grid.appendChild(card); // Resolve formats + probe direct playability on demand: when the // card scrolls near the viewport, or the moment it's hovered. cardVideo.set(card, v); probeObserver.observe(card); card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true }); state.renderedVideoIds.add(v.id); }); // Each new card lays itself out via its own rAF / image-load handler // above, so there is no need to relayout the whole grid here. 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; } } state.currentPage = 1; state.hasNextPage = true; state.renderedVideoIds.clear(); state.loadedVideos = []; state.groupCursors = null; const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; if (App.feed && typeof App.feed.reset === 'function') { App.feed.reset(); } App.videos.updateLoadMoreState(); App.videos.loadVideos(); }; App.videos.resetAndReload = function() { if (state.currentLoadController) { state.currentLoadController.abort(); state.currentLoadController = null; state.isLoading = false; } state.currentPage = 1; state.hasNextPage = true; state.renderedVideoIds.clear(); state.loadedVideos = []; state.groupCursors = null; const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; if (App.feed && typeof App.feed.reset === 'function') { App.feed.reset(); } App.videos.updateLoadMoreState(); 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); } }; // Grid track geometry is identical for every card and only changes when the // viewport crosses a breakpoint, so we read it from the DOM once and cache // it. Reading getComputedStyle per card (per image load) was a needless // layout read on the hot path. let cachedGridMetrics = null; const getGridMetrics = function() { if (cachedGridMetrics) return cachedGridMetrics; const grid = document.getElementById('video-grid'); if (!grid) return null; const styles = window.getComputedStyle(grid); if (styles.display !== 'grid') return null; const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10); const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0; if (!rowHeight) return null; cachedGridMetrics = { rowHeight, rowGap }; return cachedGridMetrics; }; const spanFor = function(itemHeight, metrics) { return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap)); }; // Masonry placement for a single card. Each card's row span is independent // of its siblings, so a newly loaded thumbnail only needs to re-measure its // own card -- not relayout the entire (potentially huge) grid. This is the // O(1) replacement for the old whole-grid pass that ran on every image load. App.videos.layoutCard = function(card) { if (!card) return; const metrics = getGridMetrics(); if (!metrics) return; const itemHeight = card.getBoundingClientRect().height; if (!itemHeight) return; card.style.gridRowEnd = `span ${spanFor(itemHeight, metrics)}`; }; // Full relayout, used only when the column width actually changes (resize / // breakpoint), since that re-wraps titles and changes every card's height. // Reads are batched ahead of writes so we don't thrash layout per card the // way the old per-item read-then-write loop did. let masonryRaf = null; App.videos.applyMasonryLayout = function() { const grid = document.getElementById('video-grid'); if (!grid) return; cachedGridMetrics = null; const metrics = getGridMetrics(); if (!metrics) return; const cards = Array.from(grid.children); const heights = cards.map((item) => item.getBoundingClientRect().height); cards.forEach((item, i) => { if (heights[i]) item.style.gridRowEnd = `span ${spanFor(heights[i], metrics)}`; }); }; App.videos.scheduleMasonryLayout = function() { if (masonryRaf) cancelAnimationFrame(masonryRaf); masonryRaf = requestAnimationFrame(() => { masonryRaf = null; App.videos.applyMasonryLayout(); }); }; 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()] || ''; }; 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; return { url: fmt.url, referer, userAgent, isLive, refererRequired: !!explicitReferer }; }); if (!sources.length) { const fallbackUrl = meta.url || videoOrUrl.url || ''; if (fallbackUrl) { sources.push({ url: fallbackUrl, referer: metaReferer || deriveReferer(fallbackUrl), userAgent: metaUserAgent, 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 }; }; // Background "direct playability" probe. The backend proxy exists to work // around CORS, hotlink (403) protection, and TLS fingerprinting. When the // browser can fetch a media URL cross-origin and actually read the response // (CORS allowed, not blocked/403), playing it directly works and the proxy // is pure overhead. CORS is an origin-level policy, so the answer is the // same for every media URL served by a given host: we probe (and cache) // once per host and let the player skip the proxy for any URL on a host // that's been proven. const DIRECT_PROBE_TIMEOUT_MS = 8000; const directHostOf = (url) => { try { return new URL(url).host; } catch (err) { return ''; } }; // host -> true (proven directly playable) | false (proven not). Absent // means unknown/unprobed, in which case the proxy is used. App.videos._directStatus = new Map(); const directPending = new Map(); App.videos.isDirectProven = function(url) { return App.videos._directStatus.get(directHostOf(url)) === true; }; App.videos.probeDirect = function(url) { if (!url) return Promise.resolve(false); const host = directHostOf(url); if (!host) return Promise.resolve(false); if (App.videos._directStatus.has(host)) { return Promise.resolve(App.videos._directStatus.get(host)); } if (directPending.has(host)) { return directPending.get(host); } const promise = (async () => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS); let ok = false; let detail = ''; try { // A simple GET (no custom headers) avoids a CORS preflight. If // the response is readable and successful, CORS + reachability // are both proven; we abort immediately so the body isn't // downloaded (it can be a whole video file). const res = await fetch(url, { method: 'GET', mode: 'cors', credentials: 'omit', signal: controller.signal }); ok = res.ok || res.status === 206; detail = `HTTP ${res.status}`; controller.abort(); } catch (err) { ok = false; detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed'; } finally { clearTimeout(timer); } App.videos._directStatus.set(host, ok); directPending.delete(host); console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${host}`); return ok; })(); directPending.set(host, promise); return promise; }; // Kicks off a background probe of a video's best (first-played) source so a // later playback can skip the proxy if its host is proven reachable. Only // runs once the video has resolved formats (see resolveAndProbe): those are // real media URLs (or redirects to them), whereas a bare listing item only // carries a page URL that the player can't use directly. App.videos.probeVideoSources = function(video) { if (!video || typeof video !== 'object') return; const meta = video.meta || video; if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return; let sources; try { sources = App.videos.resolveStreamSources(video); } catch (err) { return; } const best = sources && sources[0]; if (!best || !best.url || best.isLive) return; // Sources that require a specific upstream Referer can't be fetched // directly by the browser (it can't forge a cross-origin Referer), so a // probe would always fail -- leave them to the proxy. if (best.refererRequired) return; App.videos.probeDirect(best.url); }; // Listing items arrive without formats (meta is null) -- only a page URL -- // so there's nothing direct-playable to probe up front. This resolves a // video's real media formats via the backend (yt-dlp), attaches them as // `video.meta` so the player and probe can use them, then probes the best // source. Resolution is per-video and deduped: it runs at most once per // video, triggered lazily by hover/scroll so we don't resolve cards the // user never looks at. const cardVideo = new WeakMap(); const metaResolved = new Set(); const metaPending = new Map(); const probeObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; probeObserver.unobserve(entry.target); const video = cardVideo.get(entry.target); if (video) App.videos.resolveAndProbe(video); }); }, { rootMargin: '200px' }); App.videos.resolveAndProbe = function(video) { if (!video || typeof video !== 'object' || !video.id) return Promise.resolve(); // Already have formats (resolved earlier): just (re)probe the best one. if (video.meta && Array.isArray(video.meta.formats) && video.meta.formats.length) { App.videos.probeVideoSources(video); return Promise.resolve(); } if (metaResolved.has(video.id)) return Promise.resolve(); if (metaPending.has(video.id)) return metaPending.get(video.id); if (!video.url) return Promise.resolve(); const promise = (async () => { try { const response = await fetch('/api/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: video.url }) }); if (!response.ok) return; const data = await response.json(); if (data && Array.isArray(data.formats) && data.formats.length) { video.meta = data; App.videos.probeVideoSources(video); } } catch (err) { // Best-effort: playback still works through the proxy. } finally { metaResolved.add(video.id); metaPending.delete(video.id); } })(); metaPending.set(video.id, promise); return promise; }; // Builds a proxied stream URL. Extra params other than `url` are forwarded // by the backend as request headers, so use real header names here. App.videos.buildStreamUrlFromSource = function(resolved) { if (!resolved || !resolved.url) return ''; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; const liveParam = resolved.isLive ? '&live=1' : ''; return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; }; App.videos.buildStreamUrl = function(videoOrUrl, options) { return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options)); }; App.videos.downloadVideo = function(video) { if (!video) return; const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false }); if (!streamUrl) return; const link = document.createElement('a'); link.href = streamUrl; const rawName = (video.title || video.id || 'video').toString(); const safeName = rawName.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80); link.download = safeName ? `${safeName}.mp4` : 'video.mp4'; document.body.appendChild(link); link.click(); link.remove(); }; })();