// Dashcam page: scan for a USB dashcam, then trigger importing its videos. // Uses plain fetch() (not socket.io / yaris.js) since this is a one-shot // request/response flow, not a live data stream, and yaris.js assumes DOM // elements (#map, #speedGraph) that don't exist on this page. const deviceStatus = document.getElementById('dash-device-status'); const importStatusBar = document.getElementById('dash-import-status-bar'); const importStatus = document.getElementById('dash-import-status'); const importEta = document.getElementById('dash-import-eta'); const importResultBar = document.getElementById('dash-import-result-bar'); const importResult = document.getElementById('dash-import-result'); const scanBtn = document.getElementById('dash-scan-btn'); const importBtn = document.getElementById('dash-import-btn'); const fileSummary = document.getElementById('dash-file-summary'); const fileListBody = document.querySelector('#dashcam-file-list [data-data-list-body]'); const filesRefreshBtn = document.getElementById('dash-files-refresh-btn'); const selectAllBtn = document.getElementById('dash-select-all-btn'); const importSelectedBtn = document.getElementById('dash-import-selected-btn'); const deleteAllBtn = document.getElementById('dash-delete-all-btn'); let importInProgress = false; let importStartTime = null; let importTotal = 0; let importEtaInterval = null; let importPollInterval = null; function showImportProgress(msg, total) { importTotal = total || 0; importStartTime = Date.now(); importStatus.textContent = msg; importEta.textContent = ''; importStatusBar.style.display = 'flex'; importResultBar.style.display = 'none'; clearInterval(importEtaInterval); if (importTotal > 0) { importEtaInterval = setInterval(() => { const elapsed = (Date.now() - importStartTime) / 1000; const estTotal = importTotal * 3; const remaining = Math.max(0, Math.round(estTotal - elapsed)); importEta.textContent = remaining > 0 ? `~${remaining}s remaining` : ''; }, 1000); } } function showImportResult(msg) { clearInterval(importEtaInterval); clearInterval(importPollInterval); importPollInterval = null; importStatusBar.style.display = 'none'; importResult.textContent = msg; importResultBar.style.display = 'block'; setTimeout(() => { importResultBar.style.display = 'none'; }, 10000); } function fetchJson(url, options) { return fetch(url, options).then(response => response.json().then(data => { if (!response.ok) { const details = data.errors && data.errors.length ? data.errors.join('; ') : null; throw new Error(data.message || details || data.status || `HTTP ${response.status}`); } return data; })); } function createCell(text) { const cell = document.createElement('td'); cell.textContent = text; return cell; } function selectedPaths() { return Array.from(fileListBody.querySelectorAll('[data-select-path]:checked')) .map(checkbox => checkbox.dataset.selectPath); } function updateSelectionControls() { const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]')); const selectedCount = selectedPaths().length; importSelectedBtn.disabled = importInProgress || selectedCount === 0; selectAllBtn.disabled = checkboxes.length === 0; selectAllBtn.textContent = checkboxes.length > 0 && selectedCount === checkboxes.length ? 'Clear selection' : 'Select all'; importSelectedBtn.innerHTML = selectedCount ? `Import selected (${selectedCount})` : 'Import selected'; } function setImportBusy(busy) { importInProgress = busy; importBtn.disabled = busy; fileListBody.querySelectorAll('[data-import-path]').forEach(button => { button.disabled = busy; }); updateSelectionControls(); } function renderDashcamFiles(summary) { fileListBody.replaceChildren(); if (summary.status !== 'ok') { fileSummary.textContent = summary.message || `Unable to list files: ${summary.status}`; deleteAllBtn.disabled = true; return; } const files = summary.files || []; files.forEach(file => { const row = document.createElement('tr'); row.dataset.path = file.path; row.dataset.size = file.size; row.dataset.modified = file.modified_at; row.className = 'dash-row'; const selection = document.createElement('td'); selection.className = 'dash-col-check'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'form-check-input'; checkbox.dataset.selectPath = file.path; checkbox.setAttribute('aria-label', `Select ${file.path}`); selection.appendChild(checkbox); row.appendChild(selection); const pathCell = createCell(file.path); pathCell.className = 'dash-col-file'; row.appendChild(pathCell); const sizeCell = createCell(YarisUI.formatBytes(file.size)); sizeCell.className = 'dash-col-size'; row.appendChild(sizeCell); const dateCell = createCell(new Date(file.modified_at * 1000).toLocaleString()); dateCell.className = 'dash-col-date'; row.appendChild(dateCell); const actionsTd = document.createElement('td'); actionsTd.className = 'dash-actions-td'; const actionsDiv = document.createElement('div'); actionsDiv.className = 'data-list-actions'; const importButton = document.createElement('button'); importButton.type = 'button'; importButton.className = 'btn btn-sm btn-outline-primary btn-icon-mobile'; importButton.dataset.importPath = file.path; importButton.innerHTML = ' Import'; actionsDiv.appendChild(importButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'btn btn-sm btn-outline-danger btn-icon-mobile'; deleteButton.dataset.deletePath = file.path; deleteButton.dataset.confirm = `Permanently delete "${file.path}" from the dashcam?`; deleteButton.innerHTML = ' Delete'; actionsDiv.appendChild(deleteButton); actionsTd.appendChild(actionsDiv); row.appendChild(actionsTd); fileListBody.appendChild(row); }); fileSummary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(summary.total_size || 0)} total`; if (summary.errors && summary.errors.length) { fileSummary.textContent += ` (${summary.errors.length} file error(s))`; } deleteAllBtn.disabled = files.length === 0; updateSelectionControls(); } function refreshDashcamFiles() { fileSummary.textContent = 'Scanning...'; filesRefreshBtn.disabled = true; return fetchJson('/api/dash/files') .then(renderDashcamFiles) .catch(err => { fileListBody.replaceChildren(); fileSummary.textContent = `Unable to list dashcam files: ${err.message}`; deleteAllBtn.disabled = true; updateSelectionControls(); }) .finally(() => { filesRefreshBtn.disabled = false; }); } function deleteDashcamFile(path) { return fetchJson('/api/dash/file', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }).then(result => { fileSummary.textContent = `Deleted ${result.deleted_count} video(s). Refreshing...`; return refreshDashcamFiles(); }).catch(err => { fileSummary.textContent = `Delete failed: ${err.message}`; }); } function describeImportResult(job) { let text = `Copied ${job.copied}, skipped ${job.skipped} (already imported).`; if (job.errors && job.errors.length) { text += ` Errors: ${job.errors.join('; ')}`; } return text; } function pollImportStatus() { fetch('/api/dash/import/status') .then(r => r.json()) .then(job => { if (!job || job.status === 'none' || job.status === 'running') return; clearInterval(importPollInterval); importPollInterval = null; setImportBusy(false); if (job.status === 'ok') { showImportResult(describeImportResult(job)); refreshDashcamFiles(); } else { showImportResult(job.message || `Import failed: ${job.status}`); } }) .catch(() => {}); // ignore transient network errors while polling } function startImport(paths) { const label = paths ? `Importing ${paths.length} video(s)…` : 'Importing all videos… this can take a while.'; showImportProgress(label, paths ? paths.length : 0); setImportBusy(true); const body = paths ? JSON.stringify({ paths }) : undefined; fetch('/api/dash/import', { method: 'POST', headers: body ? { 'Content-Type': 'application/json' } : {}, body, }) .then(r => r.json()) .then(data => { if (data.status === 'busy') { showImportResult(data.message || 'Import already in progress.'); setImportBusy(false); return; } if (data.status !== 'started') { showImportResult(data.message || `Import error: ${data.status}`); setImportBusy(false); return; } clearInterval(importPollInterval); importPollInterval = setInterval(pollImportStatus, 3000); }) .catch(err => { showImportResult(`Import request failed: ${err}`); setImportBusy(false); }); } function describeDevice(device) { const label = device.label ? ` "${device.label}"` : ''; return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`; } function scanForDashcam() { deviceStatus.textContent = 'Scanning...'; importBtn.disabled = true; fetch('/api/dash/devices') .then(response => response.json()) .then(data => { const devices = data.devices || []; if (devices.length === 0) { deviceStatus.textContent = 'No USB dashcam/storage device detected. Plug it in and scan again.'; return; } deviceStatus.textContent = 'Detected: ' + devices.map(describeDevice).join(', '); importBtn.disabled = false; }) .catch(err => { deviceStatus.textContent = 'Error while scanning: ' + err; }); } scanBtn.addEventListener('click', scanForDashcam); importBtn.addEventListener('click', () => startImport(null)); filesRefreshBtn.addEventListener('click', refreshDashcamFiles); fileListBody.addEventListener('click', event => { const importButton = event.target.closest('[data-import-path]'); if (importButton) { startImport([importButton.dataset.importPath]); return; } const deleteButton = event.target.closest('[data-delete-path]'); if (deleteButton) { deleteButton.disabled = true; deleteDashcamFile(deleteButton.dataset.deletePath).finally(() => { deleteButton.disabled = false; }); } }); fileListBody.addEventListener('change', event => { if (event.target.matches('[data-select-path]')) { updateSelectionControls(); } }); importSelectedBtn.addEventListener('click', () => { const paths = selectedPaths(); if (paths.length) { startImport(paths); } }); selectAllBtn.addEventListener('click', () => { const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]')); const shouldSelect = checkboxes.some(checkbox => !checkbox.checked); checkboxes.forEach(checkbox => { checkbox.checked = shouldSelect; }); updateSelectionControls(); }); deleteAllBtn.addEventListener('click', () => { if (!window.confirm(deleteAllBtn.dataset.confirm)) { return; } deleteAllBtn.disabled = true; fetchJson('/api/dash/files', { method: 'DELETE' }) .then(result => { fileSummary.textContent = `Deleted ${result.deleted_count} video(s). Refreshing...`; return refreshDashcamFiles(); }) .catch(err => { fileSummary.textContent = `Delete all failed: ${err.message}`; deleteAllBtn.disabled = false; }); }); // Check if an import is already running when page loads (e.g. after refresh mid-import) fetch('/api/dash/import/status') .then(r => r.json()) .then(job => { if (job && job.status === 'running') { showImportProgress('Import in progress…', 0); setImportBusy(true); importPollInterval = setInterval(pollImportStatus, 3000); } }) .catch(() => {}); // Auto-scan once when the page loads. scanForDashcam(); refreshDashcamFiles(); // --- Remote server upload status/toggle ---