video_press/app.js
2026-03-09 17:42:26 -05:00

696 lines
24 KiB
JavaScript

/**
* 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 = '<p class="browser-loading" aria-live="polite">Loading…</p>';
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 += `
<button class="browser-item parent-dir" data-path="${escHtml(data.parent)}" data-is-dir="true">
<span class="item-icon" aria-hidden="true">↑</span>
<span>.. (parent directory)</span>
</button>`;
}
if (data.entries.length === 0 && !data.parent) {
html += '<p class="browser-loading">No accessible directories found.</p>';
}
for (const entry of data.entries) {
if (!entry.is_dir) continue;
html += `
<button class="browser-item" data-path="${escHtml(entry.path)}" data-is-dir="true"
role="option" aria-label="Directory: ${escHtml(entry.name)}">
<span class="item-icon" aria-hidden="true">📁</span>
<span>${escHtml(entry.name)}</span>
</button>`;
}
if (html === '') {
html = '<p class="browser-loading">No subdirectories found.</p>';
}
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 = `<p class="browser-error" role="alert">Error: ${escHtml(err.message)}</p>`;
}
}
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 = '<span class="btn-icon-prefix" aria-hidden="true">⊙</span> 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';
const pathDir = f.path.replace(f.name, '');
html += `
<tr id="row-${idx}" data-path="${escHtml(f.path)}">
<td class="col-check">
<input
type="checkbox"
class="file-checkbox"
id="chk-${idx}"
data-path="${escHtml(f.path)}"
aria-label="Select ${escHtml(f.name)} for compression"
/>
</td>
<td class="col-name">
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
<span class="file-name">${escHtml(f.name)}</span>
<span class="file-path" title="${escHtml(f.path)}">${escHtml(pathDir)}</span>
</label>
</td>
<td class="col-size">
<strong>${sizeFmt}</strong> GB
</td>
<td class="col-bitrate">
<span class="bitrate-badge">${escHtml(curBitrate)}</span>
</td>
<td class="col-target">
<span class="bitrate-badge target">${escHtml(tgtBitrate)}</span>
</td>
<td class="col-codec">
<span class="codec-tag">${escHtml(codec)}</span>
</td>
</tr>`;
});
els.fileTbody.innerHTML = html;
// Attach change events
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.addEventListener('change', () => {
const path = chk.dataset.path;
const row = chk.closest('tr');
if (chk.checked) {
state.selectedPaths.add(path);
row.classList.add('selected');
} else {
state.selectedPaths.delete(path);
row.classList.remove('selected');
}
updateSelectionUI();
});
});
updateSelectionUI();
}
function updateSelectionUI() {
const total = state.scannedFiles.length;
const sel = state.selectedPaths.size;
els.selectionSummary.textContent = `${sel} of ${total} selected`;
els.compressBtn.disabled = sel === 0;
}
els.selectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = true;
state.selectedPaths.add(chk.dataset.path);
chk.closest('tr').classList.add('selected');
});
updateSelectionUI();
announce(`All ${state.scannedFiles.length} files selected.`);
});
els.deselectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = false;
chk.closest('tr').classList.remove('selected');
});
state.selectedPaths.clear();
updateSelectionUI();
announce('All files deselected.');
});
// ─── Start Compression ────────────────────────────────────────────────────────
els.compressBtn.addEventListener('click', async () => {
const selectedFiles = state.scannedFiles.filter(f => state.selectedPaths.has(f.path));
if (selectedFiles.length === 0) return;
const suffix = els.suffixInput.value.trim() || '_new';
const payload = {
files: selectedFiles.map(f => ({
path: f.path,
size_bytes: f.size_bytes,
target_bit_rate_bps: f.target_bit_rate_bps || 1000000,
})),
suffix,
};
els.compressBtn.disabled = true;
els.compressBtn.textContent = 'Starting…';
try {
const resp = await fetch('/api/compress/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) {
alert(`Failed to start compression: ${data.error}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
return;
}
state.currentJobId = data.job_id;
state.compressionResults = [];
// Show progress section
setupProgressSection(selectedFiles);
els.sectionProgress.hidden = false;
els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' });
announce(`Compression started for ${selectedFiles.length} file(s).`);
// Start SSE stream
startProgressStream(data.job_id, selectedFiles);
} catch (err) {
alert(`Error: ${err.message}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
}
});
// ─── Progress Setup ───────────────────────────────────────────────────────────
function setupProgressSection(files) {
els.progTotal.textContent = files.length;
els.progDone.textContent = '0';
els.progStatus.textContent = 'Running';
setOverallProgress(0);
// Create per-file items
let html = '';
files.forEach((f, idx) => {
html += `
<div class="file-progress-item" id="fpi-${idx}" role="listitem"
aria-label="File ${idx+1} of ${files.length}: ${escHtml(f.name)}">
<div class="fp-header">
<span class="fp-name">${escHtml(f.name)}</span>
<span class="fp-status waiting" id="fps-${idx}" aria-live="polite">Waiting</span>
</div>
<div class="fp-bar-wrap" aria-label="Progress for ${escHtml(f.name)}">
<div class="fp-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
id="fpbar-${idx}" aria-label="${escHtml(f.name)} progress">
<div class="fp-bar-fill" id="fpfill-${idx}" style="width:0%"></div>
</div>
<span class="fp-pct" id="fppct-${idx}" aria-hidden="true">0%</span>
</div>
<div class="fp-detail" id="fpdetail-${idx}"></div>
</div>`;
});
els.fileProgressList.innerHTML = html;
}
function setOverallProgress(pct) {
const p = Math.round(pct);
els.overallBarFill.style.width = `${p}%`;
els.overallBar.setAttribute('aria-valuenow', p);
els.overallPct.textContent = `${p}%`;
}
function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) {
const fill = $(`fpfill-${idx}`);
const bar = $(`fpbar-${idx}`);
const pctEl = $(`fppct-${idx}`);
const status = $(`fps-${idx}`);
const item = $(`fpi-${idx}`);
const det = $(`fpdetail-${idx}`);
if (!fill) return;
const p = Math.round(pct);
fill.style.width = `${p}%`;
bar.setAttribute('aria-valuenow', p);
pctEl.textContent = `${p}%`;
status.className = `fp-status ${statusClass}`;
status.textContent = statusText;
item.className = `file-progress-item ${statusClass}`;
// Toggle animation on bar fill
fill.classList.toggle('active', statusClass === 'running');
if (detail !== undefined) {
det.textContent = detail;
det.className = `fp-detail ${detailClass || ''}`;
}
}
// ─── SSE Stream Handling ──────────────────────────────────────────────────────
function startProgressStream(jobId, files) {
if (state.eventSource) {
state.eventSource.close();
}
state.eventSource = new EventSource(`/api/compress/progress/${jobId}`);
let doneCount = 0;
state.eventSource.onmessage = (evt) => {
let data;
try { data = JSON.parse(evt.data); }
catch { return; }
switch (data.type) {
case 'start':
els.progStatus.textContent = 'Running';
break;
case 'file_start':
updateFileProgress(data.index, 0, 'running', 'Compressing…', '', '');
// Scroll to active item
const activeItem = $(`fpi-${data.index}`);
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
announce(`Compressing file ${data.index + 1} of ${data.total}: ${files[data.index]?.name || ''}`);
break;
case 'progress': {
const pct = data.percent || 0;
let detail = '';
if (data.elapsed_secs > 0 && data.duration_secs > 0) {
detail = `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}`;
}
updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, '');
// Update overall progress
const overallPct = ((doneCount + (pct / 100)) / files.length) * 100;
setOverallProgress(overallPct);
break;
}
case 'file_done': {
doneCount++;
els.progDone.textContent = doneCount;
const detail = data.reduction_pct
? `Saved ${data.reduction_pct}% → ${data.output_size_gb} GB`
: 'Complete';
updateFileProgress(data.index, 100, 'done', '✓ Done', detail, 'success');
setOverallProgress((doneCount / files.length) * 100);
state.compressionResults.push({ ...data, status: 'done' });
announce(`File complete: ${files[data.index]?.name}. Saved ${data.reduction_pct}%.`);
break;
}
case 'file_error': {
doneCount++;
els.progDone.textContent = doneCount;
updateFileProgress(data.index, 0, 'error', '✗ Error', data.message, 'error');
state.compressionResults.push({ ...data, status: 'error' });
announce(`Error compressing file ${files[data.index]?.name}: ${data.message}`);
break;
}
case 'done':
state.eventSource.close();
els.progStatus.textContent = 'Complete';
setOverallProgress(100);
els.cancelBtn.disabled = true;
announce('All compression operations complete.');
showResults('done');
break;
case 'cancelled':
state.eventSource.close();
els.progStatus.textContent = 'Cancelled';
announce('Compression cancelled.');
showResults('cancelled');
break;
case 'error':
state.eventSource.close();
els.progStatus.textContent = 'Error';
announce(`Compression error: ${data.message}`);
break;
}
};
state.eventSource.onerror = () => {
if (state.eventSource.readyState === EventSource.CLOSED) return;
console.error('SSE connection error');
};
}
// ─── Cancel ───────────────────────────────────────────────────────────────────
els.cancelBtn.addEventListener('click', async () => {
if (!state.currentJobId) return;
const confirmed = window.confirm(
'Cancel all compression operations? Any files currently being processed will be deleted.'
);
if (!confirmed) return;
els.cancelBtn.disabled = true;
els.cancelBtn.textContent = 'Cancelling…';
try {
await fetch(`/api/compress/cancel/${state.currentJobId}`, { method: 'POST' });
announce('Cancellation requested.');
} catch (err) {
console.error('Cancel error:', err);
}
});
// ─── Results ──────────────────────────────────────────────────────────────────
function showResults(finalStatus) {
const results = state.compressionResults;
let html = '';
if (finalStatus === 'cancelled') {
html += `<p style="color:var(--text-muted); margin-bottom: var(--space-md)">
Compression was cancelled. Any completed files are listed below.
</p>`;
}
if (results.length === 0 && finalStatus === 'cancelled') {
html += '<p style="color:var(--text-muted)">No files were completed before cancellation.</p>';
}
results.forEach(r => {
if (r.status === 'done') {
html += `
<div class="result-row">
<span class="result-icon">✅</span>
<div class="result-info">
<div class="result-name">${escHtml(r.filename)}</div>
<div class="result-meta">→ ${escHtml(r.output || '')}</div>
</div>
<span class="result-reduction">-${r.reduction_pct}%</span>
</div>`;
} else if (r.status === 'error') {
html += `
<div class="result-row">
<span class="result-icon">❌</span>
<div class="result-info">
<div class="result-name">${escHtml(r.filename)}</div>
<div class="result-meta" style="color:var(--text-danger)">${escHtml(r.message)}</div>
</div>
</div>`;
}
});
if (html === '') {
html = '<p style="color:var(--text-muted)">No results to display.</p>';
}
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 = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();