diff --git a/frontend/app.js b/frontend/app.js deleted file mode 100644 index 39084f9..0000000 --- a/frontend/app.js +++ /dev/null @@ -1,1123 +0,0 @@ -// 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; -let currentLoadController = null; -let errorToastTimer = null; -let playerMode = 'modal'; -let playerHome = null; -let onFullscreenChange = null; -let onWebkitEndFullscreen = null; -const FAVORITES_KEY = 'favorites'; -const FAVORITES_VISIBILITY_KEY = 'favoritesVisible'; - -// 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": {} }, - { "https://hottubapp.io": {} }, - { "https://hottub.spacemoehre.de": {} } - ] - })); - } - if (!localStorage.getItem('theme')) { - localStorage.setItem('theme', 'dark'); - } - if (!localStorage.getItem(FAVORITES_KEY)) { - localStorage.setItem(FAVORITES_KEY, JSON.stringify([])); - } - if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) { - localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true'); - } - // 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(); - currentLoadController = new AbortController(); - const response = await fetch('/api/videos', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body), - signal: currentLoadController.signal - }); - const videos = await response.json(); - renderVideos(videos); - hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true; - currentPage++; - ensureViewportFilled(); - } catch (err) { - if (err.name !== 'AbortError') { - console.error("Failed to load videos:", err); - } - } finally { - isLoading = false; - currentLoadController = null; - updateLoadMoreState(); - } -} - -function renderVideos(videos) { - const grid = document.getElementById('video-grid'); - if (!grid) return; - - const items = videos && Array.isArray(videos.items) ? videos.items : []; - const favoritesSet = getFavoritesSet(); - items.forEach(v => { - if (renderedVideoIds.has(v.id)) return; - - const card = document.createElement('div'); - card.className = 'video-card'; - const durationText = formatDuration(v.duration); - const favoriteKey = getFavoriteKey(v); - card.innerHTML = ` - - ${v.title} -

${v.title}

-

${v.channel}

