diff --git a/.gitignore b/.gitignore index 4c49bd7..0e4891b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +var/cache/ diff --git a/README.md b/README.md index 0aa98bc..8dd14f4 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,28 @@ The launcher exports: | MEDIA_USER | Login username | | MEDIA_PASS_HASH | bcrypt password hash | | MEDIA_ROOT | Base directory containing Videos/ and Music/ | +| MEDIA_CACHE_DIR | Cache directory for media index JSON files | +| MEDIA_CACHE_INTERVAL | Background rebuild interval in seconds (default: 900) | Credentials are **not stored on disk**. They exist only in the running server environment. +If `MEDIA_CACHE_DIR` is not set, FREAX defaults to `var/cache/media` inside the project directory. + +--- + +## Media Cache Refresh + +FREAX maintains JSON cache indexes for both `Videos` and `Music`. + +- `build_media_cache.php` rebuilds both caches in one pass +- `start-media-server.sh` runs a background refresh loop while the server is running +- Default refresh interval is 900 seconds (15 minutes) +- Cache and lock files are stored in `MEDIA_CACHE_DIR` + +`get_files.php` reads cache first and only rescans on cache miss/corruption. + --- ## LAN Usage @@ -225,7 +242,7 @@ FREAX intentionally minimizes attack surface: * No SQL backend * No upload endpoints -* No file writes +* No media file writes * No user content creation * No dynamic code execution * Strict base directory enforcement @@ -291,4 +308,3 @@ This is free software released under the GPLv2 license. You have permission to m --- Happy hacking. - diff --git a/assets/css/style.css b/assets/css/style.css index 259337f..04726d0 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -118,8 +118,55 @@ h1 { margin-bottom: 1.5rem; } +.wizard-panel { + margin-bottom: 1rem; +} + +.wizard-step { + font-family: 'Orbitron', sans-serif; + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-primary); + margin-bottom: 1rem; +} + +.wizard-section { + display: grid; + gap: 0.5rem; +} + +.wizard-toolbar { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; +} + +.wizard-toolbar .btn { + padding: 0.55rem 0.4rem; + font-size: 0.7rem; +} + +.wizard-library { + font-family: 'Orbitron', sans-serif; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.wizard-path { + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.45rem 0.6rem; + font-size: 0.82rem; + word-break: break-word; +} + .tab-btn { - flex: 1; + width: 100%; padding: 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-color); @@ -222,6 +269,10 @@ h1 { font-size: 0.85rem; } +.file-item.folder-entry { + background: var(--bg-tertiary); +} + .file-item:hover { border-color: var(--accent-primary); background: var(--bg-tertiary); diff --git a/assets/js/app.js b/assets/js/app.js index ab84b4a..453722f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,32 +1,81 @@ -function showLogin(message = '') { - document.getElementById('loginOverlay').style.display = 'flex'; - document.getElementById('logoutBtn').style.display = 'none'; +// Freax Media Player - Wizard Frontend - const err = document.getElementById('loginError'); - if (message) { - err.textContent = message; - err.style.display = 'block'; - } else { - err.textContent = ''; - err.style.display = 'none'; +const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v']; +const LIBRARY_LABELS = { videos: 'Videos', music: 'Music' }; + +const state = { + step: 1, + currentType: null, + filesByType: { videos: null, music: null }, + currentNode: null, + currentPathParts: [], + selectedFilePath: null +}; + +const mediaDebugEnabled = (() => { + const params = new URLSearchParams(window.location.search); + if (params.get('debug') === '1') return true; + try { + return window.localStorage.getItem('MEDIA_DEBUG') === '1'; + } catch (_) { + return false; } +})(); + +const $ = id => document.getElementById(id); + +function logMediaDebug(details) { + if (!mediaDebugEnabled) return; + + const { + type, + source, + url, + status, + cacheStatus, + backendMs, + frontendFetchMs, + frontendParseMs, + renderMs, + payloadBytes, + forceRefresh, + cacheGeneratedAt, + error + } = details; + + console.groupCollapsed(`[media-debug] ${type}`); + if (source) console.log('source:', source); + if (url) console.log('url:', url); + if (typeof status !== 'undefined') console.log('status:', status); + if (cacheStatus) console.log('cache:', cacheStatus); + if (backendMs) console.log('backend_ms:', backendMs); + if (typeof frontendFetchMs === 'number') console.log('frontend_fetch_ms:', Math.round(frontendFetchMs)); + if (typeof frontendParseMs === 'number') console.log('frontend_parse_ms:', Math.round(frontendParseMs)); + if (typeof renderMs === 'number') console.log('render_ms:', Math.round(renderMs)); + if (typeof payloadBytes === 'number') console.log('payload_bytes:', payloadBytes); + if (forceRefresh) console.log('force_refresh:', forceRefresh); + if (cacheGeneratedAt) console.log('cache_generated_at:', cacheGeneratedAt); + if (error) console.log('error:', error); + console.groupEnd(); +} + +function showLogin(message = '') { + $('loginOverlay').style.display = 'flex'; + $('logoutBtn').style.display = 'none'; + $('loginError').textContent = message; + $('loginError').style.display = message ? 'block' : 'none'; } function hideLogin() { - document.getElementById('loginOverlay').style.display = 'none'; - document.getElementById('loginError').style.display = 'none'; - document.getElementById('logoutBtn').style.display = 'inline-block'; + $('loginOverlay').style.display = 'none'; + $('loginError').style.display = 'none'; + $('logoutBtn').style.display = 'inline-block'; } async function authedFetch(url, options = {}) { - // Ensure cookies (session) are sent - const resp = await fetch(url, { - ...options, - credentials: 'same-origin', - }); + const resp = await fetch(url, { ...options, credentials: 'same-origin' }); if (resp.status === 401) { - // Session missing/expired showLogin('Please log in to continue.'); throw new Error('Unauthorized'); } @@ -34,39 +83,20 @@ async function authedFetch(url, options = {}) { return resp; } -const state = { - currentType: 'videos', - currentFile: null, - filesData: null, - currentItemEl: null -}; - -// Initialize document.addEventListener('DOMContentLoaded', () => { - setupTabListeners(); setupLoginHandlers(); - - // Try loading files; if 401, overlay will appear. - loadFiles('videos'); + setupWizardListeners(); + initApp(); }); function setupLoginHandlers() { - const form = document.getElementById('loginForm'); - const spinner = document.getElementById('loginSpinner'); - const err = document.getElementById('loginError'); - const logoutBtn = document.getElementById('logoutBtn'); - - // Show overlay until proven authed - showLogin(''); - - form.addEventListener('submit', async (e) => { + $('loginForm').addEventListener('submit', async (e) => { e.preventDefault(); - err.style.display = 'none'; - spinner.style.display = 'inline-block'; - - const username = document.getElementById('username').value.trim(); - const password = document.getElementById('password').value; + const username = $('username').value.trim(); + const password = $('password').value; + $('loginError').style.display = 'none'; + $('loginSpinner').style.display = 'inline-block'; try { const resp = await fetch('login.php', { @@ -77,352 +107,458 @@ function setupLoginHandlers() { }); const data = await resp.json().catch(() => ({})); - if (!resp.ok) { - throw new Error(data.error || `Login failed (${resp.status})`); + throw new Error(data.error || 'Login failed'); } hideLogin(); - - // Reload file browser after login - loadFiles(state.currentType); + initApp(); } catch (ex) { - err.textContent = ex.message || 'Login failed'; - err.style.display = 'block'; + $('loginError').textContent = ex.message || 'Login failed'; + $('loginError').style.display = 'block'; } finally { - spinner.style.display = 'none'; + $('loginSpinner').style.display = 'none'; } }); - logoutBtn.addEventListener('click', async () => { + $('logoutBtn').addEventListener('click', async () => { try { await fetch('logout.php', { method: 'POST', credentials: 'same-origin' }); - } catch (_) { - // ignore - } + } catch (_) {} + + state.step = 1; + state.currentType = null; + state.currentNode = null; + state.currentPathParts = []; + state.selectedFilePath = null; + state.filesByType = { videos: null, music: null }; + updateWizardStep(); + showLibraryPicker(); + $('fileBrowser').innerHTML = '
Choose a library to browse files.
'; + showLogin('Logged out.'); - // Clear UI - document.getElementById('fileBrowser').innerHTML = ''; - document.getElementById('playerContainer').innerHTML = ` + $('playerContainer').innerHTML = `Select a file to start playing
${data.error}
`; - return; + logMediaDebug({ + type, + source: 'network', + url, + status: resp.status, + cacheStatus: debugHeaders.cacheStatus, + backendMs: debugHeaders.backendMs, + frontendFetchMs: fetchedAt - startedAt, + frontendParseMs: parsedAt - fetchedAt, + forceRefresh: debugHeaders.forceRefresh, + cacheGeneratedAt: debugHeaders.cacheGeneratedAt, + error: data.error + }); + return false; } - - if (data.message) { - browser.innerHTML = `${data.message}
`; - return; + + const normalizedTree = data.message ? {} : data; + state.filesByType[type] = normalizedTree; + hideLogin(); + + let payloadBytes = null; + try { + payloadBytes = JSON.stringify(data).length; + } catch (_) { + payloadBytes = null; } - - state.filesData = data; - renderFileTree(data); + + logMediaDebug({ + type, + source: 'network', + url, + status: resp.status, + cacheStatus: debugHeaders.cacheStatus, + backendMs: debugHeaders.backendMs, + frontendFetchMs: fetchedAt - startedAt, + frontendParseMs: parsedAt - fetchedAt, + payloadBytes, + forceRefresh: debugHeaders.forceRefresh, + cacheGeneratedAt: debugHeaders.cacheGeneratedAt + }); + + return true; } catch (error) { console.error('Error loading files:', error); - browser.innerHTML = `Error loading files: ${error.message}
`; + logMediaDebug({ + type, + source: 'network', + url, + frontendFetchMs: performance.now() - startedAt, + error: error.message || String(error) + }); + + if (error.message !== 'Unauthorized') { + browser.innerHTML = 'Error loading files
'; + } + return false; } } -function setActiveFileItem(fileItemEl) { - // Clear existing "active" state - document.querySelectorAll('.file-item').forEach(f => f.classList.remove('active')); - - // Set new "active" - if (fileItemEl) { - fileItemEl.classList.add('active'); - fileItemEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - - state.currentItemEl = fileItemEl || null; +function getNodeAtPath(type, pathParts) { + let node = state.filesByType[type] || {}; + for (const part of pathParts) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + return {}; + } + const next = node[part]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + return {}; + } + node = next; + } + return node; } -/** - * Finds the "next" file-item in UI order, preferring the same folder/div - * the current item is in. If there's no next sibling, it walks up the DOM - * and continues searching. - */ -function findNextFileItem(currentEl) { - if (!currentEl) return null; +function renderCurrentDirectory() { + const renderStartedAt = performance.now(); + const browser = $('fileBrowser'); + const node = state.currentNode; - // 1) Prefer "next siblings" within the same container - let n = currentEl.nextElementSibling; - while (n) { - if (n.classList && n.classList.contains('file-item')) return n; - n = n.nextElementSibling; - } + updateWizardStep(); + updateWizardPath(); - // 2) If none, walk upward and look for the next file-item after the - // parent container (e.g., next folder section) - let parent = currentEl.parentElement; - while (parent) { - // Stop at the file browser root - if (parent.id === 'fileBrowser') break; + browser.innerHTML = ''; - let pNext = parent.nextElementSibling; - while (pNext) { - // If the next sibling is a file-item, play it - if (pNext.classList && pNext.classList.contains('file-item')) return pNext; + if (!node || typeof node !== 'object' || Array.isArray(node)) { + browser.innerHTML = 'No entries found.
'; + return; + } - // If it's a folder, search inside it for the first file-item - const inside = pNext.querySelector && pNext.querySelector('.file-item'); - if (inside) return inside; + const entries = Object.entries(node); + if (entries.length === 0) { + browser.innerHTML = 'No media files found.
'; + return; + } - pNext = pNext.nextElementSibling; - } + for (const [name, content] of entries) { + const item = document.createElement('div'); + item.className = 'file-item'; - parent = parent.parentElement; - } + if (typeof content === 'object' && !Array.isArray(content)) { + item.classList.add('folder-entry'); + item.innerHTML = `${name}`; + item.addEventListener('click', () => { + state.currentPathParts.push(name); + state.currentNode = content; + renderCurrentDirectory(); + }); + } else if (typeof content === 'string') { + item.dataset.kind = 'file'; + item.dataset.path = content; + item.dataset.name = name; + item.innerHTML = `${name}`; - // No next item found - return null; + if (content === state.selectedFilePath) { + item.classList.add('active'); + } + + item.addEventListener('click', () => { + navigateToPlayback(content); + }); + } else { + continue; + } + + browser.appendChild(item); + } + + logMediaDebug({ + type: state.currentType, + source: 'memory', + renderMs: performance.now() - renderStartedAt + }); +} + +function navigateToFile(filePath) { + if (!state.currentType) return; + + const parts = filePath.split('/').filter(Boolean); + const rootName = state.currentType === 'videos' ? 'Videos' : 'Music'; + if (parts[0] && parts[0].toLowerCase() === rootName.toLowerCase()) { + parts.shift(); + } + + const fileName = parts.pop(); + if (!fileName) return; + + let node = state.filesByType[state.currentType] || {}; + const folderParts = []; + + for (const part of parts) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + break; + } + const next = node[part]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + break; + } + folderParts.push(part); + node = next; + } + + state.currentPathParts = folderParts; + state.currentNode = node; + + const fileValue = node[fileName]; + if (typeof fileValue === 'string') { + state.selectedFilePath = fileValue; + playFile(fileValue, fileName); + requestAnimationFrame(() => { + renderCurrentDirectory(); + }); + return; + } + + renderCurrentDirectory(); +} + +function updateWizardStep() { + const stepText = { + 1: 'Step 1 - Choose Library', + 2: 'Step 2 - Browse', + 3: 'Step 3 - Play' + }[state.step] || 'Step 1 - Choose Library'; + $('wizardStep').textContent = stepText; +} + +function showLibraryPicker() { + state.step = 1; + updateWizardStep(); + $('libraryPicker').style.display = 'grid'; + $('browserStep').style.display = 'none'; + document.querySelectorAll('[data-library]').forEach(btn => btn.classList.remove('active')); +} + +function showBrowserStep() { + state.step = 2; + updateWizardStep(); + $('libraryPicker').style.display = 'none'; + $('browserStep').style.display = 'grid'; +} + +function updateWizardPath() { + if (!state.currentType) return; + + const label = LIBRARY_LABELS[state.currentType] || state.currentType; + const path = state.currentPathParts.length ? `/${state.currentPathParts.join('/')}` : '/'; + + $('currentLibraryLabel').textContent = label; + $('breadcrumb').textContent = path; + $('backBtn').disabled = state.currentPathParts.length === 0; + $('rootBtn').disabled = state.currentPathParts.length === 0; +} + +function setLibraryButtonActive(type) { + document.querySelectorAll('[data-library]').forEach(btn => { + btn.classList.toggle('active', btn.dataset.library === type); + }); +} + +function navigateToPlayback(path) { + const params = new URLSearchParams(); + params.set('play', path); + + const currentParams = new URLSearchParams(window.location.search); + if (currentParams.get('debug') === '1') { + params.set('debug', '1'); + } + + window.location.href = `index.php?${params.toString()}`; +} + +function playFile(path, name) { + const container = $('playerContainer'); + const ext = path.toLowerCase().split('.').pop(); + const isVideo = VIDEO_EXTENSIONS.includes(ext); + + const media = document.createElement(isVideo ? 'video' : 'audio'); + media.controls = true; + media.autoplay = true; + media.src = `serve_media.php?file=${encodeURIComponent(path)}`; + + media.addEventListener('error', () => { + container.innerHTML = ` +Error playing: ${name}
+Error playing: ${name}
-- ${mediaElement.error?.message || 'Unknown error'} -
-
+