// 1. Global State and Constants (Declare these first!) let currentPage = 1; const perPage = 12; const renderedVideoIds = new Set(); let hasNextPage = true; let isLoading = false; let hlsPlayer = null; // 2. Observer Definition (Must be defined before initApp uses it) const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) loadVideos(); }, { threshold: 1.0 }); // 3. Logic Functions async function InitializeLocalStorage() { if (!localStorage.getItem('config')) { localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] })); } if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'dark'); } // We always run this to make sure session is fresh await InitializeServerStatus(); } async function InitializeServerStatus() { const config = JSON.parse(localStorage.getItem('config')); if (!config || !config.servers) return; const statusPromises = config.servers.map(async (serverObj) => { const server = Object.keys(serverObj)[0]; try { const response = await fetch(`/api/status`, { method: "POST", body: JSON.stringify({ server: server }), headers: { "Content-Type": "application/json" }, }); const status = await response.json(); serverObj[server] = status; } catch (err) { serverObj[server] = { online: false, channels: [] }; } }); await Promise.all(statusPromises); localStorage.setItem('config', JSON.stringify(config)); const existingSession = getSession(); const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]); const selectedServerKey = existingSession && serverKeys.includes(existingSession.server) ? existingSession.server : serverKeys[0]; const serverData = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey)[selectedServerKey]; if (serverData.channels && serverData.channels.length > 0) { const prefs = getPreferences(); const serverPrefs = prefs[selectedServerKey] || {}; const preferredChannelId = serverPrefs.channelId; const channel = serverData.channels.find((ch) => ch.id === preferredChannelId) || serverData.channels[0]; const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null; const options = savedOptions ? hydrateOptions(channel, savedOptions) : buildDefaultOptions(channel); const sessionData = { server: selectedServerKey, channel: channel, options: options, }; setSession(sessionData); savePreference(sessionData); } } async function loadVideos() { const session = JSON.parse(localStorage.getItem('session')); if (!session) return; if (isLoading || !hasNextPage) return; const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; // Build the request body let body = { channel: session.channel.id, query: query || "", page: currentPage, perPage: perPage, server: session.server }; // Correct way to loop through the options object 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 { isLoading = true; updateLoadMoreState(); const response = await fetch('/api/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const videos = await response.json(); renderVideos(videos); hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true; currentPage++; ensureViewportFilled(); } catch (err) { console.error("Failed to load videos:", err); } finally { isLoading = false; updateLoadMoreState(); } } function renderVideos(videos) { const grid = document.getElementById('video-grid'); if (!grid) return; const items = videos && Array.isArray(videos.items) ? videos.items : []; items.forEach(v => { if (renderedVideoIds.has(v.id)) return; const card = document.createElement('div'); card.className = 'video-card'; const durationText = formatDuration(v.duration); card.innerHTML = ` ${v.title}

${v.title}

${v.channel}

