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'); titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`); } else { card.classList.remove('has-marquee', 'is-title-active'); titleText.style.removeProperty('--marquee-distance'); } updateTitleActive(card); }; let titleMeasureRaf = null; const scheduleTitleMeasure = function() { if (titleMeasureRaf) return; titleMeasureRaf = requestAnimationFrame(() => { titleMeasureRaf = null; document.querySelectorAll('.video-card').forEach((card) => { measureTitle(card); }); }); }; 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)}&ts=${Date.now()}`; } 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 : ""; if (!state.groupCursors || state.groupCursors.groupId !== group.id || state.groupCursors.query !== query) { state.groupCursors = { groupId: group.id, query: query, channels: group.channelIds.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'; 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('')}
` : ''; card.innerHTML = ` ${v.title}

${v.title}

${tagsMarkup} ${uploaderText ? `

` : ''} ${durationText ? `

${durationText}

` : ''} `; const thumb = card.querySelector('img'); App.videos.attachNoReferrerRetry(thumb); if (thumb) { thumb.addEventListener('load', App.videos.scheduleMasonryLayout); } 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); }); 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); }); } else if (titleObserver) { titleObserver.observe(card); } } 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); state.renderedVideoIds.add(v.id); }); App.videos.scheduleMasonryLayout(); if (App.feed && typeof App.feed.renderSlides === 'function') { App.feed.renderSlides(); } App.videos.ensureViewportFilled(); }; App.videos.handleSearch = function(value) { if (typeof value === 'string') { const searchInput = document.getElementById('search-input'); if (searchInput) { if (searchInput.value !== value) { searchInput.value = value; } searchInput.dispatchEvent(new Event('input', { bubbles: true })); } } 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); } }; let masonryRaf = null; App.videos.scheduleMasonryLayout = function() { if (masonryRaf) { cancelAnimationFrame(masonryRaf); } masonryRaf = requestAnimationFrame(() => { masonryRaf = null; App.videos.applyMasonryLayout(); }); }; App.videos.applyMasonryLayout = function() { const grid = document.getElementById('video-grid'); if (!grid) return; const styles = window.getComputedStyle(grid); if (styles.display !== 'grid') return; const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10); const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0; if (!rowHeight) return; Array.from(grid.children).forEach((item) => { const itemHeight = item.getBoundingClientRect().height; const span = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap)); item.style.gridRowEnd = `span ${span}`; }); }; 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; }; App.videos.pickBestFormat = function(formats, preferredHeight) { if (!Array.isArray(formats) || formats.length === 0) return null; const candidates = formats.filter((fmt) => fmt && fmt.url); if (!candidates.length) return null; const videoCandidates = candidates.filter((fmt) => { const videoExt = String(fmt.video_ext || '').toLowerCase(); const vcodec = String(fmt.vcodec || '').toLowerCase(); if (videoExt && videoExt !== 'none') return true; if (vcodec && vcodec !== 'none') return true; return false; }); let 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]; }; if (preferredHeight) { const atOrBelow = pool.filter((fmt) => { const size = score(fmt)[0]; return size > 0 && size <= preferredHeight; }); if (atOrBelow.length) { pool = atOrBelow; } else { // Nothing at or below the preferred quality, fall back to the lowest available. const lowest = pool.reduce((min, fmt) => { if (!min) return fmt; return score(fmt)[0] < score(min)[0] ? fmt : min; }, null); return lowest; } } return pool.reduce((best, fmt) => { if (!best) return fmt; const bestScore = score(best); const curScore = score(fmt); for (let i = 0; i < curScore.length; i++) { if (curScore[i] > bestScore[i]) return fmt; if (curScore[i] < bestScore[i]) return best; } return best; }, null); }; App.videos.resolveStreamSource = function(videoOrUrl, options) { const applyPreferredQuality = !options || options.applyPreferredQuality !== false; let sourceUrl = ''; let referer = ''; let userAgent = ''; if (typeof videoOrUrl === 'string') { sourceUrl = videoOrUrl; } else if (videoOrUrl && typeof videoOrUrl === 'object') { const meta = videoOrUrl.meta || videoOrUrl; sourceUrl = meta.url || videoOrUrl.url || ''; let preferredHeight = null; if (applyPreferredQuality) { const preferredQuality = App.storage.getPreferredQuality(); preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality); } const best = App.videos.pickBestFormat(meta.formats, preferredHeight); if (best && best.url) { sourceUrl = best.url; if (best.http_headers && (best.http_headers.Referer || best.http_headers.referer)) { referer = best.http_headers.Referer || best.http_headers.referer; } if (best.http_headers && (best.http_headers['User-Agent'] || best.http_headers['user-agent'])) { userAgent = best.http_headers['User-Agent'] || best.http_headers['user-agent']; } } if (!referer && meta.http_headers && (meta.http_headers.Referer || meta.http_headers.referer)) { referer = meta.http_headers.Referer || meta.http_headers.referer; } if (!userAgent && meta.http_headers && (meta.http_headers['User-Agent'] || meta.http_headers['user-agent'])) { userAgent = meta.http_headers['User-Agent'] || meta.http_headers['user-agent']; } } if (!referer && sourceUrl) { try { referer = `${new URL(sourceUrl).origin}/`; } catch (err) { referer = ''; } } return { url: sourceUrl, referer, userAgent }; }; // 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.buildStreamUrl = function(videoOrUrl, options) { const resolved = App.videos.resolveStreamSource(videoOrUrl, options); if (!resolved.url) return ''; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; }; 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(); }; })();