/** * VideoPress — Frontend Application * Handles all UI interactions, API calls, and SSE progress streaming. * WCAG 2.2 compliant, fully functional, no stubs. */ 'use strict'; // ─── State ────────────────────────────────────────────────────────────────── const state = { scannedFiles: [], // enriched file objects from API selectedPaths: new Set(), // paths of selected files currentJobId: null, eventSource: null, compressionResults: [], browserPath: '/', }; // ─── DOM References ────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); const els = { // Config section dirInput: $('dir-input'), browseBtn: $('browse-btn'), minSizeInput: $('min-size-input'), suffixInput: $('suffix-input'), scanBtn: $('scan-btn'), scanStatus: $('scan-status'), // Browser modal browserModal: $('browser-modal'), browserList: $('browser-list'), browserPath: $('browser-current-path'), closeBrowser: $('close-browser'), browserCancel: $('browser-cancel'), browserSelect: $('browser-select'), // Files section sectionFiles: $('section-files'), selectAllBtn: $('select-all-btn'), deselectAllBtn: $('deselect-all-btn'), selectionSummary: $('selection-summary'), fileTbody: $('file-tbody'), compressBtn: $('compress-btn'), // Progress section sectionProgress: $('section-progress'), progTotal: $('prog-total'), progDone: $('prog-done'), progStatus: $('prog-status'), overallBar: $('overall-bar'), overallBarFill: $('overall-bar-fill'), overallPct: $('overall-pct'), fileProgressList: $('file-progress-list'), cancelBtn: $('cancel-btn'), // Results sectionResults: $('section-results'), resultsContent: $('results-content'), restartBtn: $('restart-btn'), // Theme themeToggle: $('theme-toggle'), themeIcon: $('theme-icon'), // Screen reader announce srAnnounce: $('sr-announce'), }; // ─── Accessibility Helper ───────────────────────────────────────────────────── function announce(msg) { els.srAnnounce.textContent = ''; requestAnimationFrame(() => { els.srAnnounce.textContent = msg; }); } // ─── Theme Management ───────────────────────────────────────────────────────── function initTheme() { const saved = localStorage.getItem('vp-theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const theme = saved || (prefersDark ? 'dark' : 'light'); applyTheme(theme); } function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); els.themeIcon.textContent = theme === 'dark' ? '☀' : '◑'; els.themeToggle.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`); localStorage.setItem('vp-theme', theme); } els.themeToggle.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme') || 'light'; applyTheme(current === 'dark' ? 'light' : 'dark'); }); // ─── Directory Browser ──────────────────────────────────────────────────────── async function loadBrowserPath(path) { els.browserList.innerHTML = '
Loading…
'; els.browserPath.textContent = path; try { const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`); if (!resp.ok) throw new Error((await resp.json()).error || 'Error loading directory'); const data = await resp.json(); state.browserPath = data.current; els.browserPath.textContent = data.current; let html = ''; // Parent directory link if (data.parent !== null) { html += ` `; } if (data.entries.length === 0 && !data.parent) { html += 'No accessible directories found.
'; } for (const entry of data.entries) { if (!entry.is_dir) continue; html += ` `; } if (html === '') { html = 'No subdirectories found.
'; } els.browserList.innerHTML = html; // Attach click events els.browserList.querySelectorAll('.browser-item').forEach(item => { item.addEventListener('click', () => loadBrowserPath(item.dataset.path)); item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadBrowserPath(item.dataset.path); } }); }); } catch (err) { els.browserList.innerHTML = `Error: ${escHtml(err.message)}
`; } } function openBrowserModal() { els.browserModal.hidden = false; document.body.style.overflow = 'hidden'; loadBrowserPath(els.dirInput.value || '/'); // Focus trap els.closeBrowser.focus(); announce('Directory browser opened'); } function closeBrowserModal() { els.browserModal.hidden = true; document.body.style.overflow = ''; els.browseBtn.focus(); announce('Directory browser closed'); } els.browseBtn.addEventListener('click', openBrowserModal); els.closeBrowser.addEventListener('click', closeBrowserModal); els.browserCancel.addEventListener('click', closeBrowserModal); els.browserSelect.addEventListener('click', () => { els.dirInput.value = state.browserPath; closeBrowserModal(); announce(`Directory selected: ${state.browserPath}`); }); // Close modal on backdrop click els.browserModal.addEventListener('click', (e) => { if (e.target === els.browserModal) closeBrowserModal(); }); // Keyboard: close on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !els.browserModal.hidden) { closeBrowserModal(); } }); // ─── Scan for Files ─────────────────────────────────────────────────────────── els.scanBtn.addEventListener('click', async () => { const directory = els.dirInput.value.trim(); const minSize = parseFloat(els.minSizeInput.value); if (!directory) { showScanStatus('Please enter a directory path.', 'error'); els.dirInput.focus(); return; } if (isNaN(minSize) || minSize <= 0) { showScanStatus('Please enter a valid minimum size greater than 0.', 'error'); els.minSizeInput.focus(); return; } els.scanBtn.disabled = true; els.scanBtn.textContent = '⟳ Scanning…'; showScanStatus('Scanning directory, please wait…', 'info'); announce('Scanning directory for video files, please wait.'); // Hide previous results els.sectionFiles.hidden = true; try { const resp = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory, min_size_gb: minSize }), }); const data = await resp.json(); if (!resp.ok) { showScanStatus(`Error: ${data.error}`, 'error'); announce(`Scan failed: ${data.error}`); return; } state.scannedFiles = data.files; state.selectedPaths.clear(); if (data.files.length === 0) { showScanStatus( `No video files larger than ${minSize} GB found in that directory.`, 'warn' ); announce('No video files found matching your criteria.'); return; } showScanStatus(`Found ${data.files.length} file(s).`, 'success'); announce(`Scan complete. Found ${data.files.length} video files.`); renderFileTable(data.files); els.sectionFiles.hidden = false; els.sectionFiles.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { showScanStatus(`Network error: ${err.message}`, 'error'); announce(`Scan error: ${err.message}`); } finally { els.scanBtn.disabled = false; els.scanBtn.innerHTML = ' Scan for Files'; } }); function showScanStatus(msg, type) { els.scanStatus.textContent = msg; els.scanStatus.style.color = type === 'error' ? 'var(--text-danger)' : type === 'success' ? 'var(--text-success)' : type === 'warn' ? 'var(--accent)' : 'var(--text-muted)'; } // ─── File Table Rendering ───────────────────────────────────────────────────── function renderFileTable(files) { let html = ''; files.forEach((f, idx) => { const sizeFmt = f.size_gb.toFixed(3); const curBitrate = f.bit_rate_mbps ? `${f.bit_rate_mbps} Mbps` : 'Unknown'; const tgtBitrate = f.target_bit_rate_mbps ? `${f.target_bit_rate_mbps} Mbps` : '—'; const codec = (f.codec || 'unknown').toLowerCase(); const pathDir = f.path.replace(f.name, ''); // Normalise codec label and pick a CSS modifier for the badge colour const isHevc = ['hevc', 'h265', 'x265'].includes(codec); const isH264 = ['h264', 'avc', 'x264'].includes(codec); const codecLabel = isHevc ? 'H.265 / HEVC' : isH264 ? 'H.264 / AVC' : codec.toUpperCase(); const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : ''; html += `Compression was cancelled. Any completed files are listed below.
`; } if (results.length === 0 && finalStatus === 'cancelled') { html += 'No files were completed before cancellation.
'; } results.forEach(r => { if (r.status === 'done') { html += `No results to display.
'; } els.resultsContent.innerHTML = html; els.sectionResults.hidden = false; els.sectionResults.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // ─── Restart ────────────────────────────────────────────────────────────────── els.restartBtn.addEventListener('click', () => { // Reset state state.scannedFiles = []; state.selectedPaths.clear(); state.currentJobId = null; state.compressionResults = []; if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } // Reset UI els.sectionFiles.hidden = true; els.sectionProgress.hidden = true; els.sectionResults.hidden = true; els.fileTbody.innerHTML = ''; els.fileProgressList.innerHTML = ''; els.scanStatus.textContent = ''; els.compressBtn.innerHTML = ' Compress Selected Files'; els.compressBtn.disabled = true; els.cancelBtn.disabled = false; els.cancelBtn.textContent = '✕ Cancel Compression'; // Scroll to top document.getElementById('section-config').scrollIntoView({ behavior: 'smooth' }); els.dirInput.focus(); announce('Session reset. Ready to scan again.'); }); // ─── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function fmtTime(seconds) { const s = Math.floor(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`; return `${m}:${pad(sec)}`; } function pad(n) { return String(n).padStart(2, '0'); } // ─── Init ───────────────────────────────────────────────────────────────────── initTheme();