${durationText ? `

${durationText}

` : ''} `; card.onclick = () => openPlayer(v.url); grid.appendChild(card); renderedVideoIds.add(v.id); }); } function formatDuration(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`; } // 4. Initialization (Run this last) async function initApp() { // Clear old data if you want a fresh start every refresh // localStorage.clear(); await InitializeLocalStorage(); applyTheme(); renderMenu(); const sentinel = document.getElementById('sentinel'); if (sentinel) { observer.observe(sentinel); } const loadMoreBtn = document.getElementById('load-more-btn'); if (loadMoreBtn) { loadMoreBtn.onclick = () => { loadVideos(); }; } await loadVideos(); } function applyTheme() { const theme = localStorage.getItem('theme') || 'dark'; document.body.classList.toggle('theme-light', theme === 'light'); const select = document.getElementById('theme-select'); if (select) select.value = theme; } async function openPlayer(url) { const modal = document.getElementById('video-modal'); const video = document.getElementById('player'); // 1. Define isHls (the missing piece!) const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`; let isHls = /\.m3u8($|\?)/i.test(url); // 2. Cleanup existing player instance to prevent aborted bindings if (hlsPlayer) { hlsPlayer.stopLoad(); hlsPlayer.detachMedia(); hlsPlayer.destroy(); hlsPlayer = null; } // 3. Reset the video element video.pause(); video.removeAttribute('src'); video.load(); if (!isHls) { try { const headResp = await fetch(streamUrl, { method: 'HEAD' }); const contentType = headResp.headers.get('Content-Type') || ''; if (contentType.includes('application/vnd.apple.mpegurl')) { isHls = true; } } catch (err) { console.warn('Failed to detect stream type', err); } } if (isHls) { if (window.Hls && window.Hls.isSupported()) { hlsPlayer = new window.Hls(); hlsPlayer.loadSource(streamUrl); hlsPlayer.attachMedia(video); hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() { video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = streamUrl; } else { console.error("HLS not supported in this browser."); return; } } else { video.src = streamUrl; } modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function closePlayer() { const modal = document.getElementById('video-modal'); const video = document.getElementById('player'); if (hlsPlayer) { hlsPlayer.destroy(); hlsPlayer = null; } video.pause(); video.src = ''; modal.style.display = 'none'; document.body.style.overflow = 'auto'; } function handleSearch(value) { currentPage = 1; hasNextPage = true; renderedVideoIds.clear(); const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; updateLoadMoreState(); loadVideos(); } function getConfig() { return JSON.parse(localStorage.getItem('config')) || { servers: [] }; } function getSession() { return JSON.parse(localStorage.getItem('session')) || null; } function setSession(nextSession) { localStorage.setItem('session', JSON.stringify(nextSession)); } function getPreferences() { return JSON.parse(localStorage.getItem('preferences')) || {}; } function setPreferences(nextPreferences) { localStorage.setItem('preferences', JSON.stringify(nextPreferences)); } function getServerEntries() { const config = getConfig(); if (!config.servers || !Array.isArray(config.servers)) return []; return config.servers.map((serverObj) => { const server = Object.keys(serverObj)[0]; return { url: server, data: serverObj[server] || null }; }); } function setConfig(nextConfig) { localStorage.setItem('config', JSON.stringify(nextConfig)); } async function refreshServerStatus() { await InitializeServerStatus(); renderMenu(); } function serializeOptions(options) { const serialized = {}; Object.entries(options || {}).forEach(([key, value]) => { if (Array.isArray(value)) { serialized[key] = value.map((entry) => entry.id); } else if (value && value.id) { serialized[key] = value.id; } }); return serialized; } function hydrateOptions(channel, savedOptions) { const hydrated = {}; if (!channel || !Array.isArray(channel.options)) return hydrated; const saved = savedOptions || {}; channel.options.forEach((optionGroup) => { const allOptions = optionGroup.options || []; const savedValue = saved[optionGroup.id]; if (optionGroup.multiSelect) { const selectedIds = Array.isArray(savedValue) ? savedValue : []; const selected = allOptions.filter((opt) => selectedIds.includes(opt.id)); hydrated[optionGroup.id] = selected.length > 0 ? selected : allOptions.slice(0, 1); } else { const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0]; if (selected) hydrated[optionGroup.id] = selected; } }); return hydrated; } function savePreference(session) { if (!session || !session.server || !session.channel) return; const prefs = getPreferences(); const serverPrefs = prefs[session.server] || {}; serverPrefs.channelId = session.channel.id; serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {}; serverPrefs.optionsByChannel[session.channel.id] = serializeOptions(session.options); prefs[session.server] = serverPrefs; setPreferences(prefs); } function buildDefaultOptions(channel) { const selected = {}; if (!channel || !Array.isArray(channel.options)) return selected; channel.options.forEach((optionGroup) => { if (!optionGroup.options || optionGroup.options.length === 0) return; if (optionGroup.multiSelect) { selected[optionGroup.id] = [optionGroup.options[0]]; } else { selected[optionGroup.id] = optionGroup.options[0]; } }); return selected; } function resetAndReload() { currentPage = 1; hasNextPage = true; renderedVideoIds.clear(); const grid = document.getElementById('video-grid'); if (grid) grid.innerHTML = ""; updateLoadMoreState(); loadVideos(); } function ensureViewportFilled() { if (!hasNextPage || isLoading) return; const grid = document.getElementById('video-grid'); if (!grid) return; const contentHeight = grid.getBoundingClientRect().bottom; if (contentHeight < window.innerHeight + 120) { window.setTimeout(() => loadVideos(), 0); } } function updateLoadMoreState() { const loadMoreBtn = document.getElementById('load-more-btn'); if (!loadMoreBtn) return; loadMoreBtn.disabled = isLoading || !hasNextPage; loadMoreBtn.style.display = hasNextPage ? 'flex' : 'none'; } function renderMenu() { const session = getSession(); const serverEntries = getServerEntries(); const sourceSelect = document.getElementById('source-select'); const channelSelect = document.getElementById('channel-select'); const filtersContainer = document.getElementById('filters-container'); const sourcesList = document.getElementById('sources-list'); const addSourceBtn = document.getElementById('add-source-btn'); const sourceInput = document.getElementById('source-input'); const reloadChannelBtn = document.getElementById('reload-channel-btn'); if (!sourceSelect || !channelSelect || !filtersContainer) return; sourceSelect.innerHTML = ""; serverEntries.forEach((entry) => { const option = document.createElement('option'); option.value = entry.url; option.textContent = entry.url; sourceSelect.appendChild(option); }); if (session && session.server) { sourceSelect.value = session.server; } sourceSelect.onchange = () => { const selectedServerUrl = sourceSelect.value; const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl); const channels = selectedServer && selectedServer.data && selectedServer.data.channels ? selectedServer.data.channels : []; const prefs = getPreferences(); const serverPrefs = prefs[selectedServerUrl] || {}; const preferredChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || null; const nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null); const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null; const nextSession = { server: selectedServerUrl, channel: nextChannel, options: nextChannel ? (savedOptions ? hydrateOptions(nextChannel, savedOptions) : buildDefaultOptions(nextChannel)) : {} }; setSession(nextSession); savePreference(nextSession); renderMenu(); resetAndReload(); }; const activeServer = serverEntries.find((entry) => entry.url === (session && session.server)); const availableChannels = activeServer && activeServer.data && activeServer.data.channels ? [...activeServer.data.channels] : []; availableChannels.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase(); const nameB = (b.name || b.id || '').toLowerCase(); return nameA.localeCompare(nameB); }); channelSelect.innerHTML = ""; availableChannels.forEach((channel) => { const option = document.createElement('option'); option.value = channel.id; option.textContent = channel.name || channel.id; channelSelect.appendChild(option); }); if (session && session.channel) { channelSelect.value = session.channel.id; } channelSelect.onchange = () => { const selectedId = channelSelect.value; const nextChannel = availableChannels.find((channel) => channel.id === selectedId) || null; const prefs = getPreferences(); const serverPrefs = prefs[session.server] || {}; const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null; const nextSession = { server: session.server, channel: nextChannel, options: nextChannel ? (savedOptions ? hydrateOptions(nextChannel, savedOptions) : buildDefaultOptions(nextChannel)) : {} }; setSession(nextSession); savePreference(nextSession); renderMenu(); resetAndReload(); }; renderFilters(filtersContainer, session); const themeSelect = document.getElementById('theme-select'); if (themeSelect) { themeSelect.onchange = () => { const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark'; localStorage.setItem('theme', nextTheme); applyTheme(); }; } if (sourcesList) { sourcesList.innerHTML = ""; serverEntries.forEach((entry) => { const row = document.createElement('div'); row.className = 'source-item'; const text = document.createElement('span'); text.textContent = entry.url; const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.textContent = 'Remove'; removeBtn.onclick = async () => { const config = getConfig(); config.servers = (config.servers || []).filter((serverObj) => { const key = Object.keys(serverObj)[0]; return key !== entry.url; }); setConfig(config); const prefs = getPreferences(); if (prefs[entry.url]) { delete prefs[entry.url]; setPreferences(prefs); } const remaining = getServerEntries(); if (remaining.length === 0) { localStorage.removeItem('session'); } else { const nextServerUrl = remaining[0].url; const nextServer = remaining[0]; const serverPrefs = prefs[nextServerUrl] || {}; const channels = nextServer.data && nextServer.data.channels ? nextServer.data.channels : []; const nextChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || channels[0] || null; const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null; const nextSession = { server: nextServerUrl, channel: nextChannel, options: nextChannel ? (savedOptions ? hydrateOptions(nextChannel, savedOptions) : buildDefaultOptions(nextChannel)) : {} }; setSession(nextSession); savePreference(nextSession); } await refreshServerStatus(); resetAndReload(); }; row.appendChild(text); row.appendChild(removeBtn); sourcesList.appendChild(row); }); } if (addSourceBtn && sourceInput) { addSourceBtn.onclick = async () => { const raw = sourceInput.value.trim(); if (!raw) return; const normalized = raw.endsWith('/') ? raw.slice(0, -1) : raw; const config = getConfig(); const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized); if (!exists) { config.servers = config.servers || []; config.servers.push({ [normalized]: {} }); setConfig(config); sourceInput.value = ''; await refreshServerStatus(); const session = getSession(); if (!session || session.server !== normalized) { const entries = getServerEntries(); const addedEntry = entries.find((entry) => entry.url === normalized); const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels ? addedEntry.data.channels[0] : null; const nextSession = { server: normalized, channel: nextChannel, options: nextChannel ? buildDefaultOptions(nextChannel) : {} }; setSession(nextSession); savePreference(nextSession); } renderMenu(); resetAndReload(); } }; } if (reloadChannelBtn) { reloadChannelBtn.onclick = () => { resetAndReload(); }; } } function renderFilters(container, session) { container.innerHTML = ""; if (!session || !session.channel || !Array.isArray(session.channel.options)) { const empty = document.createElement('div'); empty.className = 'filters-empty'; empty.textContent = 'No filters available for this channel.'; container.appendChild(empty); return; } session.channel.options.forEach((optionGroup) => { const wrapper = document.createElement('div'); wrapper.className = 'setting-item'; const label = document.createElement('label'); label.textContent = optionGroup.title || optionGroup.id; const select = document.createElement('select'); select.multiple = Boolean(optionGroup.multiSelect); (optionGroup.options || []).forEach((opt) => { const option = document.createElement('option'); option.value = opt.id; option.textContent = opt.title || opt.id; select.appendChild(option); }); const currentSelection = session.options ? session.options[optionGroup.id] : null; if (Array.isArray(currentSelection)) { const ids = new Set(currentSelection.map((item) => item.id)); Array.from(select.options).forEach((opt) => { opt.selected = ids.has(opt.value); }); } else if (currentSelection && currentSelection.id) { select.value = currentSelection.id; } select.onchange = () => { const nextSession = getSession(); if (!nextSession || !nextSession.channel) return; const selectedOptions = optionGroup.options || []; if (optionGroup.multiSelect) { const selected = Array.from(select.selectedOptions).map((opt) => selectedOptions.find((item) => item.id === opt.value) ).filter(Boolean); nextSession.options[optionGroup.id] = selected; } else { const selected = selectedOptions.find((item) => item.id === select.value); if (selected) { nextSession.options[optionGroup.id] = selected; } } setSession(nextSession); savePreference(nextSession); resetAndReload(); }; wrapper.appendChild(label); wrapper.appendChild(select); container.appendChild(wrapper); }); } function closeDrawers() { const menuDrawer = document.getElementById('drawer-menu'); const settingsDrawer = document.getElementById('drawer-settings'); const overlay = document.getElementById('overlay'); const menuBtn = document.querySelector('.menu-toggle'); const settingsBtn = document.querySelector('.settings-toggle'); if (menuDrawer) menuDrawer.classList.remove('open'); if (settingsDrawer) settingsDrawer.classList.remove('open'); if (overlay) overlay.classList.remove('open'); if (menuBtn) menuBtn.classList.remove('active'); if (settingsBtn) settingsBtn.classList.remove('active'); document.body.classList.remove('drawer-open'); } function toggleDrawer(type) { const menuDrawer = document.getElementById('drawer-menu'); const settingsDrawer = document.getElementById('drawer-settings'); const overlay = document.getElementById('overlay'); const menuBtn = document.querySelector('.menu-toggle'); const settingsBtn = document.querySelector('.settings-toggle'); const isMenu = type === 'menu'; const targetDrawer = isMenu ? menuDrawer : settingsDrawer; const otherDrawer = isMenu ? settingsDrawer : menuDrawer; const targetBtn = isMenu ? menuBtn : settingsBtn; const otherBtn = isMenu ? settingsBtn : menuBtn; if (!targetDrawer || !overlay) return; const willOpen = !targetDrawer.classList.contains('open'); if (otherDrawer) otherDrawer.classList.remove('open'); if (otherBtn) otherBtn.classList.remove('active'); if (willOpen) { targetDrawer.classList.add('open'); if (targetBtn) targetBtn.classList.add('active'); overlay.classList.add('open'); document.body.classList.add('drawer-open'); } else { closeDrawers(); } } document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeDrawers(); }); initApp();