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 }); App.videos.observeSentinel = function() { const sentinel = document.getElementById('sentinel'); if (sentinel) { observer.observe(sentinel); } }; 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); if (hours > 0) { return `${hours}h ${String(minutes).padStart(2, '0')}m`; } return `${minutes}m`; }; // Fetches the next page of videos and renders them into the grid. App.videos.loadVideos = async function() { const session = App.storage.getSession(); if (!session) return; if (state.isLoading || !state.hasNextPage) return; 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; 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 || v.channel || ''; card.innerHTML = `
${durationText}
` : ''} `; 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 uploaderBtn = card.querySelector('.uploader-link'); if (uploaderBtn) { uploaderBtn.onclick = (event) => { event.stopPropagation(); const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || ''; App.videos.handleSearch(uploader); }; } 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 = () => App.player.open(v.url); grid.appendChild(card); state.renderedVideoIds.add(v.id); }); App.videos.ensureViewportFilled(); }; App.videos.handleSearch = function(value) { if (typeof value === 'string') { const searchInput = document.getElementById('search-input'); if (searchInput && searchInput.value !== value) { searchInput.value = value; } } state.currentPage = 1; state.hasNextPage = true; state.renderedVideoIds.clear(); const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; 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(); const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; 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); } }; 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'); } } }; // Builds a proxied stream URL with an optional referer parameter. App.videos.buildStreamUrl = function(videoUrl) { let refererParam = ''; try { const origin = new URL(videoUrl).origin; refererParam = `&referer=${encodeURIComponent(origin + '/')}`; } catch (err) { refererParam = ''; } return `/api/stream?url=${encodeURIComponent(videoUrl)}${refererParam}`; }; App.videos.downloadVideo = function(video) { if (!video || !video.url) return; const link = document.createElement('a'); link.href = App.videos.buildStreamUrl(video.url); 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(); }; })();