- ${durationText ? `

${durationText}

` : ''} - `; - const favoriteBtn = card.querySelector('.favorite-btn'); - if (favoriteBtn && favoriteKey) { - setFavoriteButtonState(favoriteBtn, favoritesSet.has(favoriteKey)); - favoriteBtn.onclick = (event) => { - event.stopPropagation(); - toggleFavorite(v); - }; - } - card.onclick = () => openPlayer(v.url); - grid.appendChild(card); - renderedVideoIds.add(v.id); - }); - - ensureViewportFilled(); -} - -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`; -} - -function isMobilePlayback() { - if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') { - return navigator.userAgentData.mobile; - } - const ua = navigator.userAgent || ''; - if (/iPhone|iPad|iPod|Android/i.test(ua)) return true; - return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches; -} - -function isTvPlayback() { - const ua = navigator.userAgent || ''; - return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua); -} - -function getMobileVideoHost() { - let host = document.getElementById('mobile-video-host'); - if (!host) { - host = document.createElement('div'); - host.id = 'mobile-video-host'; - document.body.appendChild(host); - } - return host; -} - -function getFavorites() { - try { - const raw = localStorage.getItem(FAVORITES_KEY); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed : []; - } catch (err) { - return []; - } -} - -function setFavorites(items) { - localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); -} - -function getFavoriteKey(video) { - if (!video) return null; - return video.key || video.id || video.url || null; -} - -function normalizeFavorite(video) { - const key = getFavoriteKey(video); - if (!key) return null; - return { - key, - id: video.id || null, - url: video.url || '', - title: video.title || '', - thumb: video.thumb || '', - channel: video.channel || '', - duration: video.duration || 0 - }; -} - -function getFavoritesSet() { - return new Set(getFavorites().map((item) => item.key)); -} - -function setFavoriteButtonState(button, isFavorite) { - button.classList.toggle('is-favorite', isFavorite); - button.textContent = isFavorite ? '♥' : '♡'; - button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false'); - button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites'); -} - -function syncFavoriteButtons() { - const favoritesSet = getFavoritesSet(); - document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => { - const key = button.dataset.favKey; - if (!key) return; - setFavoriteButtonState(button, favoritesSet.has(key)); - }); -} - -function toggleFavorite(video) { - const key = getFavoriteKey(video); - if (!key) return; - const favorites = getFavorites(); - const existingIndex = favorites.findIndex((item) => item.key === key); - if (existingIndex >= 0) { - favorites.splice(existingIndex, 1); - } else { - const entry = normalizeFavorite(video); - if (entry) favorites.unshift(entry); - } - setFavorites(favorites); - renderFavoritesBar(); - syncFavoriteButtons(); -} - -function isFavoritesVisible() { - return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false'; -} - -function setFavoritesVisible(isVisible) { - localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false'); -} - -function renderFavoritesBar() { - const bar = document.getElementById('favorites-bar'); - const list = document.getElementById('favorites-list'); - const empty = document.getElementById('favorites-empty'); - if (!bar || !list) return; - - const favorites = getFavorites(); - const visible = isFavoritesVisible(); - bar.style.display = visible ? 'block' : 'none'; - - list.innerHTML = ""; - favorites.forEach((item) => { - const card = document.createElement('div'); - card.className = 'favorite-card'; - card.dataset.favKey = item.key; - card.innerHTML = ` - - ${item.title} -
-

${item.title}

-

${item.channel}

-
- `; - card.onclick = () => openPlayer(item.url); - const favoriteBtn = card.querySelector('.favorite-btn'); - if (favoriteBtn) { - favoriteBtn.onclick = (event) => { - event.stopPropagation(); - toggleFavorite(item); - }; - } - list.appendChild(card); - }); - - if (empty) { - empty.style.display = favorites.length > 0 ? 'none' : 'block'; - } -} - -// 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(); - renderFavoritesBar(); - - const sentinel = document.getElementById('sentinel'); - if (sentinel) { - observer.observe(sentinel); - } - - const loadMoreBtn = document.getElementById('load-more-btn'); - if (loadMoreBtn) { - loadMoreBtn.onclick = () => { - loadVideos(); - }; - } - - const errorToastClose = document.getElementById('error-toast-close'); - if (errorToastClose) { - errorToastClose.onclick = () => { - const toast = document.getElementById('error-toast'); - if (toast) toast.classList.remove('show'); - }; - } - - window.addEventListener('resize', () => { - ensureViewportFilled(); - }); - - await loadVideos(); - syncFavoriteButtons(); -} - -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; -} - -function showError(message) { - const toast = document.getElementById('error-toast'); - const text = document.getElementById('error-toast-text'); - if (!toast || !text) return; - text.textContent = message; - toast.classList.add('show'); - if (errorToastTimer) { - clearTimeout(errorToastTimer); - } - errorToastTimer = setTimeout(() => { - toast.classList.remove('show'); - }, 4000); -} - -async function openPlayer(url) { - const modal = document.getElementById('video-modal'); - const video = document.getElementById('player'); - const useMobileFullscreen = isMobilePlayback() || isTvPlayback(); - let playbackStarted = false; - - if (!playerHome) { - playerHome = video.parentElement; - } - - // 1. Define isHls (the missing piece!) - let refererParam = ''; - try { - const origin = new URL(url).origin; - refererParam = `&referer=${encodeURIComponent(origin + '/')}`; - } catch (err) { - refererParam = ''; - } - const streamUrl = `/api/stream?url=${encodeURIComponent(url)}${refererParam}`; - 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 (useMobileFullscreen) { - const host = getMobileVideoHost(); - if (video.parentElement !== host) { - host.appendChild(video); - } - playerMode = 'mobile'; - video.removeAttribute('playsinline'); - video.removeAttribute('webkit-playsinline'); - video.playsInline = false; - } else { - if (playerHome && video.parentElement !== playerHome) { - playerHome.appendChild(video); - } - playerMode = 'modal'; - video.setAttribute('playsinline', ''); - video.setAttribute('webkit-playsinline', ''); - video.playsInline = true; - } - - const requestFullscreen = () => { - if (playerMode !== 'mobile') return; - if (typeof video.webkitEnterFullscreen === 'function') { - try { - video.webkitEnterFullscreen(); - } catch (err) { - // Ignore if fullscreen is not allowed. - } - return; - } - if (video.requestFullscreen) { - video.requestFullscreen().catch(() => {}); - } - }; - - const startPlayback = () => { - if (playbackStarted) return; - playbackStarted = true; - const playPromise = video.play(); - if (playPromise && typeof playPromise.catch === 'function') { - playPromise.catch(() => {}); - } - if (playerMode === 'mobile') { - if (video.readyState >= 1) { - requestFullscreen(); - } else { - video.addEventListener('loadedmetadata', requestFullscreen, { once: true }); - } - } - }; - - 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() { - startPlayback(); - }); - startPlayback(); - hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) { - if (data && data.fatal) { - showError('Unable to play this stream.'); - closePlayer(); - } - }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = streamUrl; - startPlayback(); - } else { - console.error("HLS not supported in this browser."); - showError('HLS is not supported in this browser.'); - return; - } - } else { - video.src = streamUrl; - startPlayback(); - } - - video.onerror = () => { - showError('Video failed to load.'); - closePlayer(); - }; - - if (playerMode === 'modal') { - modal.style.display = 'flex'; - document.body.style.overflow = 'hidden'; - } else { - modal.style.display = 'none'; - document.body.style.overflow = 'auto'; - if (!onFullscreenChange) { - onFullscreenChange = () => { - if (playerMode === 'mobile' && !document.fullscreenElement) { - closePlayer(); - } - }; - } - document.addEventListener('fullscreenchange', onFullscreenChange); - if (!onWebkitEndFullscreen) { - onWebkitEndFullscreen = () => { - if (playerMode === 'mobile') { - closePlayer(); - } - }; - } - video.addEventListener('webkitendfullscreen', onWebkitEndFullscreen); - } -} - -function closePlayer() { - const modal = document.getElementById('video-modal'); - const video = document.getElementById('player'); - if (hlsPlayer) { - hlsPlayer.destroy(); - hlsPlayer = null; - } - if (document.fullscreenElement && document.exitFullscreen) { - document.exitFullscreen().catch(() => {}); - } - if (onFullscreenChange) { - document.removeEventListener('fullscreenchange', onFullscreenChange); - } - if (onWebkitEndFullscreen) { - video.removeEventListener('webkitendfullscreen', onWebkitEndFullscreen); - } - video.onerror = null; - video.pause(); - video.src = ''; - modal.style.display = 'none'; - document.body.style.overflow = 'auto'; - if (playerHome && video.parentElement !== playerHome) { - playerHome.appendChild(video); - } - playerMode = 'modal'; -} - -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() { - if (currentLoadController) { - currentLoadController.abort(); - currentLoadController = null; - isLoading = false; - } - 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 docHeight = document.documentElement.scrollHeight; - if (docHeight <= 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'); - const favoritesToggle = document.getElementById('favorites-toggle'); - - 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 (favoritesToggle) { - favoritesToggle.checked = isFavoritesVisible(); - favoritesToggle.onchange = () => { - setFavoritesVisible(favoritesToggle.checked); - renderFavoritesBar(); - }; - } - - 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 labelRow = document.createElement('div'); - labelRow.className = 'setting-label-row'; - - const label = document.createElement('label'); - label.textContent = optionGroup.title || optionGroup.id; - labelRow.appendChild(label); - const options = optionGroup.options || []; - const currentSelection = session.options ? session.options[optionGroup.id] : null; - - if (optionGroup.multiSelect) { - const actionBtn = document.createElement('button'); - actionBtn.type = 'button'; - actionBtn.className = 'btn-link'; - - const list = document.createElement('div'); - list.className = 'multi-select'; - - const selectedIds = new Set( - Array.isArray(currentSelection) - ? currentSelection.map((item) => item.id) - : [] - ); - - const updateActionLabel = () => { - const allChecked = options.length > 0 && - Array.from(list.querySelectorAll('input[type="checkbox"]')) - .every((cb) => cb.checked); - actionBtn.textContent = allChecked ? 'Deselect all' : 'Select all'; - actionBtn.disabled = options.length === 0; - }; - - options.forEach((opt) => { - const item = document.createElement('label'); - item.className = 'multi-select-item'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.value = opt.id; - checkbox.checked = selectedIds.has(opt.id); - - const text = document.createElement('span'); - text.textContent = opt.title || opt.id; - - checkbox.onchange = () => { - const nextSession = getSession(); - if (!nextSession || !nextSession.channel) return; - const selected = []; - list.querySelectorAll('input[type="checkbox"]').forEach((cb) => { - if (cb.checked) { - const found = options.find((item) => item.id === cb.value); - if (found) selected.push(found); - } - }); - nextSession.options[optionGroup.id] = selected; - setSession(nextSession); - savePreference(nextSession); - resetAndReload(); - updateActionLabel(); - }; - - item.appendChild(checkbox); - item.appendChild(text); - list.appendChild(item); - }); - - updateActionLabel(); - - actionBtn.onclick = () => { - const checkboxes = Array.from(list.querySelectorAll('input[type="checkbox"]')); - const allChecked = checkboxes.length > 0 && checkboxes.every((cb) => cb.checked); - checkboxes.forEach((cb) => { - cb.checked = !allChecked; - }); - - const nextSession = getSession(); - if (!nextSession || !nextSession.channel) return; - const selected = []; - if (!allChecked) { - options.forEach((opt) => selected.push(opt)); - } - nextSession.options[optionGroup.id] = selected; - setSession(nextSession); - savePreference(nextSession); - resetAndReload(); - updateActionLabel(); - }; - - labelRow.appendChild(actionBtn); - wrapper.appendChild(labelRow); - wrapper.appendChild(list); - container.appendChild(wrapper); - return; - } - - const select = document.createElement('select'); - options.forEach((opt) => { - const option = document.createElement('option'); - option.value = opt.id; - option.textContent = opt.title || opt.id; - select.appendChild(option); - }); - - if (currentSelection && currentSelection.id) { - select.value = currentSelection.id; - } - - select.onchange = () => { - const nextSession = getSession(); - if (!nextSession || !nextSession.channel) return; - const selected = options.find((item) => item.id === select.value); - if (selected) { - nextSession.options[optionGroup.id] = selected; - } - - setSession(nextSession); - savePreference(nextSession); - resetAndReload(); - }; - - wrapper.appendChild(labelRow); - 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(); diff --git a/frontend/style.css b/frontend/css/style.css similarity index 100% rename from frontend/style.css rename to frontend/css/style.css diff --git a/frontend/index.html b/frontend/index.html index 927ef78..5e617f7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,7 +8,7 @@ - +
@@ -118,7 +118,13 @@ - + + + + + + + diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js new file mode 100644 index 0000000..ce2d426 --- /dev/null +++ b/frontend/js/favorites.js @@ -0,0 +1,124 @@ +window.App = window.App || {}; +App.favorites = App.favorites || {}; + +(function() { + const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants; + + // Favorites storage helpers. + App.favorites.getAll = function() { + try { + const raw = localStorage.getItem(FAVORITES_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + return []; + } + }; + + App.favorites.setAll = function(items) { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); + }; + + App.favorites.getKey = function(video) { + if (!video) return null; + return video.key || video.id || video.url || null; + }; + + App.favorites.normalize = function(video) { + const key = App.favorites.getKey(video); + if (!key) return null; + return { + key, + id: video.id || null, + url: video.url || '', + title: video.title || '', + thumb: video.thumb || '', + channel: video.channel || '', + duration: video.duration || 0 + }; + }; + + App.favorites.getSet = function() { + return new Set(App.favorites.getAll().map((item) => item.key)); + }; + + App.favorites.isVisible = function() { + return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false'; + }; + + App.favorites.setVisible = function(isVisible) { + localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false'); + }; + + // UI helpers for rendering and syncing heart states. + App.favorites.setButtonState = function(button, isFavorite) { + button.classList.toggle('is-favorite', isFavorite); + button.textContent = isFavorite ? '♥' : '♡'; + button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false'); + button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites'); + }; + + App.favorites.syncButtons = function() { + const favoritesSet = App.favorites.getSet(); + document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => { + const key = button.dataset.favKey; + if (!key) return; + App.favorites.setButtonState(button, favoritesSet.has(key)); + }); + }; + + App.favorites.toggle = function(video) { + const key = App.favorites.getKey(video); + if (!key) return; + const favorites = App.favorites.getAll(); + const existingIndex = favorites.findIndex((item) => item.key === key); + if (existingIndex >= 0) { + favorites.splice(existingIndex, 1); + } else { + const entry = App.favorites.normalize(video); + if (entry) favorites.unshift(entry); + } + App.favorites.setAll(favorites); + App.favorites.renderBar(); + App.favorites.syncButtons(); + }; + + App.favorites.renderBar = function() { + const bar = document.getElementById('favorites-bar'); + const list = document.getElementById('favorites-list'); + const empty = document.getElementById('favorites-empty'); + if (!bar || !list) return; + + const favorites = App.favorites.getAll(); + const visible = App.favorites.isVisible(); + bar.style.display = visible ? 'block' : 'none'; + + list.innerHTML = ""; + favorites.forEach((item) => { + const card = document.createElement('div'); + card.className = 'favorite-card'; + card.dataset.favKey = item.key; + card.innerHTML = ` + + ${item.title} +
+

${item.title}

+

${item.channel}

+
+ `; + card.onclick = () => App.player.open(item.url); + const favoriteBtn = card.querySelector('.favorite-btn'); + if (favoriteBtn) { + favoriteBtn.onclick = (event) => { + event.stopPropagation(); + App.favorites.toggle(item); + }; + } + list.appendChild(card); + }); + + if (empty) { + empty.style.display = favorites.length > 0 ? 'none' : 'block'; + } + }; +})(); diff --git a/frontend/js/main.js b/frontend/js/main.js new file mode 100644 index 0000000..49891de --- /dev/null +++ b/frontend/js/main.js @@ -0,0 +1,38 @@ +window.App = window.App || {}; + +(function() { + // App bootstrap: initialize storage, render UI, and load the first page. + async function initApp() { + await App.storage.ensureDefaults(); + App.ui.applyTheme(); + App.ui.renderMenu(); + App.favorites.renderBar(); + App.ui.bindGlobalHandlers(); + + App.videos.observeSentinel(); + + const loadMoreBtn = document.getElementById('load-more-btn'); + if (loadMoreBtn) { + loadMoreBtn.onclick = () => { + App.videos.loadVideos(); + }; + } + + const errorToastClose = document.getElementById('error-toast-close'); + if (errorToastClose) { + errorToastClose.onclick = () => { + const toast = document.getElementById('error-toast'); + if (toast) toast.classList.remove('show'); + }; + } + + window.addEventListener('resize', () => { + App.videos.ensureViewportFilled(); + }); + + await App.videos.loadVideos(); + App.favorites.syncButtons(); + } + + initApp(); +})(); diff --git a/frontend/js/player.js b/frontend/js/player.js new file mode 100644 index 0000000..b7d9dce --- /dev/null +++ b/frontend/js/player.js @@ -0,0 +1,221 @@ +window.App = window.App || {}; +App.player = App.player || {}; + +(function() { + const state = App.state; + + // Playback heuristics for full-screen behavior on mobile/TV browsers. + function isMobilePlayback() { + if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') { + return navigator.userAgentData.mobile; + } + const ua = navigator.userAgent || ''; + if (/iPhone|iPad|iPod|Android/i.test(ua)) return true; + return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches; + } + + function isTvPlayback() { + const ua = navigator.userAgent || ''; + return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua); + } + + function getMobileVideoHost() { + let host = document.getElementById('mobile-video-host'); + if (!host) { + host = document.createElement('div'); + host.id = 'mobile-video-host'; + document.body.appendChild(host); + } + return host; + } + + App.player.open = async function(url) { + const modal = document.getElementById('video-modal'); + const video = document.getElementById('player'); + if (!modal || !video) return; + const useMobileFullscreen = isMobilePlayback() || isTvPlayback(); + let playbackStarted = false; + + if (!state.playerHome) { + state.playerHome = video.parentElement; + } + + // Normalize stream URL + optional referer forwarding. + let refererParam = ''; + try { + const origin = new URL(url).origin; + refererParam = `&referer=${encodeURIComponent(origin + '/')}`; + } catch (err) { + refererParam = ''; + } + const streamUrl = `/api/stream?url=${encodeURIComponent(url)}${refererParam}`; + let isHls = /\.m3u8($|\?)/i.test(url); + + // Cleanup existing player instance to prevent aborted bindings. + if (state.hlsPlayer) { + state.hlsPlayer.stopLoad(); + state.hlsPlayer.detachMedia(); + state.hlsPlayer.destroy(); + state.hlsPlayer = null; + } + + // Reset the video element before re-binding a new source. + 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 (useMobileFullscreen) { + const host = getMobileVideoHost(); + if (video.parentElement !== host) { + host.appendChild(video); + } + state.playerMode = 'mobile'; + video.removeAttribute('playsinline'); + video.removeAttribute('webkit-playsinline'); + video.playsInline = false; + } else { + if (state.playerHome && video.parentElement !== state.playerHome) { + state.playerHome.appendChild(video); + } + state.playerMode = 'modal'; + video.setAttribute('playsinline', ''); + video.setAttribute('webkit-playsinline', ''); + video.playsInline = true; + } + + const requestFullscreen = () => { + if (state.playerMode !== 'mobile') return; + if (typeof video.webkitEnterFullscreen === 'function') { + try { + video.webkitEnterFullscreen(); + } catch (err) { + // Ignore if fullscreen is not allowed. + } + return; + } + if (video.requestFullscreen) { + video.requestFullscreen().catch(() => {}); + } + }; + + const startPlayback = () => { + if (playbackStarted) return; + playbackStarted = true; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(() => {}); + } + if (state.playerMode === 'mobile') { + if (video.readyState >= 1) { + requestFullscreen(); + } else { + video.addEventListener('loadedmetadata', requestFullscreen, { once: true }); + } + } + }; + + if (isHls) { + if (window.Hls && window.Hls.isSupported()) { + state.hlsPlayer = new window.Hls(); + state.hlsPlayer.loadSource(streamUrl); + state.hlsPlayer.attachMedia(video); + state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() { + startPlayback(); + }); + startPlayback(); + state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) { + if (data && data.fatal) { + if (App.ui && App.ui.showError) { + App.ui.showError('Unable to play this stream.'); + } + App.player.close(); + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = streamUrl; + startPlayback(); + } else { + console.error("HLS not supported in this browser."); + if (App.ui && App.ui.showError) { + App.ui.showError('HLS is not supported in this browser.'); + } + return; + } + } else { + video.src = streamUrl; + startPlayback(); + } + + video.onerror = () => { + if (App.ui && App.ui.showError) { + App.ui.showError('Video failed to load.'); + } + App.player.close(); + }; + + if (state.playerMode === 'modal') { + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + } else { + modal.style.display = 'none'; + document.body.style.overflow = 'auto'; + if (!state.onFullscreenChange) { + state.onFullscreenChange = () => { + if (state.playerMode === 'mobile' && !document.fullscreenElement) { + App.player.close(); + } + }; + } + document.addEventListener('fullscreenchange', state.onFullscreenChange); + if (!state.onWebkitEndFullscreen) { + state.onWebkitEndFullscreen = () => { + if (state.playerMode === 'mobile') { + App.player.close(); + } + }; + } + video.addEventListener('webkitendfullscreen', state.onWebkitEndFullscreen); + } + }; + + App.player.close = function() { + const modal = document.getElementById('video-modal'); + const video = document.getElementById('player'); + if (!modal || !video) return; + + if (state.hlsPlayer) { + state.hlsPlayer.destroy(); + state.hlsPlayer = null; + } + if (document.fullscreenElement && document.exitFullscreen) { + document.exitFullscreen().catch(() => {}); + } + if (state.onFullscreenChange) { + document.removeEventListener('fullscreenchange', state.onFullscreenChange); + } + if (state.onWebkitEndFullscreen) { + video.removeEventListener('webkitendfullscreen', state.onWebkitEndFullscreen); + } + video.onerror = null; + video.pause(); + video.src = ''; + modal.style.display = 'none'; + document.body.style.overflow = 'auto'; + if (state.playerHome && video.parentElement !== state.playerHome) { + state.playerHome.appendChild(video); + } + state.playerMode = 'modal'; + }; +})(); diff --git a/frontend/js/state.js b/frontend/js/state.js new file mode 100644 index 0000000..7e52eb2 --- /dev/null +++ b/frontend/js/state.js @@ -0,0 +1,23 @@ +window.App = window.App || {}; + +// Centralized runtime state for pagination, player, and UI behavior. +App.state = { + currentPage: 1, + perPage: 12, + renderedVideoIds: new Set(), + hasNextPage: true, + isLoading: false, + hlsPlayer: null, + currentLoadController: null, + errorToastTimer: null, + playerMode: 'modal', + playerHome: null, + onFullscreenChange: null, + onWebkitEndFullscreen: null +}; + +// Local storage keys used across modules. +App.constants = { + FAVORITES_KEY: 'favorites', + FAVORITES_VISIBILITY_KEY: 'favoritesVisible' +}; diff --git a/frontend/js/storage.js b/frontend/js/storage.js new file mode 100644 index 0000000..4aa4f4d --- /dev/null +++ b/frontend/js/storage.js @@ -0,0 +1,182 @@ +window.App = window.App || {}; +App.storage = App.storage || {}; +App.session = App.session || {}; + +(function() { + const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants; + + // Basic localStorage helpers. + App.storage.getConfig = function() { + return JSON.parse(localStorage.getItem('config')) || { servers: [] }; + }; + + App.storage.setConfig = function(nextConfig) { + localStorage.setItem('config', JSON.stringify(nextConfig)); + }; + + App.storage.getSession = function() { + return JSON.parse(localStorage.getItem('session')) || null; + }; + + App.storage.setSession = function(nextSession) { + localStorage.setItem('session', JSON.stringify(nextSession)); + }; + + App.storage.getPreferences = function() { + return JSON.parse(localStorage.getItem('preferences')) || {}; + }; + + App.storage.setPreferences = function(nextPreferences) { + localStorage.setItem('preferences', JSON.stringify(nextPreferences)); + }; + + App.storage.getServerEntries = function() { + const config = App.storage.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 + }; + }); + }; + + // Options/session helpers that power channel selection and filters. + App.session.serializeOptions = function(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; + }; + + App.session.hydrateOptions = function(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; + }; + + App.session.savePreference = function(session) { + if (!session || !session.server || !session.channel) return; + const prefs = App.storage.getPreferences(); + const serverPrefs = prefs[session.server] || {}; + serverPrefs.channelId = session.channel.id; + serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {}; + serverPrefs.optionsByChannel[session.channel.id] = App.session.serializeOptions(session.options); + prefs[session.server] = serverPrefs; + App.storage.setPreferences(prefs); + }; + + App.session.buildDefaultOptions = function(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; + }; + + // Ensures defaults exist and refreshes server status. + App.storage.ensureDefaults = async function() { + if (!localStorage.getItem('config')) { + localStorage.setItem('config', JSON.stringify({ + servers: [ + { "https://getfigleaf.com": {} }, + { "https://hottubapp.io": {} }, + { "https://hottub.spacemoehre.de": {} } + ] + })); + } + if (!localStorage.getItem('theme')) { + localStorage.setItem('theme', 'dark'); + } + if (!localStorage.getItem(FAVORITES_KEY)) { + localStorage.setItem(FAVORITES_KEY, JSON.stringify([])); + } + if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) { + localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true'); + } + await App.storage.initializeServerStatus(); + }; + + // Fetches server status and keeps the session pointing to a valid channel/options. + App.storage.initializeServerStatus = async function() { + 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 = App.storage.getSession(); + const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]); + if (serverKeys.length === 0) return; + const selectedServerKey = existingSession && serverKeys.includes(existingSession.server) + ? existingSession.server + : serverKeys[0]; + const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey); + const serverData = serverEntry ? serverEntry[selectedServerKey] : null; + + if (serverData && serverData.channels && serverData.channels.length > 0) { + const prefs = App.storage.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 ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel); + + const sessionData = { + server: selectedServerKey, + channel: channel, + options: options, + }; + + App.storage.setSession(sessionData); + App.session.savePreference(sessionData); + } + }; +})(); diff --git a/frontend/js/ui.js b/frontend/js/ui.js new file mode 100644 index 0000000..ec22c6d --- /dev/null +++ b/frontend/js/ui.js @@ -0,0 +1,437 @@ +window.App = window.App || {}; +App.ui = App.ui || {}; + +(function() { + const state = App.state; + + App.ui.applyTheme = function() { + 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; + }; + + // Toast helper for playback + network errors. + App.ui.showError = function(message) { + const toast = document.getElementById('error-toast'); + const text = document.getElementById('error-toast-text'); + if (!toast || !text) return; + text.textContent = message; + toast.classList.add('show'); + if (state.errorToastTimer) { + clearTimeout(state.errorToastTimer); + } + state.errorToastTimer = setTimeout(() => { + toast.classList.remove('show'); + }, 4000); + }; + + // Drawer controls shared by the inline HTML handlers. + App.ui.closeDrawers = function() { + 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'); + }; + + App.ui.toggleDrawer = function(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 { + App.ui.closeDrawers(); + } + }; + + // Settings + menu rendering. + App.ui.renderMenu = function() { + const session = App.storage.getSession(); + const serverEntries = App.storage.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'); + const favoritesToggle = document.getElementById('favorites-toggle'); + + 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 = App.storage.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 ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {} + }; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + App.ui.renderMenu(); + App.videos.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 = App.storage.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 ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {} + }; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + App.ui.renderMenu(); + App.videos.resetAndReload(); + }; + + App.ui.renderFilters(filtersContainer, session); + + const themeSelect = document.getElementById('theme-select'); + if (themeSelect) { + themeSelect.onchange = () => { + const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark'; + localStorage.setItem('theme', nextTheme); + App.ui.applyTheme(); + }; + } + + if (favoritesToggle) { + favoritesToggle.checked = App.favorites.isVisible(); + favoritesToggle.onchange = () => { + App.favorites.setVisible(favoritesToggle.checked); + App.favorites.renderBar(); + }; + } + + 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 = App.storage.getConfig(); + config.servers = (config.servers || []).filter((serverObj) => { + const key = Object.keys(serverObj)[0]; + return key !== entry.url; + }); + App.storage.setConfig(config); + const prefs = App.storage.getPreferences(); + if (prefs[entry.url]) { + delete prefs[entry.url]; + App.storage.setPreferences(prefs); + } + + const remaining = App.storage.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 ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {} + }; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + } + + await App.storage.initializeServerStatus(); + App.videos.resetAndReload(); + App.ui.renderMenu(); + }; + + 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 = App.storage.getConfig(); + const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized); + if (!exists) { + config.servers = config.servers || []; + config.servers.push({ + [normalized]: {} + }); + App.storage.setConfig(config); + sourceInput.value = ''; + await App.storage.initializeServerStatus(); + + const session = App.storage.getSession(); + if (!session || session.server !== normalized) { + const entries = App.storage.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 ? App.session.buildDefaultOptions(nextChannel) : {} + }; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + } + App.ui.renderMenu(); + App.videos.resetAndReload(); + } + }; + } + + if (reloadChannelBtn) { + reloadChannelBtn.onclick = () => { + App.videos.resetAndReload(); + }; + } + }; + + App.ui.renderFilters = function(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 labelRow = document.createElement('div'); + labelRow.className = 'setting-label-row'; + + const label = document.createElement('label'); + label.textContent = optionGroup.title || optionGroup.id; + labelRow.appendChild(label); + const options = optionGroup.options || []; + const currentSelection = session.options ? session.options[optionGroup.id] : null; + + if (optionGroup.multiSelect) { + const actionBtn = document.createElement('button'); + actionBtn.type = 'button'; + actionBtn.className = 'btn-link'; + + const list = document.createElement('div'); + list.className = 'multi-select'; + + const selectedIds = new Set( + Array.isArray(currentSelection) + ? currentSelection.map((item) => item.id) + : [] + ); + + const updateActionLabel = () => { + const allChecked = options.length > 0 && + Array.from(list.querySelectorAll('input[type="checkbox"]')) + .every((cb) => cb.checked); + actionBtn.textContent = allChecked ? 'Deselect all' : 'Select all'; + actionBtn.disabled = options.length === 0; + }; + + options.forEach((opt) => { + const item = document.createElement('label'); + item.className = 'multi-select-item'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = opt.id; + checkbox.checked = selectedIds.has(opt.id); + + const text = document.createElement('span'); + text.textContent = opt.title || opt.id; + + checkbox.onchange = () => { + const nextSession = App.storage.getSession(); + if (!nextSession || !nextSession.channel) return; + const selected = []; + list.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + if (cb.checked) { + const found = options.find((item) => item.id === cb.value); + if (found) selected.push(found); + } + }); + nextSession.options[optionGroup.id] = selected; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + App.videos.resetAndReload(); + updateActionLabel(); + }; + + item.appendChild(checkbox); + item.appendChild(text); + list.appendChild(item); + }); + + updateActionLabel(); + + actionBtn.onclick = () => { + const checkboxes = Array.from(list.querySelectorAll('input[type="checkbox"]')); + const allChecked = checkboxes.length > 0 && checkboxes.every((cb) => cb.checked); + checkboxes.forEach((cb) => { + cb.checked = !allChecked; + }); + + const nextSession = App.storage.getSession(); + if (!nextSession || !nextSession.channel) return; + const selected = []; + if (!allChecked) { + options.forEach((opt) => selected.push(opt)); + } + nextSession.options[optionGroup.id] = selected; + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + App.videos.resetAndReload(); + updateActionLabel(); + }; + + labelRow.appendChild(actionBtn); + wrapper.appendChild(labelRow); + wrapper.appendChild(list); + container.appendChild(wrapper); + return; + } + + const select = document.createElement('select'); + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.id; + option.textContent = opt.title || opt.id; + select.appendChild(option); + }); + + if (currentSelection && currentSelection.id) { + select.value = currentSelection.id; + } + + select.onchange = () => { + const nextSession = App.storage.getSession(); + if (!nextSession || !nextSession.channel) return; + const selected = options.find((item) => item.id === select.value); + if (selected) { + nextSession.options[optionGroup.id] = selected; + } + + App.storage.setSession(nextSession); + App.session.savePreference(nextSession); + App.videos.resetAndReload(); + }; + + wrapper.appendChild(labelRow); + wrapper.appendChild(select); + container.appendChild(wrapper); + }); + }; + + // Expose inline handlers + keyboard shortcuts. + App.ui.bindGlobalHandlers = function() { + window.toggleDrawer = App.ui.toggleDrawer; + window.closeDrawers = App.ui.closeDrawers; + window.closePlayer = App.player.close; + window.handleSearch = App.videos.handleSearch; + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') App.ui.closeDrawers(); + }); + }; +})(); diff --git a/frontend/js/videos.js b/frontend/js/videos.js new file mode 100644 index 0000000..cc416b3 --- /dev/null +++ b/frontend/js/videos.js @@ -0,0 +1,162 @@ +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); + card.innerHTML = ` + + ${v.title} +

${v.title}

+

${v.channel}

+ ${durationText ? `

${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); + }; + } + card.onclick = () => App.player.open(v.url); + grid.appendChild(card); + state.renderedVideoIds.add(v.id); + }); + + App.videos.ensureViewportFilled(); + }; + + App.videos.handleSearch = function(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'; + }; +})();