Files
peji e7dee4f5db Remove fisheye dewarp feature
Remove the per-camera fisheye dewarp tool: the WebGL dewarp module
(shaders, init/render/toggle, per-channel dewarpState), the Dewarp
button, its CSS, the dewarp branches interleaved into the shared
wheel/dblclick/mousedown/mousemove/mouseup handlers, and the WebGL
context cleanup in newSession(). Normal digital zoom/pan, drag-reorder,
magnifier, and expand are unaffected. Also drop the dewarp mention from
the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:39:12 -04:00

3770 lines
141 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; media-src blob:; img-src 'self' data:;">
<title>Alta Video Player</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23121826'/><polygon points='38,25 38,75 78,50' fill='%23006ED7'/></svg>">
<script src="/static/jszip.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Backgrounds */
--bg-app: #121826;
--bg-panel: #121826;
--bg-surface: #181F32;
--bg-surface-hover: rgba(0, 110, 215, 0.12);
--bg-surface-active: rgba(0, 110, 215, 0.20);
--bg-input: rgba(0, 110, 215, 0.10);
--bg-button: rgba(0, 110, 215, 0.20);
--bg-button-hover: rgba(0, 110, 215, 0.32);
/* Borders */
--border-default: rgba(244, 244, 246, 0.12);
--border-light: #EBEEF0;
--border-subtle: rgba(244, 244, 246, 0.12);
/* Text */
--text-primary: #F4F4F6;
--text-secondary: #656972;
--text-muted: #8D9399;
--text-on-accent: #FFFFFF;
/* Accent */
--accent-primary: #006ED7;
--accent-primary-hover: #0080F0;
--accent-primary-muted: rgba(0, 110, 215, 0.20);
/* Status */
--status-success: #20C62F;
--status-error: #DE1111;
--status-warning: #EAA301;
--status-info: #8D9399;
--status-purple: #8957E5;
/* Overlays */
--overlay-dark: rgba(18, 24, 38, 0.60);
--overlay-panel: rgba(18, 24, 38, 0.85);
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
/* Radii */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
/* Shadows */
--shadow-elevated: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-focus: 0 0 0 3px rgba(0, 110, 215, 0.25);
/* Typography */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
font-family: var(--font-family);
background: var(--bg-app);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
/* Icon Rail */
.icon-rail {
width: 72px; height: 100vh; background: var(--bg-app);
display: flex; flex-direction: column; align-items: center;
padding: 16px 0; flex-shrink: 0;
border-right: 1px solid var(--border-default);
position: fixed; left: 0; top: 0; z-index: 60;
}
.icon-rail .rail-logo {
width: 36px; height: 36px; background: var(--accent-primary);
border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center;
margin-bottom: 4px;
}
.icon-rail .rail-logo svg { width: 20px; height: 20px; fill: #fff; }
.icon-rail .rail-label {
font-size: 10px; color: var(--text-secondary); text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 20px; font-weight: 600;
}
.icon-rail .rail-sep { width: 32px; height: 1px; background: var(--border-default); margin: 8px 0; }
.icon-rail .rail-btn {
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-lg); cursor: pointer; transition: background 0.15s ease-out;
border: none; background: none; color: var(--text-secondary); margin-bottom: 4px;
}
.icon-rail .rail-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
.icon-rail .rail-btn.active { background: var(--accent-primary); color: #fff; }
.icon-rail .rail-btn svg { width: 20px; height: 20px; fill: currentColor; }
.icon-rail .rail-spacer { flex: 1; }
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-default);
flex-shrink: 0;
margin-left: 72px;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-title-badge {
width: 32px; height: 32px; background: var(--accent-primary); border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
}
.header-title-badge svg { width: 16px; height: 16px; fill: #fff; }
.header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); }
.header .header-subtitle { font-size: 12px; color: var(--text-secondary); font-weight: 400; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.header .cam-count { font-size: 12px; color: var(--text-secondary); }
/* Offset body content for rail */
.drop-zone, .player-area { margin-left: 72px; transition: margin-left 0.2s ease-out; }
.header { transition: margin-left 0.2s ease-out; }
.loading-overlay { padding-left: 72px; transition: padding-left 0.2s ease-out; }
/* Drop zone */
.drop-zone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed var(--border-subtle);
border-radius: var(--radius-lg);
margin: 32px;
transition: border-color 0.15s ease-out, background 0.15s ease-out;
cursor: pointer;
position: relative;
background: var(--bg-surface);
}
.drop-zone.drag-over { border-color: var(--accent-primary); background: var(--bg-surface-hover); }
.drop-zone.hidden { display: none; }
.drop-zone .drop-icon-wrap {
width: 80px; height: 80px; border-radius: 50%; background: var(--bg-input);
display: flex; align-items: center; justify-content: center; margin-bottom: 20px;
border: 1px solid var(--border-default);
}
.drop-zone svg { width: 36px; height: 36px; fill: var(--accent-primary); }
.drop-zone p { font-size: 16px; color: var(--text-primary); margin-bottom: 8px; font-weight: 600; }
.drop-zone small { font-size: 13px; color: var(--text-secondary); max-width: 400px; text-align: center; line-height: 1.4; }
/* Loading overlay */
.loading-overlay {
position: fixed; inset: 0; background: var(--overlay-panel);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100;
}
.loading-overlay.hidden { display: none; }
.loading-spinner {
width: 40px; height: 40px; border: 3px solid var(--border-default);
border-top-color: var(--accent-primary); border-radius: 50%;
animation: spin 0.8s linear infinite; margin-bottom: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 13px; color: var(--text-secondary); }
.loading-detail { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
/* Player area */
.player-area { flex: 1; display: none; flex-direction: column; min-height: 0; }
.player-area.active { display: flex; }
/* Camera grid — always fills available height */
.camera-grid {
flex: 1; display: grid; gap: 2px; padding: 2px; min-height: 0; background: #000;
grid-auto-rows: 1fr;
}
.camera-grid.cams-1 { grid-template-columns: 1fr; grid-template-rows: 1fr; }
.camera-grid.cams-2 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; }
.camera-grid.cams-3 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.camera-grid.cams-3 .cam-cell:first-child { grid-column: 1 / -1; }
.camera-grid.cams-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.camera-grid.cams-5 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
.camera-grid.cams-6 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
.camera-grid.cams-7 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr 1fr; }
.camera-grid.cams-8 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr 1fr; }
.camera-grid.cams-9 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr 1fr; }
.cam-cell {
position: relative; background: #000; overflow: hidden; cursor: pointer;
min-height: 0; min-width: 0;
border-left: 3px solid var(--cam-color, transparent);
}
.cam-cell video {
width: 100%; height: 100%; object-fit: contain; display: block;
position: absolute; inset: 0;
}
.cam-cell .hidden-video { display: none; }
.cam-cell.expanded {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 50;
grid-column: auto; grid-row: auto;
}
.player-area.has-expanded .controls-panel {
position: fixed; bottom: 0; left: 72px; right: 0; z-index: 51;
border-top: 1px solid var(--border-default);
}
.player-area.has-expanded .cam-cell.expanded {
bottom: 0; padding-bottom: 140px;
}
.cam-label {
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 6px;
background: linear-gradient(180deg, rgba(0,0,0,0.7) 0%, transparent 100%);
padding: 10px 12px; border-radius: 0;
font-size: 13px; font-weight: 500; z-index: 2; color: #fff;
}
.cam-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.cam-info-badge {
position: absolute; top: 10px; right: 12px;
background: transparent; padding: 3px 8px; border-radius: var(--radius-sm);
font-size: 10px; color: #fff; z-index: 2; opacity: 0.8;
}
.cam-timestamp {
position: absolute; bottom: 8px; left: 8px;
background: transparent; padding: 3px 8px; border-radius: 0;
font-size: 11px; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: #fff; z-index: 2;
}
.cam-segment-info {
position: absolute; bottom: 8px; right: 8px;
background: transparent; padding: 3px 8px; border-radius: 0;
font-size: 11px; color: var(--text-secondary); z-index: 2;
}
/* Bottom gradient overlay for video tiles */
.cam-cell::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px;
background: linear-gradient(0deg, rgba(0,0,0,0.7) 0%, transparent 100%);
z-index: 1; pointer-events: none;
}
/* Panning cursor when zoomed */
.cam-cell.zoomed-in { cursor: grab; }
.cam-cell.zoomed-in.panning { cursor: grabbing; }
/* Drag-to-reorder */
.cam-cell.drag-source { opacity: 0.4; }
.cam-cell.drag-over { outline: 2px solid var(--cam-color, var(--accent-primary)); outline-offset: -2px; }
.cam-cell .drag-handle {
position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.7); padding: 2px 8px; border-radius: 4px;
font-size: 10px; color: var(--text-secondary); z-index: 3;
cursor: grab; opacity: 0; transition: opacity 0.15s;
display: flex; align-items: center; gap: 4px;
}
.cam-cell:hover .drag-handle { opacity: 1; }
.cam-cell .drag-handle svg { width: 12px; height: 12px; fill: var(--text-secondary); }
.cam-cell .zoom-indicator {
position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.7); padding: 3px 8px; border-radius: 4px;
font-size: 10px; color: var(--accent-primary); z-index: 3;
display: none; font-family: monospace;
}
.cam-cell .zoom-indicator.visible { display: block; }
/* Magnifier selection rectangle */
.mag-selection {
position: absolute; border: 2px solid var(--accent-primary); background: rgba(0,110,215,0.12);
pointer-events: none; z-index: 4; display: none;
}
.mag-selection.active { display: block; }
/* Magnifier button */
.btn-mag { position: relative; }
.btn-mag.active { background: var(--accent-primary); color: #fff; }
.btn-mag.active:hover { background: var(--accent-primary-hover); }
/* Crosshair cursor when magnifier is active */
.cam-cell.mag-mode { cursor: crosshair; }
/* Slideshow mode */
.slideshow-container {
display: none; position: relative; flex: 1; background: #000; min-height: 0; overflow: hidden;
}
.slideshow-container.active { display: flex; }
.player-area.slideshow-mode .camera-grid { display: none; }
.slideshow-stage {
flex: 1; position: relative; min-height: 0;
}
.ss-pane {
position: absolute; inset: 0; background: #000; overflow: hidden;
animation: ssSlideIn 0.4s ease both;
}
.ss-pane.ss-exit {
animation: ssSlideOut 0.3s ease both;
pointer-events: none;
}
.ss-pane video {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.ss-pane .ss-label {
position: absolute; bottom: 16px; left: 16px;
display: flex; align-items: center; gap: 8px;
background: rgba(0,0,0,0.80); padding: 8px 16px; border-radius: 6px;
font-size: 13px; font-weight: 600; z-index: 2;
}
.ss-pane .ss-label .cam-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.ss-pane .ss-counter {
position: absolute; top: 16px; right: 16px;
background: rgba(0,0,0,0.70); padding: 4px 12px; border-radius: 4px;
font-size: 11px; color: var(--text-muted); z-index: 2;
}
@keyframes ssSlideIn {
from { opacity: 0; transform: scale(0.96) translateX(30px); }
to { opacity: 1; transform: scale(1) translateX(0); }
}
@keyframes ssSlideOut {
from { opacity: 1; transform: scale(1) translateX(0); }
to { opacity: 0; transform: scale(0.96) translateX(-30px); }
}
.btn-slideshow { position: relative; }
.btn-slideshow.active { background: var(--accent-primary); color: #fff; }
.btn-slideshow.active:hover { background: var(--accent-primary-hover); }
.no-signal {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 2px;
background: repeating-linear-gradient(0deg, #121826 0px, #121826 2px, #0e1320 2px, #0e1320 4px);
z-index: 1;
}
/* Controls panel */
.controls-panel {
background: var(--bg-panel); border-top: 1px solid var(--border-default);
padding: 8px 16px 4px; flex-shrink: 0;
}
.timeline-container { margin-bottom: 8px; }
.timeline-labels {
display: flex; justify-content: space-between;
font-size: 10px; color: var(--text-secondary); margin-bottom: 4px;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
.timeline-zoom-info {
font-size: 9px; color: var(--text-muted); text-align: center;
margin-top: 2px; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
.timeline-outer {
position: relative; border-radius: 4px; overflow: hidden;
}
.timeline-track {
position: relative; background: var(--bg-input); border-radius: var(--radius-md);
cursor: pointer; min-height: 32px; overflow: hidden;
}
.timeline-segment {
position: absolute; height: 8px; border-radius: 2px; opacity: 0.7;
transition: opacity 0.15s;
}
.timeline-segment.active-segment { opacity: 1; box-shadow: 0 0 6px currentColor, 0 0 2px currentColor; }
.timeline-gap {
position: absolute; height: 8px;
background: repeating-linear-gradient(90deg, transparent, transparent 3px, var(--border-default) 3px, var(--border-default) 6px);
opacity: 0.3;
}
.timeline-playhead {
position: absolute; top: 0; bottom: 0; width: 2px;
background: #fff; z-index: 3; pointer-events: none;
box-shadow: 0 0 4px rgba(255,255,255,0.5);
}
.timeline-hover {
position: absolute; top: -20px; transform: translateX(-50%);
background: var(--bg-surface); border: 1px solid var(--border-default); padding: 2px 6px; border-radius: var(--radius-sm);
font-size: 10px; color: var(--text-primary); display: none; z-index: 4;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
box-shadow: var(--shadow-elevated);
}
.timeline-tick {
position: absolute; top: 0; width: 1px; background: rgba(255,255,255,0.08);
z-index: 1; pointer-events: none;
}
.timeline-tick.major { background: rgba(255,255,255,0.15); }
.timeline-tick-label {
position: absolute; top: 1px; transform: translateX(-50%);
font-size: 8px; color: var(--text-muted); z-index: 2; pointer-events: none;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
white-space: nowrap;
}
/* Mini-map showing full timeline when zoomed */
.timeline-minimap {
position: relative; height: 6px; background: var(--bg-input);
border-radius: 2px; margin-top: 3px; cursor: pointer; display: none;
}
.timeline-minimap.visible { display: block; }
.timeline-minimap .minimap-seg {
position: absolute; height: 100%; border-radius: 1px; opacity: 0.5;
}
.timeline-minimap .minimap-viewport {
position: absolute; top: 0; height: 100%;
border: 1px solid rgba(255,255,255,0.7); border-radius: 2px;
background: rgba(255,255,255,0.08); z-index: 2;
}
.transport {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.transport-left, .transport-right { display: flex; align-items: center; gap: 4px; }
.transport-center { display: flex; align-items: center; gap: 4px; }
.btn {
display: flex; align-items: center; justify-content: center;
background: none; border: none; color: var(--text-secondary); cursor: pointer;
border-radius: var(--radius-md); transition: background 0.15s ease-out, color 0.15s ease-out;
}
.btn:hover { background: var(--bg-button-hover); color: var(--text-primary); }
.btn svg { fill: currentColor; }
.btn-icon { width: 36px; height: 36px; }
.btn-icon svg { width: 20px; height: 20px; }
.btn-play { width: 40px; height: 40px; background: var(--accent-primary); border-radius: 50%; color: #fff; }
.btn-play:hover { background: var(--accent-primary-hover); }
.btn-play svg { width: 20px; height: 20px; fill: #fff; }
.speed-display {
font-size: 13px; font-weight: 500; min-width: 44px; text-align: center;
padding: 4px 8px; background: var(--bg-input); border: 1px solid var(--border-default);
border-radius: var(--radius-md); font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
.speed-display.highlight { color: var(--accent-primary); }
.time-display {
font-size: 13px; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: var(--text-primary); padding: 0 8px; white-space: nowrap;
}
.kb-hints {
display: flex; justify-content: center; gap: 16px; padding: 4px 16px 8px;
font-size: 10px; color: var(--text-muted); flex-wrap: wrap;
}
.kb-hints kbd {
background: var(--bg-input); border: 1px solid var(--border-default); border-radius: var(--radius-sm);
padding: 1px 5px; font-family: inherit; font-size: 10px; color: var(--text-secondary);
}
.btn-add {
padding: 8px 16px; font-size: 13px; font-weight: 500; gap: 6px;
border: none; border-radius: var(--radius-md); background: var(--bg-button);
color: var(--text-primary); transition: background 0.15s ease-out;
}
.btn-add:hover { background: var(--bg-button-hover); }
.btn-add svg { width: 14px; height: 14px; fill: currentColor; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-panel); }
::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 3px; }
#fileInput { display: none; }
:fullscreen .header { display: none; }
:fullscreen .icon-rail { display: none; }
:fullscreen .player-area { margin-left: 0; }
:fullscreen .kb-hints { display: none; }
/* Verification badge */
.verify-badge {
display: none; align-items: center; gap: 6px; padding: 4px 10px;
border-radius: var(--radius-md); font-size: 11px; font-weight: 600; cursor: pointer;
border: 1px solid transparent; transition: background 0.15s ease-out;
}
.verify-badge.visible { display: flex; }
.verify-badge .verify-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.verify-badge.status-verifying { color: var(--text-secondary); border-color: var(--border-default); }
.verify-badge.status-verifying .verify-dot { background: var(--text-secondary); animation: pulse 1.5s ease-in-out infinite; }
.verify-badge.status-success { color: var(--status-success); border-color: rgba(32,198,47,0.3); background: rgba(32,198,47,0.1); }
.verify-badge.status-success .verify-dot { background: var(--status-success); }
.verify-badge.status-unverified { color: var(--status-warning); border-color: rgba(234,163,1,0.3); background: rgba(234,163,1,0.1); }
.verify-badge.status-unverified .verify-dot { background: var(--status-warning); }
.verify-badge.status-failed { color: var(--status-error); border-color: rgba(222,17,17,0.3); background: rgba(222,17,17,0.1); }
.verify-badge.status-failed .verify-dot { background: var(--status-error); }
.verify-badge.status-none { color: var(--text-secondary); border-color: var(--border-default); }
.verify-badge.status-none .verify-dot { background: var(--text-secondary); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
/* Verification modal */
.verify-modal-overlay {
position: fixed; inset: 0; background: var(--overlay-dark);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.verify-modal-overlay.hidden { display: none; }
.verify-modal {
background: var(--bg-app); border: 1px solid var(--border-default); border-radius: var(--radius-xl);
padding: 20px; max-width: 520px; width: 90%; max-height: 70vh; overflow-y: auto;
box-shadow: var(--shadow-elevated);
}
.verify-modal h2 { font-size: 14px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.verify-modal .verify-summary { font-size: 12px; color: var(--text-secondary); margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border-default); }
.verify-modal .verify-cert-info { font-size: 11px; color: var(--text-secondary); margin-bottom: 12px; padding: 8px; background: var(--bg-surface-hover); border-radius: 4px; }
.verify-modal .verify-cert-info span { color: var(--text-primary); }
.verify-file-list { list-style: none; }
.verify-file-list li {
display: flex; align-items: center; gap: 8px; padding: 4px 0;
font-size: 11px; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
.verify-file-list .vf-icon { flex-shrink: 0; font-size: 13px; }
.verify-file-list .vf-name { color: var(--text-primary); word-break: break-all; }
.verify-modal .close-btn {
margin-top: 16px; width: 100%; padding: 8px; background: var(--bg-button);
border: none; border-radius: var(--radius-md); color: var(--text-primary);
cursor: pointer; font-size: 13px; font-weight: 500;
transition: background 0.15s ease-out;
}
.verify-modal .close-btn:hover { background: var(--bg-button-hover); }
/* Password modal */
.password-modal-overlay {
position: fixed; inset: 0; background: var(--overlay-dark);
display: flex; align-items: center; justify-content: center; z-index: 300;
}
.password-modal-overlay.hidden { display: none; }
.password-modal {
background: var(--bg-app); border: 1px solid var(--border-default); border-radius: var(--radius-xl);
padding: 24px; max-width: 400px; width: 90%; text-align: center;
box-shadow: var(--shadow-elevated);
}
.password-modal .pw-icon { font-size: 32px; margin-bottom: 12px; }
.password-modal h2 { font-size: 14px; margin-bottom: 4px; }
.password-modal p { font-size: 12px; color: var(--text-secondary); margin-bottom: 16px; }
.password-modal .pw-error {
font-size: 11px; color: var(--status-error); margin-bottom: 8px; min-height: 16px;
}
.password-modal input[type="password"] {
width: 100%; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border-default);
border-radius: var(--radius-md); color: var(--text-primary); font-size: 14px; outline: none;
font-family: inherit; margin-bottom: 12px;
transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out;
}
.password-modal input[type="password"]:focus { border-color: var(--accent-primary); box-shadow: var(--shadow-focus); }
.password-modal .pw-buttons { display: flex; gap: 8px; }
.password-modal .pw-btn {
flex: 1; padding: 8px; border-radius: var(--radius-md); border: none;
cursor: pointer; font-size: 13px; font-weight: 500; font-family: inherit;
}
.password-modal .pw-btn-cancel { background: var(--bg-button); color: var(--text-primary); }
.password-modal .pw-btn-cancel:hover { background: var(--bg-button-hover); }
.password-modal .pw-btn-submit { background: var(--accent-primary); color: #fff; }
.password-modal .pw-btn-submit:hover { background: var(--accent-primary-hover); }
/* Log panel */
.log-panel {
position: fixed; right: 0; top: 0; width: 360px; height: 100vh;
background: var(--bg-app); border-left: 1px solid var(--border-default);
z-index: 150; display: flex; flex-direction: column;
transform: translateX(0); transition: transform 0.2s ease-out;
}
.log-panel.hidden { transform: translateX(100%); pointer-events: none; }
.log-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0;
}
.log-panel-header h2 { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.log-panel-header h2 svg { width: 16px; height: 16px; fill: var(--text-secondary); }
.log-panel-actions { display: flex; gap: 6px; }
.log-panel-actions button {
background: var(--bg-button); border: none; border-radius: var(--radius-sm);
color: var(--text-secondary); cursor: pointer; font-size: 11px; padding: 4px 10px;
font-family: inherit; transition: background 0.15s ease-out;
}
.log-panel-actions button:hover { background: var(--bg-button-hover); color: var(--text-primary); }
.log-entries {
flex: 1; overflow-y: auto; padding: 8px 12px;
scrollbar-width: thin; scrollbar-color: var(--border-default) transparent;
}
.log-entries::-webkit-scrollbar { width: 5px; }
.log-entries::-webkit-scrollbar-track { background: transparent; }
.log-entries::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 3px; }
.log-entry {
display: flex; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm);
font-size: 11px; line-height: 1.5; border-left: 3px solid transparent;
margin-bottom: 2px; word-break: break-word;
}
.log-entry:hover { background: var(--bg-surface-hover); }
.log-entry .log-time { color: var(--text-muted); white-space: nowrap; flex-shrink: 0; font-family: monospace; font-size: 10px; }
.log-entry .log-msg { color: var(--text-primary); flex: 1; }
.log-entry.log-info { border-left-color: var(--accent-primary); }
.log-entry.log-warn { border-left-color: var(--status-warning); }
.log-entry.log-warn .log-msg { color: var(--status-warning); }
.log-entry.log-error { border-left-color: var(--status-error); }
.log-entry.log-error .log-msg { color: var(--status-error); }
.log-entry.log-success { border-left-color: var(--status-success); }
.log-entry.log-success .log-msg { color: var(--status-success); }
.log-empty {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); font-size: 12px;
}
.rail-btn .log-badge {
position: absolute; top: 4px; right: 4px; width: 8px; height: 8px;
border-radius: 50%; background: var(--status-error); display: none;
}
.rail-btn { position: relative; }
/* Layout panel — pushes content */
.layout-panel {
position: fixed; left: 72px; top: 0; width: 280px; height: 100vh;
background: var(--bg-app); border-right: 1px solid var(--border-default);
z-index: 55; display: flex; flex-direction: column;
transform: translateX(0); transition: transform 0.2s ease-out;
}
.layout-panel.hidden { transform: translateX(-100%); pointer-events: none; }
/* When layout panel is open, push all content right */
body.layout-open .header,
body.layout-open .drop-zone,
body.layout-open .player-area { margin-left: 352px; }
body.layout-open .loading-overlay { padding-left: 352px; }
body.layout-open .player-area.has-expanded .controls-panel { left: 352px; }
.layout-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0;
}
.layout-panel-header h2 { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.layout-panel-header h2 svg { width: 16px; height: 16px; fill: var(--text-secondary); }
.layout-section { padding: 12px 16px; border-bottom: 1px solid var(--border-default); }
.layout-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); margin-bottom: 10px; }
.layout-presets { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; margin-bottom: 8px; }
.layout-preset-btn {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 8px 4px; background: var(--bg-button); border: 1px solid transparent;
border-radius: var(--radius-md); cursor: pointer; color: var(--text-secondary);
font-size: 10px; font-weight: 500; transition: all 0.15s ease-out;
}
.layout-preset-btn:hover { background: var(--bg-button-hover); color: var(--text-primary); }
.layout-preset-btn.active { background: var(--accent-primary-muted); border-color: var(--accent-primary); color: var(--accent-primary); }
.layout-preset-btn svg { width: 22px; height: 22px; fill: currentColor; }
.layout-custom-row {
display: flex; align-items: center; gap: 6px; margin-top: 8px;
}
.layout-custom-row input {
width: 44px; padding: 5px 6px; background: var(--bg-input); border: 1px solid var(--border-default);
border-radius: var(--radius-sm); color: var(--text-primary); font-size: 12px; text-align: center;
font-family: inherit; outline: none;
}
.layout-custom-row input:focus { border-color: var(--accent-primary); }
.layout-custom-row span { color: var(--text-muted); font-size: 12px; }
.layout-custom-row button {
padding: 5px 12px; background: var(--bg-button); border: none; border-radius: var(--radius-sm);
color: var(--text-primary); font-size: 11px; cursor: pointer; font-family: inherit;
transition: background 0.15s ease-out;
}
.layout-custom-row button:hover { background: var(--bg-button-hover); }
.layout-device-list {
flex: 1; overflow-y: auto; padding: 8px 12px;
scrollbar-width: thin; scrollbar-color: var(--border-default) transparent;
}
.layout-device-item {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-radius: var(--radius-sm); cursor: pointer; transition: background 0.1s;
}
.layout-device-item:hover { background: var(--bg-surface-hover); }
.layout-device-item .dev-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.layout-device-item .dev-info { flex: 1; min-width: 0; }
.layout-device-item .dev-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.layout-device-item .dev-meta { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.layout-device-item .dev-toggle {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm); border: none; background: none; cursor: pointer;
color: var(--text-secondary); transition: all 0.15s;
}
.layout-device-item .dev-toggle:hover { background: var(--bg-button); }
.layout-device-item .dev-toggle.hidden-cam { color: var(--text-muted); opacity: 0.4; }
.layout-device-item .dev-toggle svg { width: 16px; height: 16px; fill: currentColor; }
.layout-device-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 12px; }
</style>
</head>
<body>
<nav class="icon-rail">
<div class="rail-logo">
<svg viewBox="0 0 24 24"><polygon points="8,5 8,19 19,12"/></svg>
</div>
<span class="rail-label">Video</span>
<div class="rail-sep"></div>
<button class="rail-btn" title="Grid Layout" id="railGridBtn">
<svg viewBox="0 0 24 24"><path d="M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z"/></svg>
</button>
<div class="rail-sep"></div>
<button class="rail-btn" title="Activity Log" id="railLogBtn">
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
<span class="log-badge" id="logBadge"></span>
</button>
<div class="rail-spacer"></div>
<button class="rail-btn" title="Add Files" id="railAddBtn">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</nav>
<div class="header">
<div class="header-left">
<div class="header-title-badge">
<svg viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
</div>
<div>
<h1>Alta Video Player</h1>
</div>
</div>
<div class="header-actions">
<div class="verify-badge" id="verifyBadge" title="Click for details">
<span class="verify-dot"></span>
<span class="verify-label">Integrity</span>
</div>
<span class="cam-count" id="camCount"></span>
<button class="btn btn-add" id="addFilesBtn" style="display:none">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Add Files
</button>
<button class="btn btn-add" id="newSessionBtn" style="display:none" title="Clear all loaded data and start fresh">
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
New Session
</button>
</div>
</div>
<div class="drop-zone" id="dropZone">
<div class="drop-icon-wrap">
<svg viewBox="0 0 24 24"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
</div>
<p>Drop export folder or ZIP files here</p>
<small>Supports ZIP archives, folders, or individual MP4 + TXT files from Alta exports</small>
</div>
<div class="loading-overlay hidden" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">Extracting files...</div>
<div class="loading-detail" id="loadingDetail"></div>
</div>
<div class="player-area" id="playerArea">
<div class="camera-grid" id="cameraGrid"></div>
<div class="slideshow-container" id="slideshowContainer">
<div class="slideshow-stage" id="slideshowStage"></div>
</div>
<div class="controls-panel">
<div class="timeline-container">
<div class="timeline-labels">
<span id="tlStart">00:00:00</span>
<span id="tlEnd">00:00:00</span>
</div>
<div class="timeline-outer" id="timelineOuter">
<div class="timeline-track" id="timelineTrack">
<div class="timeline-playhead" id="playhead" style="left:0"></div>
<div class="timeline-hover" id="timelineHover">00:00:00</div>
</div>
<div class="timeline-minimap" id="timelineMinimap">
<div class="minimap-viewport" id="minimapViewport"></div>
</div>
</div>
<div class="timeline-zoom-info" id="tlZoomInfo"></div>
</div>
<div class="transport">
<div class="transport-left">
<div class="time-display" id="timeDisplay">00:00:00 / 00:00:00</div>
</div>
<div class="transport-center">
<button class="btn btn-icon" id="btnSkipBack" title="Skip back 10s (Shift+Left)">
<svg viewBox="0 0 24 24"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>
</button>
<button class="btn btn-icon" id="btnFrameBack" title="Frame back (Left)">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="btn btn-play" id="btnPlay" title="Play/Pause (Space)">
<svg viewBox="0 0 24 24" id="playIcon"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="btn btn-icon" id="btnFrameFwd" title="Frame forward (Right)">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="btn btn-icon" id="btnSkipFwd" title="Skip forward 10s (Shift+Right)">
<svg viewBox="0 0 24 24"><path d="M12.01 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/></svg>
</button>
</div>
<div class="transport-right">
<button class="btn btn-icon" id="btnSpeedDown" title="Slow down ([)">
<svg viewBox="0 0 24 24"><path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z"/></svg>
</button>
<div class="speed-display" id="speedDisplay">1.0x</div>
<button class="btn btn-icon" id="btnSpeedUp" title="Speed up (])">
<svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>
</button>
<button class="btn btn-icon btn-slideshow" id="btnSlideshow" title="Slideshow mode (S)">
<svg viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zm-7-1l5-3.5-5-3.5z"/></svg>
</button>
<button class="btn btn-icon btn-mag" id="btnMagnifier" title="Region zoom (M)">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
</button>
<button class="btn btn-icon" id="btnFullscreen" title="Fullscreen (F)">
<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
</button>
</div>
</div>
<div class="kb-hints">
<span><kbd>Space</kbd> Play/Pause</span>
<span><kbd>&#8592;</kbd><kbd>&#8594;</kbd> Frame step</span>
<span><kbd>Shift</kbd>+<kbd>&#8592;</kbd><kbd>&#8594;</kbd> Skip 10s</span>
<span><kbd>[</kbd><kbd>]</kbd> Speed</span>
<span><kbd>F</kbd> Fullscreen</span>
<span><kbd>S</kbd> Slideshow</span>
<span><kbd>M</kbd> Region zoom</span>
<span><kbd>Scroll</kbd> Zoom video</span>
<span><kbd>0</kbd> Reset timeline zoom</span>
<span>Click camera to expand</span>
</div>
</div>
</div>
<div class="verify-modal-overlay hidden" id="verifyModal">
<div class="verify-modal">
<h2 id="verifyModalTitle">Export Integrity Verification</h2>
<div class="verify-summary" id="verifySummary"></div>
<div class="verify-cert-info" id="verifyCertInfo" style="display:none"></div>
<ul class="verify-file-list" id="verifyFileList"></ul>
<button class="close-btn" id="verifyModalClose">Close</button>
</div>
</div>
<div class="log-panel hidden" id="logPanel">
<div class="log-panel-header">
<h2><svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg> Activity Log</h2>
<div class="log-panel-actions">
<button id="logClearBtn">Clear</button>
<button id="logCloseBtn">Close</button>
</div>
</div>
<div class="log-entries" id="logEntries">
<div class="log-empty" id="logEmpty">No activity yet</div>
</div>
</div>
<div class="layout-panel hidden" id="layoutPanel">
<div class="layout-panel-header">
<h2><svg viewBox="0 0 24 24"><path d="M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z"/></svg> Grid Layout</h2>
<button id="layoutCloseBtn" style="background:var(--bg-button);border:none;border-radius:var(--radius-sm);color:var(--text-secondary);cursor:pointer;font-size:11px;padding:4px 10px;font-family:inherit;">Close</button>
</div>
<div class="layout-section">
<div class="layout-section-title">Layout</div>
<div class="layout-presets" id="layoutPresets">
<button class="layout-preset-btn active" data-layout="auto" title="Auto">
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>
Auto
</button>
<button class="layout-preset-btn" data-layout="1x1" title="1x1">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/></svg>
1x1
</button>
<button class="layout-preset-btn" data-layout="2x2" title="2x2">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="13" y="3" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="3" y="13" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="13" y="13" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
2x2
</button>
<button class="layout-preset-btn" data-layout="3x3" title="3x3">
<svg viewBox="0 0 24 24"><rect x="2" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
3x3
</button>
</div>
<div class="layout-custom-row">
<input type="number" id="layoutCols" min="1" max="6" value="2" placeholder="C">
<span>&times;</span>
<input type="number" id="layoutRows" min="1" max="6" value="2" placeholder="R">
<button id="layoutApplyCustom">Apply</button>
</div>
</div>
<div class="layout-section" style="padding-bottom:6px;">
<div class="layout-section-title">Devices</div>
</div>
<div class="layout-device-list" id="layoutDeviceList">
<div class="layout-device-empty">No cameras loaded</div>
</div>
</div>
<div class="password-modal-overlay hidden" id="passwordModal">
<div class="password-modal">
<div class="pw-icon">&#128274;</div>
<h2>Password Protected Export</h2>
<p>This export is encrypted with AES-256. Enter the password to decrypt.</p>
<div class="pw-error" id="pwError"></div>
<input type="password" id="pwInput" placeholder="Enter export password" autocomplete="off">
<div class="pw-buttons">
<button class="pw-btn pw-btn-cancel" id="pwCancel">Cancel</button>
<button class="pw-btn pw-btn-submit" id="pwSubmit">Decrypt</button>
</div>
</div>
</div>
<input type="file" id="fileInput" multiple accept=".mp4,.txt,.mov,.avi,.mkv,.zip">
<script>
(() => {
// ─── Web Crypto: ASN.1 / X.509 Helpers ───
function pemToDer(pem) {
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function parseAsn1(data, offset) {
const tag = data[offset];
let len = data[offset + 1];
let hdr = 2;
if (len & 0x80) {
const n = len & 0x7f;
len = 0;
for (let i = 0; i < n; i++) len = (len << 8) | data[offset + 2 + i];
hdr = 2 + n;
}
return { tag, len, hdr, body: offset + hdr, end: offset + hdr + len };
}
function asn1Children(data, node) {
const kids = [];
let p = node.body;
while (p < node.end) {
const child = parseAsn1(data, p);
kids.push(child);
p = child.end;
}
return kids;
}
function asn1Bytes(data, node) {
return data.slice(node.body, node.end);
}
function asn1Raw(data, node) {
return data.slice(node.body - node.hdr, node.end);
}
function asn1Utf8(data, node) {
return new TextDecoder().decode(data.slice(node.body, node.end));
}
const DN_OIDS = {
'550403': 'CN', '550406': 'C', '550407': 'L', '550408': 'ST',
'55040a': 'O', '55040b': 'OU',
};
function parseOidHex(data, node) {
return Array.from(data.slice(node.body, node.end))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
function parseDN(data, node) {
const parts = [];
for (const set of asn1Children(data, node)) {
for (const seq of asn1Children(data, set)) {
const kids = asn1Children(data, seq);
const oidHex = parseOidHex(data, kids[0]);
const label = DN_OIDS[oidHex] || oidHex;
const value = asn1Utf8(data, kids[1]);
parts.push(`${label}=${value}`);
}
}
return parts.join(', ');
}
function findCN(data, node) {
for (const set of asn1Children(data, node)) {
for (const seq of asn1Children(data, set)) {
const kids = asn1Children(data, seq);
if (parseOidHex(data, kids[0]) === '550403') {
return asn1Utf8(data, kids[1]);
}
}
}
return '';
}
function parseTime(data, node) {
const s = asn1Utf8(data, node);
// UTCTime: YYMMDDHHMMSSZ
if (node.tag === 0x17) {
const yy = parseInt(s.slice(0, 2));
const year = yy >= 50 ? 1900 + yy : 2000 + yy;
return `${year}-${s.slice(2,4)}-${s.slice(4,6)} ${s.slice(6,8)}:${s.slice(8,10)}:${s.slice(10,12)} UTC`;
}
// GeneralizedTime: YYYYMMDDHHMMSSZ
return `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)} ${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)} UTC`;
}
function parseCertificate(pemString) {
try {
const der = pemToDer(pemString);
const cert = parseAsn1(der, 0);
const tbs = asn1Children(der, cert)[0];
const tbsKids = asn1Children(der, tbs);
// version is context-tagged [0] (0xA0) — if present, shift index
let idx = 0;
if (tbsKids[0].tag === 0xa0) idx = 1;
const issuerNode = tbsKids[idx + 2];
const validityNode = tbsKids[idx + 3];
const subjectNode = tbsKids[idx + 4];
const spkiNode = tbsKids[idx + 5];
const validityKids = asn1Children(der, validityNode);
return {
commonName: findCN(der, subjectNode),
issuer: parseDN(der, issuerNode),
validFrom: parseTime(der, validityKids[0]),
validTo: parseTime(der, validityKids[1]),
spkiDer: asn1Raw(der, spkiNode),
};
} catch (err) {
return { error: err.message };
}
}
// Detect key algorithm from SPKI
function detectKeyAlgo(spkiDer) {
const spki = parseAsn1(spkiDer, 0);
const algoSeq = asn1Children(spkiDer, spki)[0];
const oid = asn1Children(spkiDer, algoSeq)[0];
const oidHex = parseOidHex(spkiDer, oid);
// RSA: 1.2.840.113549.1.1.1 → 2a864886f70d010101
if (oidHex.startsWith('2a864886f70d0101')) {
return { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
}
// EC: 1.2.840.10045.2.1 → 2a8648ce3d0201
if (oidHex.startsWith('2a8648ce3d0201')) {
// Read the curve OID from params
const params = asn1Children(spkiDer, algoSeq)[1];
const curveHex = parseOidHex(spkiDer, params);
let namedCurve = 'P-256';
if (curveHex === '2b81040022') namedCurve = 'P-384';
else if (curveHex === '2b81040023') namedCurve = 'P-521';
return { name: 'ECDSA', namedCurve, hash: 'SHA-256' };
}
// Fallback: try RSA
return { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
}
// Convert DER-encoded ECDSA signature to raw (r || s)
function derSigToRaw(der, keySize) {
const seq = parseAsn1(der, 0);
const kids = asn1Children(der, seq);
const r = asn1Bytes(der, kids[0]);
const s = asn1Bytes(der, kids[1]);
const raw = new Uint8Array(keySize * 2);
// r and s may have leading zero padding; trim or pad to keySize
if (r.length <= keySize) {
raw.set(r, keySize - r.length);
} else {
raw.set(r.slice(r.length - keySize), 0);
}
if (s.length <= keySize) {
raw.set(s, keySize * 2 - s.length);
} else {
raw.set(s.slice(s.length - keySize), keySize);
}
return raw;
}
async function verifySignature(certPem, fileData, sigData) {
try {
const certInfo = parseCertificate(certPem);
if (certInfo.error) return false;
const algo = detectKeyAlgo(certInfo.spkiDer);
const importAlgo = algo.name === 'ECDSA'
? { name: 'ECDSA', namedCurve: algo.namedCurve }
: { name: 'RSASSA-PKCS1-v1_5', hash: algo.hash };
const key = await crypto.subtle.importKey('spki', certInfo.spkiDer, importAlgo, false, ['verify']);
let sig = sigData instanceof Uint8Array ? sigData : new Uint8Array(sigData);
// For ECDSA, convert DER signature to raw format
if (algo.name === 'ECDSA' && sig[0] === 0x30) {
const keySizes = { 'P-256': 32, 'P-384': 48, 'P-521': 66 };
sig = derSigToRaw(sig, keySizes[algo.namedCurve] || 32);
}
const verifyAlgo = algo.name === 'ECDSA'
? { name: 'ECDSA', hash: 'SHA-256' }
: 'RSASSA-PKCS1-v1_5';
return await crypto.subtle.verify(verifyAlgo, key, sig, fileData);
} catch (err) {
avpLog('error', 'Signature verification error: ' + err.message);
return false;
}
}
async function verifyCertificateOnline(certPemBytes, serial) {
try {
const hashBuf = await crypto.subtle.digest('SHA-256', certPemBytes);
const hash = btoa(String.fromCharCode(...new Uint8Array(hashBuf)));
if (window.webavpNative?.verifyCertificateOnline) {
return await window.webavpNative.verifyCertificateOnline({ serial, certificateHash: hash });
}
const url = `/api/verify-cert?serial=${encodeURIComponent(serial.toLowerCase())}&certificateHash=${encodeURIComponent(hash)}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json().catch(() => ({}));
return { verified: data.verified !== false, error: data.error };
}
return { verified: false, error: `HTTP ${res.status}` };
} catch (err) {
// CORS or network error — degrade gracefully
return { verified: false, error: err.message || 'Network error (possible CORS restriction)' };
}
}
// ─── Session Cache (IndexedDB) ───
const SESSION_DB = 'avp-session';
const SESSION_VERSION = 1;
function openSessionDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SESSION_DB, SESSION_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('videos')) db.createObjectStore('videos', { keyPath: 'fileName' });
if (!db.objectStoreNames.contains('metas')) db.createObjectStore('metas', { keyPath: 'fileName' });
if (!db.objectStoreNames.contains('sign')) db.createObjectStore('sign', { keyPath: 'key' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbPut(db, store, item) {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
tx.objectStore(store).put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
function idbGetAll(db, store) {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readonly');
const req = tx.objectStore(store).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbClear(db, store) {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
tx.objectStore(store).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function saveSessionData() {
try {
const db = await openSessionDB();
// Save each channel's segments' blobs and metadata
for (const [name, ch] of channels) {
for (const seg of ch.segments) {
if (seg.blob) {
await idbPut(db, 'videos', { fileName: name + '|' + (seg.startTime || 0), blob: seg.blob });
}
await idbPut(db, 'metas', {
fileName: name + '|' + (seg.startTime || 0),
meta: seg.meta || {},
cameraName: name
});
}
}
// Save sign data (per-certificate)
let certIdx = 0;
for (const [, certEntry] of signCerts) {
await idbPut(db, 'sign', { key: `cert|${certIdx}|pem`, value: certEntry.pem });
await idbPut(db, 'sign', { key: `cert|${certIdx}|bytes`, value: certEntry.bytes });
for (const [sigName, sigData] of certEntry.signatures) {
await idbPut(db, 'sign', { key: `cert|${certIdx}|sig|${sigName}`, value: sigData });
}
certIdx++;
}
db.close();
} catch (e) {
avpLog('warn', 'Session save failed: ' + e.message);
}
}
async function loadSessionData() {
try {
const db = await openSessionDB();
const videos = await idbGetAll(db, 'videos');
const metas = await idbGetAll(db, 'metas');
const signItems = await idbGetAll(db, 'sign');
db.close();
if (videos.length === 0 && metas.length === 0) return false;
// Restore sign data (per-certificate)
const certMap = new Map(); // idx -> { pem, bytes, sigs }
for (const item of signItems) {
const parts = item.key.split('|');
if (parts[0] !== 'cert') continue;
const idx = parts[1];
if (!certMap.has(idx)) certMap.set(idx, { pem: null, bytes: null, sigs: new Map() });
const c = certMap.get(idx);
if (parts[2] === 'pem') c.pem = item.value;
else if (parts[2] === 'bytes') c.bytes = item.value;
else if (parts[2] === 'sig') c.sigs.set(parts.slice(3).join('|'), item.value);
}
for (const [, c] of certMap) {
if (c.pem && c.sigs.size > 0) {
signCerts.set(c.pem, { pem: c.pem, bytes: c.bytes, signatures: c.sigs });
}
}
// Build a map of metas by key
const metaMap = new Map();
for (const m of metas) metaMap.set(m.fileName, m);
// Restore segments
batchingSegments = true;
for (const v of videos) {
const m = metaMap.get(v.fileName);
if (m && m.meta && m.meta['Name']) {
addSegment(m.cameraName, m.meta, v.blob);
} else {
// Video without proper meta
const cameraName = m ? m.cameraName : v.fileName.split('|')[0];
const ch = getOrCreateChannel(cameraName);
const url = URL.createObjectURL(v.blob);
ch.segments.push({ startTime: null, endTime: null, url, blob: v.blob, meta: m ? m.meta : {}, videoEl: null, loaded: false });
}
}
batchingSegments = false;
if (channels.size > 0) {
recalcGlobals();
renderAll();
showPlayer();
if (signCerts.size > 0) runVerification();
return true;
}
return false;
} catch (e) {
avpLog('warn', 'Session restore failed: ' + e.message);
return false;
}
}
async function clearSessionDB() {
try {
const db = await openSessionDB();
await idbClear(db, 'videos');
await idbClear(db, 'metas');
await idbClear(db, 'sign');
db.close();
} catch (e) {
avpLog('warn', 'Session clear failed: ' + e.message);
}
}
// ─── Data Model ───
const channels = new Map();
const CAM_COLORS = ['#006ED7', '#ff6b35', '#20C62F', '#DE1111', '#8957E5', '#EAA301', '#26c6da', '#A855F7', '#ec407a'];
const SPEEDS = [0.25, 0.5, 1, 2, 4, 8];
let currentSpeedIdx = 2;
let isPlaying = false;
let globalStart = Infinity;
let globalEnd = -Infinity;
let globalDuration = 0;
let currentTime = 0;
let animFrame = null;
let lastTick = 0;
const pendingVideos = [];
const pendingMetas = [];
// ─── Verification State ───
let verifyStatus = 'none';
let verifyResults = []; // [{ certId, fileName, passed, detail? }]
let verifyCertInfos = []; // [{ certId, certInfo, cloudResult }]
// Per-certificate store: certId -> { pem, bytes, signatures: Map<fileName, Uint8Array> }
const signCerts = new Map();
// ─── Elements ───
const dropZone = document.getElementById('dropZone');
const playerArea = document.getElementById('playerArea');
const cameraGrid = document.getElementById('cameraGrid');
const camCount = document.getElementById('camCount');
const addFilesBtn = document.getElementById('addFilesBtn');
const fileInput = document.getElementById('fileInput');
const timelineTrack = document.getElementById('timelineTrack');
const playhead = document.getElementById('playhead');
const timelineHover = document.getElementById('timelineHover');
const tlStart = document.getElementById('tlStart');
const tlEnd = document.getElementById('tlEnd');
const timeDisplay = document.getElementById('timeDisplay');
const btnPlay = document.getElementById('btnPlay');
const playIcon = document.getElementById('playIcon');
const speedDisplay = document.getElementById('speedDisplay');
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText');
const loadingDetail = document.getElementById('loadingDetail');
const verifyBadge = document.getElementById('verifyBadge');
const verifyLabel = verifyBadge.querySelector('.verify-label');
const verifyModal = document.getElementById('verifyModal');
const verifySummary = document.getElementById('verifySummary');
const verifyCertInfoEl = document.getElementById('verifyCertInfo');
const verifyFileList = document.getElementById('verifyFileList');
// ─── Activity Log ───
const logPanel = document.getElementById('logPanel');
const logEntries = document.getElementById('logEntries');
const logEmpty = document.getElementById('logEmpty');
const logBadge = document.getElementById('logBadge');
const railLogBtn = document.getElementById('railLogBtn');
const logStore = [];
let logErrorCount = 0;
function avpLog(level, message) {
const now = new Date();
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const entry = { time, level, message };
logStore.push(entry);
if (logEmpty) logEmpty.style.display = 'none';
const el = document.createElement('div');
el.className = `log-entry log-${level}`;
const timeSpan = document.createElement('span');
timeSpan.className = 'log-time';
timeSpan.textContent = time;
const msgSpan = document.createElement('span');
msgSpan.className = 'log-msg';
msgSpan.textContent = message;
el.appendChild(timeSpan);
el.appendChild(msgSpan);
logEntries.appendChild(el);
logEntries.scrollTop = logEntries.scrollHeight;
if (level === 'error' || level === 'warn') {
logErrorCount++;
logBadge.style.display = 'block';
}
}
railLogBtn.addEventListener('click', () => {
logPanel.classList.toggle('hidden');
if (!logPanel.classList.contains('hidden')) {
logErrorCount = 0;
logBadge.style.display = 'none';
}
});
document.getElementById('logCloseBtn').addEventListener('click', () => {
logPanel.classList.add('hidden');
});
document.getElementById('logClearBtn').addEventListener('click', () => {
logStore.length = 0;
logEntries.innerHTML = '';
logEmpty.style.display = '';
logEntries.appendChild(logEmpty);
logErrorCount = 0;
logBadge.style.display = 'none';
});
// ─── Grid Layout Panel ───
const layoutPanel = document.getElementById('layoutPanel');
const layoutPresets = document.getElementById('layoutPresets');
const layoutDeviceList = document.getElementById('layoutDeviceList');
const railGridBtn = document.getElementById('railGridBtn');
const hiddenCameras = new Set(); // names of hidden cameras
let gridLayoutOverride = null; // null = auto, or {cols, rows}
function toggleLayoutPanel(forceClose) {
if (forceClose) {
layoutPanel.classList.add('hidden');
} else {
layoutPanel.classList.toggle('hidden');
}
const isOpen = !layoutPanel.classList.contains('hidden');
document.body.classList.toggle('layout-open', isOpen);
railGridBtn.classList.toggle('active', isOpen);
if (isOpen) renderDeviceList();
}
railGridBtn.addEventListener('click', () => toggleLayoutPanel());
document.getElementById('layoutCloseBtn').addEventListener('click', () => toggleLayoutPanel(true));
// Preset buttons
layoutPresets.addEventListener('click', (e) => {
const btn = e.target.closest('.layout-preset-btn');
if (!btn) return;
layoutPresets.querySelectorAll('.layout-preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const layout = btn.dataset.layout;
if (layout === 'auto') {
gridLayoutOverride = null;
} else {
const [c, r] = layout.split('x').map(Number);
gridLayoutOverride = { cols: c, rows: r };
}
applyGridLayout();
});
// Custom layout
document.getElementById('layoutApplyCustom').addEventListener('click', () => {
const cols = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutCols').value) || 2));
const rows = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutRows').value) || 2));
document.getElementById('layoutCols').value = cols;
document.getElementById('layoutRows').value = rows;
gridLayoutOverride = { cols, rows };
layoutPresets.querySelectorAll('.layout-preset-btn').forEach(b => b.classList.remove('active'));
applyGridLayout();
});
function applyGridLayout() {
applyCameraVisibility();
if (!gridLayoutOverride) {
// Auto mode — restore cams-N class
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
} else {
// Override mode
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${gridLayoutOverride.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${gridLayoutOverride.rows}, 1fr)`;
}
}
function countVisibleCameras() {
let count = 0;
for (const ch of channels.values()) {
if (!hiddenCameras.has(ch.name)) count++;
}
return count;
}
function renderDeviceList() {
layoutDeviceList.innerHTML = '';
if (channels.size === 0) {
layoutDeviceList.innerHTML = '<div class="layout-device-empty">No cameras loaded</div>';
return;
}
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
let visibleIdx = 0;
for (const ch of channels.values()) {
const isManuallyHidden = hiddenCameras.has(ch.name);
const isOverflow = !isManuallyHidden && visibleIdx >= maxSlots;
if (!isManuallyHidden) visibleIdx++;
const item = document.createElement('div');
item.className = 'layout-device-item';
if (isManuallyHidden || isOverflow) item.style.opacity = '0.45';
const dot = document.createElement('span');
dot.className = 'dev-dot';
dot.style.background = ch.color;
const info = document.createElement('div');
info.className = 'dev-info';
const name = document.createElement('div');
name.className = 'dev-name';
name.textContent = ch.name;
info.appendChild(name);
const clips = ch.segments.length;
const metaLine = [];
if (clips > 0) metaLine.push(`${clips} clip${clips !== 1 ? 's' : ''}`);
if (ch.meta && ch.meta['Model']) metaLine.push(ch.meta['Model']);
if (isOverflow) metaLine.push('not in layout');
if (isManuallyHidden) metaLine.push('hidden');
if (metaLine.length > 0) {
const meta = document.createElement('div');
meta.className = 'dev-meta';
meta.textContent = metaLine.join(' \u00B7 ');
info.appendChild(meta);
}
// Visibility toggle
const eyeShow = '<svg viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>';
const eyeHide = '<svg viewBox="0 0 24 24"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46A11.804 11.804 0 001 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>';
const toggle = document.createElement('button');
toggle.className = 'dev-toggle' + (isManuallyHidden ? ' hidden-cam' : '');
toggle.title = isManuallyHidden ? 'Show camera' : 'Hide camera';
toggle.innerHTML = isManuallyHidden ? eyeHide : eyeShow;
const chName = ch.name;
toggle.addEventListener('click', (e) => {
e.stopPropagation();
if (hiddenCameras.has(chName)) {
hiddenCameras.delete(chName);
} else {
hiddenCameras.add(chName);
}
applyGridLayout();
renderDeviceList();
});
// Click name to expand camera
info.style.cursor = 'pointer';
info.addEventListener('click', () => {
if (ch.cellEl && !isManuallyHidden && !isOverflow) ch.cellEl.click();
});
item.appendChild(dot);
item.appendChild(info);
item.appendChild(toggle);
layoutDeviceList.appendChild(item);
}
}
function applyCameraVisibility() {
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
let visibleIdx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) continue;
const manuallyHidden = hiddenCameras.has(ch.name);
const overflowHidden = visibleIdx >= maxSlots;
ch.cellEl.style.display = (manuallyHidden || overflowHidden) ? 'none' : '';
if (!manuallyHidden) visibleIdx++;
}
}
// ─── Parsing ───
function parseMeta(text) {
const meta = {};
for (const line of text.split('\n')) {
const idx = line.indexOf(':');
if (idx === -1) continue;
meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return meta;
}
function parseTimestamp(str) {
if (!str) return null;
const m = str.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\.?(\d*)\s*([\+\-]\d{4})/);
if (!m) return null;
const iso = `${m[1]}T${m[2]}.${m[3] || '0'}${m[4].slice(0,3)}:${m[4].slice(3)}`;
return new Date(iso).getTime() / 1000;
}
function formatTime(sec) {
if (!isFinite(sec) || sec < 0) sec = 0;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function formatLocalTime(epochSec) {
return new Date(epochSec * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true });
}
function formatLocalDateTime(epochSec) {
return new Date(epochSec * 1000).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true
});
}
function baseName(fileName) {
const name = fileName.split('/').pop();
return name.replace(/\.(mp4|mov|avi|mkv|txt)$/i, '');
}
// Derive a per-sensor camera id from an Alta export filename by stripping the
// trailing timestamp, e.g. "Backyard_4-2026-06-03T10-02-48.000Z.mp4" -> "Backyard 4".
// Multi-sensor cameras (e.g. Avigilon 32C-H5A) export one file per sensor that
// share the SAME metadata Name and Serial, so the filename prefix is the only
// thing distinguishing the sensors. Keying channels by it stops distinct sensors
// from collapsing into one tile. Returns '' when no Alta timestamp is present so
// callers can fall back to the metadata Name.
function cameraIdFromFile(fileName) {
const base = baseName(fileName);
const stripped = base.replace(/-\d{4}-\d{2}-\d{2}T[\d\-.]+Z?$/i, '');
if (stripped === base) return '';
return stripped.replace(/_/g, ' ').trim();
}
// ─── Channel / Segment Management ───
function getOrCreateChannel(name) {
if (channels.has(name)) return channels.get(name);
const ch = {
name,
color: CAM_COLORS[channels.size % CAM_COLORS.length],
segments: [],
meta: null,
cellEl: null,
activeSegIdx: -1,
};
channels.set(name, ch);
return ch;
}
// Batch flag: when true, skip per-segment rendering
let batchingSegments = false;
// Track which camera is expanded (null = none)
let expandedChannel = null;
// ─── Timeline Zoom State ───
// viewStart/viewEnd define the visible window in seconds (offset from globalStart)
let tlZoom = 1; // 1 = full view, higher = more zoomed in
let tlViewCenter = 0; // center of view in seconds offset from globalStart
const TL_MIN_ZOOM = 1;
const TL_MAX_ZOOM = 200;
function getViewWindow() {
const viewDur = globalDuration / tlZoom;
let start = tlViewCenter - viewDur / 2;
let end = tlViewCenter + viewDur / 2;
// Clamp to global bounds
if (start < 0) { start = 0; end = Math.min(viewDur, globalDuration); }
if (end > globalDuration) { end = globalDuration; start = Math.max(0, end - viewDur); }
return { start, end, dur: end - start };
}
// Convert absolute time offset (from globalStart) to percentage within the view
function timeToViewPct(t) {
const vw = getViewWindow();
return ((t - vw.start) / vw.dur) * 100;
}
function resetTimelineZoom() {
tlZoom = 1;
tlViewCenter = globalDuration / 2;
}
function addSegment(channelName, meta, videoBlob) {
const ch = getOrCreateChannel(channelName);
if (!ch.meta) ch.meta = meta;
const startTime = parseTimestamp(meta['Recording start time']);
const endTime = parseTimestamp(meta['Recording end time']);
const url = URL.createObjectURL(videoBlob);
if (ch.segments.find(s => s.startTime === startTime)) return;
const seg = { startTime, endTime, url, blob: videoBlob, meta, videoEl: null, loaded: false };
ch.segments.push(seg);
ch.segments.sort((a, b) => (a.startTime || 0) - (b.startTime || 0));
if (!batchingSegments) {
recalcGlobals();
renderAll();
showPlayer();
}
}
function tryMatchPending() {
for (let vi = pendingVideos.length - 1; vi >= 0; vi--) {
const v = pendingVideos[vi];
const vBase = baseName(v.fileName);
for (let mi = pendingMetas.length - 1; mi >= 0; mi--) {
const m = pendingMetas[mi];
if (vBase === baseName(m.fileName)) {
const cameraName = cameraIdFromFile(v.fileName) || m.meta['Name'] || `Camera ${channels.size + 1}`;
addSegment(cameraName, m.meta, v.blob);
pendingVideos.splice(vi, 1);
pendingMetas.splice(mi, 1);
return true;
}
}
}
return false;
}
function flushPending() {
batchingSegments = true;
while (tryMatchPending()) { /* keep matching */ }
// Remaining videos without metadata
for (const v of pendingVideos) {
const name = cameraIdFromFile(v.fileName) || baseName(v.fileName).replace(/_/g, ' ');
const ch = getOrCreateChannel(name);
const url = URL.createObjectURL(v.blob);
ch.segments.push({ startTime: null, endTime: null, url, blob: v.blob, meta: {}, videoEl: null, loaded: false });
}
pendingVideos.length = 0;
pendingMetas.length = 0;
batchingSegments = false;
if (channels.size > 0) {
recalcGlobals();
renderAll();
showPlayer();
avpLog('info', `Player ready: ${channels.size} camera(s) loaded`);
}
}
// ─── Global Timeline ───
function recalcGlobals() {
globalStart = Infinity;
globalEnd = -Infinity;
for (const ch of channels.values()) {
for (const seg of ch.segments) {
if (seg.startTime != null && seg.startTime < globalStart) globalStart = seg.startTime;
if (seg.endTime != null && seg.endTime > globalEnd) globalEnd = seg.endTime;
}
}
if (!isFinite(globalStart)) { globalStart = 0; globalEnd = 0; }
globalDuration = globalEnd - globalStart;
if (globalDuration <= 0) globalDuration = 1;
// Reset zoom if it's the first load
if (tlZoom === 1) tlViewCenter = globalDuration / 2;
updateTimelineLabels();
}
// ─── Rendering ───
function renderAll() {
renderGrid();
renderTimeline();
updateSegmentVisibility();
updateUI();
if (!layoutPanel.classList.contains('hidden')) renderDeviceList();
}
function renderGrid() {
const count = channels.size;
camCount.textContent = `${count} camera${count !== 1 ? 's' : ''} | ${totalSegments()} clips`;
if (gridLayoutOverride) {
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${gridLayoutOverride.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${gridLayoutOverride.rows}, 1fr)`;
} else {
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
}
applyCameraVisibility();
let idx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) {
ch.cellEl = createCamCell(ch, idx);
cameraGrid.appendChild(ch.cellEl);
}
for (const seg of ch.segments) {
if (!seg.videoEl) {
const video = document.createElement('video');
video.src = seg.url;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.className = 'hidden-video';
video.addEventListener('loadedmetadata', () => {
seg.loaded = true;
if (seg.startTime == null) {
seg.startTime = 0;
seg.endTime = video.duration;
recalcGlobals();
renderTimeline();
}
});
ch.cellEl.appendChild(video);
seg.videoEl = video;
}
}
idx++;
}
}
function createCamCell(ch) {
const cell = document.createElement('div');
cell.className = 'cam-cell' + (magActive ? ' mag-mode' : '');
cell.style.setProperty('--cam-color', ch.color);
const noSig = document.createElement('div');
noSig.className = 'no-signal';
noSig.textContent = 'No Signal';
cell.appendChild(noSig);
ch._noSignalEl = noSig;
const label = document.createElement('div');
label.className = 'cam-label';
const dot = document.createElement('span');
dot.className = 'cam-dot';
dot.style.background = ch.color;
const nameSpan = document.createElement('span');
nameSpan.textContent = ch.name;
label.appendChild(dot);
label.appendChild(nameSpan);
cell.appendChild(label);
if (ch.meta) {
const model = ch.meta['Model'] || '';
const serial = ch.meta['Serial number'] || '';
if (model || serial) {
const info = document.createElement('div');
info.className = 'cam-info-badge';
info.textContent = [model, serial ? `S/N: ${serial}` : ''].filter(Boolean).join(' | ');
cell.appendChild(info);
}
}
const ts = document.createElement('div');
ts.className = 'cam-timestamp';
ts.textContent = '--:--:--';
cell.appendChild(ts);
ch._tsEl = ts;
const segInfo = document.createElement('div');
segInfo.className = 'cam-segment-info';
segInfo.textContent = `${ch.segments.length} clips`;
cell.appendChild(segInfo);
ch._segInfoEl = segInfo;
setupDragHandle(cell);
cell.addEventListener('click', (e) => {
if (magActive) return;
if (e.target.closest('.drag-handle')) return;
const z = getZoom(cell);
if (z.scale > 1) return;
const wasExpanded = cell.classList.contains('expanded');
cameraGrid.querySelectorAll('.cam-cell.expanded').forEach(c => {
c.classList.remove('expanded');
resetZoom(c);
});
if (!wasExpanded) {
cell.classList.add('expanded');
expandedChannel = ch.name;
} else {
expandedChannel = null;
}
playerArea.classList.toggle('has-expanded', !!expandedChannel);
renderTimeline();
});
return cell;
}
function totalSegments() {
let n = 0;
for (const ch of channels.values()) n += ch.segments.length;
return n;
}
// Tick interval steps in seconds
const TICK_STEPS = [5, 10, 15, 30, 60, 2*60, 5*60, 10*60, 15*60, 30*60, 60*60, 2*3600, 6*3600];
function pickTickInterval(viewDurSec, trackWidthPx) {
// Aim for a tick every ~80-120px
const targetCount = Math.max(2, Math.floor(trackWidthPx / 100));
const idealInterval = viewDurSec / targetCount;
for (const step of TICK_STEPS) {
if (step >= idealInterval) return step;
}
return TICK_STEPS[TICK_STEPS.length - 1];
}
function formatTickLabel(epochSec, interval, hasAbsolute) {
if (hasAbsolute) {
const d = new Date(epochSec * 1000);
if (interval < 60) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
const sec = epochSec;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (interval < 60) return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}
function updateTimelineLabels() {
const vw = getViewWindow();
const hasAbsolute = globalStart > 0;
if (hasAbsolute) {
tlStart.textContent = formatLocalTime(globalStart + vw.start);
tlEnd.textContent = formatLocalTime(globalStart + vw.end);
} else {
tlStart.textContent = formatTime(vw.start);
tlEnd.textContent = formatTime(vw.end);
}
const minimap = document.getElementById('timelineMinimap');
const viewport = document.getElementById('minimapViewport');
const tlZoomInfo = document.getElementById('tlZoomInfo');
if (tlZoom > 1) {
minimap.classList.add('visible');
viewport.style.left = (vw.start / globalDuration * 100) + '%';
viewport.style.width = (vw.dur / globalDuration * 100) + '%';
tlZoomInfo.textContent = `${tlZoom.toFixed(1)}x zoom \u2014 scroll to zoom, drag to pan`;
timelineTrack.style.cursor = 'grab';
} else {
minimap.classList.remove('visible');
tlZoomInfo.textContent = '';
timelineTrack.style.cursor = '';
}
}
function renderTimeline() {
// Clear dynamic elements
timelineTrack.querySelectorAll('.timeline-segment, .timeline-gap, .timeline-tick, .timeline-tick-label').forEach(el => el.remove());
const vw = getViewWindow();
const hasAbsolute = globalStart > 0;
const trackWidth = timelineTrack.offsetWidth || 600;
const rowH = 10;
const pad = 2;
const visibleChannels = expandedChannel
? [channels.get(expandedChannel)].filter(Boolean)
: [...channels.values()];
const tickAreaH = 14;
timelineTrack.style.height = Math.max(32, visibleChannels.length * (rowH + pad) + pad + tickAreaH) + 'px';
// Draw tick marks
const interval = pickTickInterval(vw.dur, trackWidth);
// First tick aligned to interval
const absViewStart = globalStart + vw.start;
const absViewEnd = globalStart + vw.end;
const firstTick = Math.ceil(absViewStart / interval) * interval;
for (let t = firstTick; t <= absViewEnd; t += interval) {
const offset = t - globalStart; // seconds from globalStart
const pct = timeToViewPct(offset);
if (pct < 0 || pct > 100) continue;
const tick = document.createElement('div');
tick.className = 'timeline-tick major';
tick.style.cssText = `left:${pct}%;height:100%;`;
timelineTrack.appendChild(tick);
const label = document.createElement('div');
label.className = 'timeline-tick-label';
label.style.left = pct + '%';
label.textContent = formatTickLabel(hasAbsolute ? t : offset, interval, hasAbsolute);
timelineTrack.appendChild(label);
}
// Draw sub-ticks (half-intervals, no labels)
const subInterval = interval / 2;
if (subInterval >= 1) {
const firstSub = Math.ceil(absViewStart / subInterval) * subInterval;
for (let t = firstSub; t <= absViewEnd; t += subInterval) {
// Skip if it's a major tick
if (Math.abs(t / interval - Math.round(t / interval)) < 0.001) continue;
const offset = t - globalStart;
const pct = timeToViewPct(offset);
if (pct < 0 || pct > 100) continue;
const tick = document.createElement('div');
tick.className = 'timeline-tick';
tick.style.cssText = `left:${pct}%;height:100%;`;
timelineTrack.appendChild(tick);
}
}
// Draw segments and gaps
let row = 0;
for (const ch of visibleChannels) {
const y = pad + tickAreaH + row * (rowH + pad);
for (let i = 0; i < ch.segments.length; i++) {
const seg = ch.segments[i];
if (seg.startTime == null || seg.endTime == null) continue;
const segStart = seg.startTime - globalStart;
const segEnd = seg.endTime - globalStart;
// Skip if entirely outside view
if (segEnd < vw.start || segStart > vw.end) continue;
const left = timeToViewPct(segStart);
const right = timeToViewPct(segEnd);
const width = right - left;
const el = document.createElement('div');
el.className = 'timeline-segment';
el.dataset.channel = ch.name;
el.dataset.segIdx = i;
el.style.cssText = `left:${left}%;width:${width}%;top:${y}px;background:${ch.color};`;
timelineTrack.appendChild(el);
if (i > 0) {
const prev = ch.segments[i - 1];
if (prev.endTime != null && seg.startTime - prev.endTime > 1) {
const gapStart = prev.endTime - globalStart;
const gapEnd = segStart;
if (gapEnd < vw.start || gapStart > vw.end) continue;
const gapLeft = timeToViewPct(gapStart);
const gapRight = timeToViewPct(gapEnd);
const gap = document.createElement('div');
gap.className = 'timeline-gap';
gap.style.cssText = `left:${gapLeft}%;width:${gapRight - gapLeft}%;top:${y}px;`;
timelineTrack.appendChild(gap);
}
}
}
row++;
}
// Update minimap segments
renderMinimap(visibleChannels);
updateTimelineLabels();
}
function renderMinimap(visibleChannels) {
const minimap = document.getElementById('timelineMinimap');
minimap.querySelectorAll('.minimap-seg').forEach(el => el.remove());
for (const ch of visibleChannels) {
for (const seg of ch.segments) {
if (seg.startTime == null || seg.endTime == null) continue;
const left = ((seg.startTime - globalStart) / globalDuration) * 100;
const width = ((seg.endTime - seg.startTime) / globalDuration) * 100;
const el = document.createElement('div');
el.className = 'minimap-seg';
el.style.cssText = `left:${left}%;width:${width}%;background:${ch.color};`;
minimap.appendChild(el);
}
}
}
// ─── Segment Switching ───
function findActiveSegment(ch, absTime) {
for (let i = 0; i < ch.segments.length; i++) {
const seg = ch.segments[i];
if (seg.startTime == null) continue;
if (absTime >= seg.startTime && absTime <= seg.endTime) return i;
}
return -1;
}
function updateSegmentVisibility() {
const absTime = globalStart + currentTime;
for (const ch of channels.values()) {
const newIdx = findActiveSegment(ch, absTime);
if (ch._noSignalEl) {
ch._noSignalEl.style.display = newIdx === -1 ? 'flex' : 'none';
}
if (newIdx !== ch.activeSegIdx) {
if (ch.activeSegIdx >= 0 && ch.segments[ch.activeSegIdx]?.videoEl) {
const oldVid = ch.segments[ch.activeSegIdx].videoEl;
oldVid.pause();
oldVid.classList.add('hidden-video');
}
ch.activeSegIdx = newIdx;
if (newIdx >= 0) {
const seg = ch.segments[newIdx];
if (seg.videoEl) {
seg.videoEl.classList.remove('hidden-video');
const localTime = absTime - seg.startTime;
seg.videoEl.currentTime = Math.max(0, Math.min(localTime, seg.videoEl.duration || 0));
seg.videoEl.playbackRate = SPEEDS[currentSpeedIdx];
if (isPlaying) seg.videoEl.play();
}
}
// Re-apply zoom to newly visible video
if (ch.cellEl) {
applyZoom(ch.cellEl);
}
}
if (ch._segInfoEl) {
const total = ch.segments.length;
ch._segInfoEl.textContent = newIdx >= 0
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
}
}
}
// ─── Player State ───
function showPlayer() {
dropZone.classList.add('hidden');
playerArea.classList.add('active');
addFilesBtn.style.display = 'flex';
document.getElementById('newSessionBtn').style.display = 'flex';
}
function showLoading(msg, detail) {
loadingText.textContent = msg || 'Processing...';
loadingDetail.textContent = detail || '';
loadingOverlay.classList.remove('hidden');
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
}
function play() {
isPlaying = true;
lastTick = 0;
playIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
for (const ch of channels.values()) {
const idx = ch.activeSegIdx;
if (idx >= 0) {
const seg = ch.segments[idx];
if (seg.videoEl && seg.videoEl.readyState >= 2) {
seg.videoEl.playbackRate = SPEEDS[currentSpeedIdx];
seg.videoEl.play();
}
}
}
tick();
}
function pause() {
isPlaying = false;
playIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
for (const ch of channels.values()) {
for (const seg of ch.segments) {
if (seg.videoEl) seg.videoEl.pause();
}
}
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
}
function togglePlay() { isPlaying ? pause() : play(); }
function seekTo(time) {
currentTime = Math.max(0, Math.min(time, globalDuration));
const absTime = globalStart + currentTime;
updateSegmentVisibility();
for (const ch of channels.values()) {
const idx = ch.activeSegIdx;
if (idx >= 0) {
const seg = ch.segments[idx];
if (seg.videoEl) {
seg.videoEl.currentTime = Math.max(0, absTime - seg.startTime);
if (isPlaying) seg.videoEl.play();
}
}
}
updateUI();
}
function setSpeed(idx) {
currentSpeedIdx = Math.max(0, Math.min(idx, SPEEDS.length - 1));
const speed = SPEEDS[currentSpeedIdx];
speedDisplay.textContent = speed + 'x';
speedDisplay.classList.toggle('highlight', speed !== 1);
for (const ch of channels.values()) {
for (const seg of ch.segments) {
if (seg.videoEl) seg.videoEl.playbackRate = speed;
}
}
if (slideshowActive) {
const v = slideshowStage.querySelector('.ss-pane:not(.ss-exit) video');
if (v) v.playbackRate = speed;
}
}
function frameStep(dir) {
pause();
seekTo(currentTime + dir * (1 / 30));
}
function tick() {
if (!isPlaying) return;
const now = performance.now();
if (lastTick) {
const dt = (now - lastTick) / 1000;
currentTime += dt * SPEEDS[currentSpeedIdx];
if (currentTime >= globalDuration) {
currentTime = globalDuration;
pause();
updateUI();
return;
}
updateSegmentVisibility();
}
lastTick = now;
updateUI();
animFrame = requestAnimationFrame(tick);
}
function updateUI() {
const pct = timeToViewPct(currentTime);
playhead.style.left = Math.max(0, Math.min(100, pct)) + '%';
// Hide playhead if outside view
playhead.style.display = (pct < -1 || pct > 101) ? 'none' : '';
const absTime = globalStart + currentTime;
const hasAbsolute = globalStart > 0;
timeDisplay.textContent = hasAbsolute
? formatLocalDateTime(absTime)
: `${formatTime(currentTime)} / ${formatTime(globalDuration)}`;
for (const ch of channels.values()) {
if (!ch._tsEl) continue;
if (hasAbsolute) {
ch._tsEl.textContent = formatLocalTime(absTime);
} else {
const idx = ch.activeSegIdx;
if (idx >= 0 && ch.segments[idx].videoEl) {
ch._tsEl.textContent = formatTime(ch.segments[idx].videoEl.currentTime);
}
}
}
timelineTrack.querySelectorAll('.timeline-segment').forEach(el => {
const chName = el.dataset.channel;
const segIdx = parseInt(el.dataset.segIdx);
const ch = channels.get(chName);
el.classList.toggle('active-segment', ch && ch.activeSegIdx === segIdx);
});
if (slideshowActive) updateSlideshow();
}
// ─── Timeline Interaction ───
let isDragging = false;
let tlPanning = false;
let tlPanStartX = 0;
let tlPanStartCenter = 0;
function timelineSeek(e) {
const rect = timelineTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const vw = getViewWindow();
seekTo(vw.start + pct * vw.dur);
}
const TL_DRAG_THRESHOLD = 5; // pixels before drag becomes a pan
let tlMouseDownX = 0;
let tlMouseDownHandled = false;
timelineTrack.addEventListener('mousedown', e => {
if (tlZoom > 1) {
// When zoomed, start potential pan — decide on mousemove whether it's pan or seek
tlMouseDownX = e.clientX;
tlPanStartX = e.clientX;
tlPanStartCenter = tlViewCenter;
tlMouseDownHandled = false;
isDragging = true; // will become either seek or pan
e.preventDefault();
} else {
isDragging = true;
timelineSeek(e);
}
});
document.addEventListener('mousemove', e => {
if (tlPanning) {
const rect = timelineTrack.getBoundingClientRect();
const dx = e.clientX - tlPanStartX;
const vw = getViewWindow();
const timeDelta = -(dx / rect.width) * vw.dur;
tlViewCenter = Math.max(0, Math.min(globalDuration, tlPanStartCenter + timeDelta));
timelineTrack.style.cursor = 'grabbing';
renderTimeline();
updateUI();
return;
}
if (isDragging && tlZoom > 1 && !tlMouseDownHandled) {
const dx = Math.abs(e.clientX - tlMouseDownX);
if (dx > TL_DRAG_THRESHOLD) {
// Exceeded threshold — this is a pan
tlPanning = true;
tlMouseDownHandled = true;
return;
}
}
if (isDragging && !tlPanning) timelineSeek(e);
const rect = timelineTrack.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom) {
const pct = (e.clientX - rect.left) / rect.width;
const vw = getViewWindow();
const t = vw.start + pct * vw.dur;
const hasAbs = globalStart > 0;
timelineHover.textContent = hasAbs ? formatLocalTime(globalStart + t) : formatTime(t);
timelineHover.style.left = (pct * 100) + '%';
timelineHover.style.display = 'block';
} else {
timelineHover.style.display = 'none';
}
});
document.addEventListener('mouseup', e => {
// If zoomed and mouse didn't move past threshold, treat as a seek click
if (isDragging && tlZoom > 1 && !tlMouseDownHandled) {
timelineSeek(e);
}
isDragging = false;
tlPanning = false;
timelineTrack.style.cursor = '';
});
// Scroll wheel on timeline = zoom in/out
timelineTrack.addEventListener('wheel', e => {
e.preventDefault();
const rect = timelineTrack.getBoundingClientRect();
const mousePct = (e.clientX - rect.left) / rect.width;
const vw = getViewWindow();
// Time under cursor
const timeAtCursor = vw.start + mousePct * vw.dur;
const factor = e.deltaY < 0 ? 1.3 : 1 / 1.3;
tlZoom = Math.max(TL_MIN_ZOOM, Math.min(TL_MAX_ZOOM, tlZoom * factor));
// Keep the time under cursor at the same screen position
const newDur = globalDuration / tlZoom;
tlViewCenter = timeAtCursor - (mousePct - 0.5) * newDur;
tlViewCenter = Math.max(0, Math.min(globalDuration, tlViewCenter));
renderTimeline();
updateUI();
}, { passive: false });
// Minimap click to jump view
document.getElementById('timelineMinimap').addEventListener('mousedown', e => {
const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
tlViewCenter = pct * globalDuration;
renderTimeline();
updateUI();
});
// ─── Button Handlers ───
btnPlay.addEventListener('click', togglePlay);
document.getElementById('btnSkipBack').addEventListener('click', () => seekTo(currentTime - 10));
document.getElementById('btnSkipFwd').addEventListener('click', () => seekTo(currentTime + 10));
document.getElementById('btnFrameBack').addEventListener('click', () => frameStep(-1));
document.getElementById('btnFrameFwd').addEventListener('click', () => frameStep(1));
document.getElementById('btnSpeedDown').addEventListener('click', () => setSpeed(currentSpeedIdx - 1));
document.getElementById('btnSpeedUp').addEventListener('click', () => setSpeed(currentSpeedIdx + 1));
document.getElementById('btnFullscreen').addEventListener('click', () => {
if (document.fullscreenElement) document.exitFullscreen();
else playerArea.requestFullscreen();
});
// ─── Keyboard ───
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break;
case 'ArrowLeft':
e.preventDefault();
e.shiftKey ? seekTo(currentTime - 10) : frameStep(-1);
break;
case 'ArrowRight':
e.preventDefault();
e.shiftKey ? seekTo(currentTime + 10) : frameStep(1);
break;
case '[': setSpeed(currentSpeedIdx - 1); break;
case ']': setSpeed(currentSpeedIdx + 1); break;
case 'f': case 'F':
if (document.fullscreenElement) document.exitFullscreen();
else playerArea.requestFullscreen();
break;
case '0':
resetTimelineZoom();
renderTimeline();
updateUI();
break;
case 's': case 'S':
toggleSlideshow();
break;
case 'm': case 'M':
toggleMagnifier();
break;
case 'Escape':
if (magActive) toggleMagnifier();
break;
}
});
// ─── Digital Zoom ───
const btnMagnifier = document.getElementById('btnMagnifier');
let magActive = false;
const zoomState = new Map();
function getZoom(cell) {
if (!zoomState.has(cell)) zoomState.set(cell, { scale: 1, ox: 50, oy: 50 });
return zoomState.get(cell);
}
function applyZoom(cell) {
const z = getZoom(cell);
const videos = cell.querySelectorAll('video:not(.hidden-video)');
for (const v of videos) {
if (z.scale <= 1) {
v.style.transform = '';
v.style.transformOrigin = '';
} else {
v.style.transform = `scale(${z.scale})`;
v.style.transformOrigin = `${z.ox}% ${z.oy}%`;
}
}
cell.classList.toggle('zoomed-in', z.scale > 1);
let indicator = cell.querySelector('.zoom-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'zoom-indicator';
cell.appendChild(indicator);
}
if (z.scale > 1) {
indicator.textContent = `${z.scale.toFixed(1)}x`;
indicator.classList.add('visible');
} else {
indicator.classList.remove('visible');
}
}
function resetZoom(cell) {
zoomState.set(cell, { scale: 1, ox: 50, oy: 50 });
applyZoom(cell);
}
// Scroll-wheel zoom
cameraGrid.addEventListener('wheel', e => {
const cell = e.target.closest('.cam-cell');
if (!cell) return;
e.preventDefault();
const z = getZoom(cell);
const rect = cell.getBoundingClientRect();
const mx = ((e.clientX - rect.left) / rect.width) * 100;
const my = ((e.clientY - rect.top) / rect.height) * 100;
const delta = e.deltaY > 0 ? -0.25 : 0.25;
const newScale = Math.max(1, Math.min(10, z.scale + delta));
if (newScale <= 1) {
z.scale = 1; z.ox = 50; z.oy = 50;
} else {
z.ox = mx; z.oy = my; z.scale = newScale;
}
applyZoom(cell);
}, { passive: false });
// Double-click to reset zoom
cameraGrid.addEventListener('dblclick', e => {
const cell = e.target.closest('.cam-cell');
if (!cell) return;
const z = getZoom(cell);
if (z.scale > 1) {
e.stopPropagation();
resetZoom(cell);
}
});
// ─── Pan (click+drag while zoomed) ───
let panCell = null;
let panStartX = 0, panStartY = 0;
let panStartOx = 0, panStartOy = 0;
cameraGrid.addEventListener('mousedown', e => {
if (magActive) return; // magnifier takes priority
if (e.target.closest('.drag-handle')) return; // drag handle takes priority
const cell = e.target.closest('.cam-cell');
if (!cell) return;
const z = getZoom(cell);
if (z.scale <= 1) return;
e.preventDefault();
e.stopPropagation();
panCell = cell;
panStartX = e.clientX;
panStartY = e.clientY;
panStartOx = z.ox;
panStartOy = z.oy;
cell.classList.add('panning');
});
document.addEventListener('mousemove', e => {
if (!panCell) return;
const z = getZoom(panCell);
const rect = panCell.getBoundingClientRect();
// Convert pixel drag to percentage shift (invert for natural panning)
const dx = ((e.clientX - panStartX) / rect.width) * 100;
const dy = ((e.clientY - panStartY) / rect.height) * 100;
// Clamp origin to keep image from sliding too far off
z.ox = Math.max(0, Math.min(100, panStartOx - dx));
z.oy = Math.max(0, Math.min(100, panStartOy - dy));
applyZoom(panCell);
});
document.addEventListener('mouseup', () => {
if (panCell) {
panCell.classList.remove('panning');
panCell = null;
}
});
// ─── Magnifier tool (one-use) ───
function toggleMagnifier() {
magActive = !magActive;
btnMagnifier.classList.toggle('active', magActive);
document.querySelectorAll('.cam-cell').forEach(c => {
c.classList.toggle('mag-mode', magActive);
});
}
btnMagnifier.addEventListener('click', e => {
e.stopPropagation();
toggleMagnifier();
});
let magDragging = false;
let magStartX = 0, magStartY = 0;
let magCell = null;
let magSelEl = null;
cameraGrid.addEventListener('mousedown', e => {
if (!magActive) return;
const cell = e.target.closest('.cam-cell');
if (!cell) return;
e.preventDefault();
e.stopPropagation();
magDragging = true;
magCell = cell;
const rect = cell.getBoundingClientRect();
magStartX = e.clientX - rect.left;
magStartY = e.clientY - rect.top;
if (!magSelEl) {
magSelEl = document.createElement('div');
magSelEl.className = 'mag-selection';
}
magSelEl.style.left = magStartX + 'px';
magSelEl.style.top = magStartY + 'px';
magSelEl.style.width = '0';
magSelEl.style.height = '0';
magSelEl.classList.add('active');
cell.appendChild(magSelEl);
}, true);
document.addEventListener('mousemove', e => {
if (!magDragging || !magCell) return;
const rect = magCell.getBoundingClientRect();
const curX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const curY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
magSelEl.style.left = Math.min(magStartX, curX) + 'px';
magSelEl.style.top = Math.min(magStartY, curY) + 'px';
magSelEl.style.width = Math.abs(curX - magStartX) + 'px';
magSelEl.style.height = Math.abs(curY - magStartY) + 'px';
});
document.addEventListener('mouseup', e => {
if (!magDragging || !magCell) return;
magDragging = false;
const rect = magCell.getBoundingClientRect();
const curX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const curY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const left = Math.min(magStartX, curX);
const top = Math.min(magStartY, curY);
const w = Math.abs(curX - magStartX);
const h = Math.abs(curY - magStartY);
if (magSelEl) magSelEl.classList.remove('active');
if (w < 10 || h < 10) { magCell = null; return; }
const scale = Math.min(rect.width / w, rect.height / h);
const z = getZoom(magCell);
z.scale = Math.max(1, Math.min(10, scale));
z.ox = ((left + w / 2) / rect.width) * 100;
z.oy = ((top + h / 2) / rect.height) * 100;
applyZoom(magCell);
magCell = null;
// Auto-deactivate after use
toggleMagnifier();
});
// ─── Camera Reorder (drag & drop) ───
let dragSrcCell = null;
let dragSrcChName = null;
function findChForCell(cell) {
for (const ch of channels.values()) {
if (ch.cellEl === cell) return ch.name;
}
return null;
}
function setupDragHandle(cell) {
const handle = document.createElement('div');
handle.className = 'drag-handle';
handle.setAttribute('draggable', 'true');
handle.innerHTML = '<svg viewBox="0 0 24 24"><path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg> move';
handle.title = 'Drag to reorder';
handle.addEventListener('dragstart', e => {
dragSrcCell = cell;
dragSrcChName = findChForCell(cell);
cell.classList.add('drag-source');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
});
handle.addEventListener('dragend', () => {
cell.classList.remove('drag-source');
document.querySelectorAll('.cam-cell.drag-over').forEach(c => c.classList.remove('drag-over'));
dragSrcCell = null;
dragSrcChName = null;
});
cell.addEventListener('dragover', e => {
if (!dragSrcCell || dragSrcCell === cell) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
cell.classList.add('drag-over');
});
cell.addEventListener('dragleave', () => {
cell.classList.remove('drag-over');
});
cell.addEventListener('drop', e => {
e.preventDefault();
cell.classList.remove('drag-over');
if (!dragSrcCell || dragSrcCell === cell) return;
const targetChName = findChForCell(cell);
if (!dragSrcChName || !targetChName) return;
// Swap the two channels' positions in the Map and DOM
swapChannels(dragSrcChName, targetChName);
});
cell.appendChild(handle);
}
function swapChannels(nameA, nameB) {
const chA = channels.get(nameA);
const chB = channels.get(nameB);
if (!chA || !chB) return;
const cellA = chA.cellEl;
const cellB = chB.cellEl;
// Swap DOM positions
const placeholder = document.createElement('div');
cameraGrid.insertBefore(placeholder, cellA);
cameraGrid.insertBefore(cellA, cellB);
cameraGrid.insertBefore(cellB, placeholder);
placeholder.remove();
// Rebuild channel Map to preserve new order
const entries = [...channels.entries()];
const idxA = entries.findIndex(([n]) => n === nameA);
const idxB = entries.findIndex(([n]) => n === nameB);
[entries[idxA], entries[idxB]] = [entries[idxB], entries[idxA]];
channels.clear();
for (const [n, ch] of entries) channels.set(n, ch);
// Update colors to match new positions
let i = 0;
for (const ch of channels.values()) {
ch.color = CAM_COLORS[i % CAM_COLORS.length];
ch.cellEl.style.setProperty('--cam-color', ch.color);
const dot = ch.cellEl.querySelector('.cam-dot');
if (dot) dot.style.background = ch.color;
i++;
}
renderTimeline();
}
// ─── Slideshow Mode ───
const btnSlideshow = document.getElementById('btnSlideshow');
const slideshowContainer = document.getElementById('slideshowContainer');
const slideshowStage = document.getElementById('slideshowStage');
let slideshowActive = false;
let ssActiveList = []; // current list of active cameras
let ssIndex = 0; // which camera in ssActiveList we're showing
let ssCurrentName = ''; // name of currently displayed camera (for change detection)
let ssRotateTimer = null; // auto-advance timer
const SS_ROTATE_INTERVAL = 5000; // ms between auto-advances
function toggleSlideshow() {
slideshowActive = !slideshowActive;
btnSlideshow.classList.toggle('active', slideshowActive);
playerArea.classList.toggle('slideshow-mode', slideshowActive);
slideshowContainer.classList.toggle('active', slideshowActive);
if (slideshowActive) {
ssIndex = 0;
ssCurrentName = '';
updateSlideshow();
startSsRotation();
} else {
stopSsRotation();
// Destroy slideshow video elements before clearing
slideshowStage.querySelectorAll('video').forEach(v => destroyVideoEl(v));
slideshowStage.innerHTML = '';
ssActiveList = [];
ssCurrentName = '';
}
}
function startSsRotation() {
stopSsRotation();
ssRotateTimer = setInterval(() => {
if (!slideshowActive || ssActiveList.length <= 1) return;
ssIndex = (ssIndex + 1) % ssActiveList.length;
ssCurrentName = ''; // force rebuild
updateSlideshow();
}, SS_ROTATE_INTERVAL);
}
function stopSsRotation() {
if (ssRotateTimer) { clearInterval(ssRotateTimer); ssRotateTimer = null; }
}
btnSlideshow.addEventListener('click', e => {
e.stopPropagation();
toggleSlideshow();
});
function getActiveCamerasAtTime(absTime) {
const active = [];
for (const ch of channels.values()) {
const idx = findActiveSegment(ch, absTime);
if (idx >= 0) {
active.push({ ch, segIdx: idx, seg: ch.segments[idx] });
}
}
return active;
}
function updateSlideshow() {
if (!slideshowActive) return;
const absTime = globalStart + currentTime;
const active = getActiveCamerasAtTime(absTime);
ssActiveList = active;
if (active.length === 0) {
// No active feeds — show placeholder if not already showing
if (ssCurrentName !== '__none__') {
ssCurrentName = '__none__';
transitionSsPane(null, 0, 0);
}
return;
}
// Keep index in bounds
if (ssIndex >= active.length) ssIndex = 0;
// If the camera we were showing is still active, stay on it
if (ssCurrentName) {
const stillIdx = active.findIndex(a => a.ch.name === ssCurrentName);
if (stillIdx >= 0) {
ssIndex = stillIdx;
} else {
// Current camera went inactive — jump to next available
if (ssIndex >= active.length) ssIndex = 0;
}
}
const current = active[ssIndex];
const showingName = current.ch.name + ':' + current.segIdx;
if (showingName !== ssCurrentName) {
// Camera changed — animate transition
ssCurrentName = showingName;
transitionSsPane(current, active.length, absTime);
} else {
// Same camera — just sync video time
syncSsVideo(absTime);
}
}
function transitionSsPane(activeCam, totalActive, absTime) {
// Animate out old pane and destroy its video elements
const oldPanes = slideshowStage.querySelectorAll('.ss-pane');
for (const p of oldPanes) {
p.classList.add('ss-exit');
}
setTimeout(() => {
for (const p of oldPanes) {
p.querySelectorAll('video').forEach(v => destroyVideoEl(v));
p.remove();
}
}, 300);
// Build new pane
const pane = document.createElement('div');
pane.className = 'ss-pane';
if (!activeCam) {
pane.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#555;font-size:14px;letter-spacing:2px;text-transform:uppercase;">No Active Feeds</div>';
slideshowStage.appendChild(pane);
return;
}
const video = document.createElement('video');
video.src = activeCam.seg.url;
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
const localTime = absTime - activeCam.seg.startTime;
video.currentTime = Math.max(0, localTime);
video.playbackRate = SPEEDS[currentSpeedIdx];
if (isPlaying) video.play();
pane.appendChild(video);
// Camera label
const label = document.createElement('div');
label.className = 'ss-label';
const dot = document.createElement('span');
dot.className = 'cam-dot';
dot.style.background = activeCam.ch.color;
const nameSpan = document.createElement('span');
nameSpan.textContent = activeCam.ch.name;
label.appendChild(dot);
label.appendChild(nameSpan);
pane.appendChild(label);
// Counter (e.g. "2 / 5")
if (totalActive > 1) {
const counter = document.createElement('div');
counter.className = 'ss-counter';
counter.textContent = `${ssIndex + 1} / ${totalActive}`;
pane.appendChild(counter);
}
pane._ssVideo = video;
pane._ssSeg = activeCam.seg;
pane._ssCh = activeCam.ch;
slideshowStage.appendChild(pane);
// Reset rotation timer on manual or auto transitions
startSsRotation();
}
function syncSsVideo(absTime) {
const pane = slideshowStage.querySelector('.ss-pane:not(.ss-exit)');
if (!pane || !pane._ssVideo || !pane._ssSeg) return;
const localTime = absTime - pane._ssSeg.startTime;
const drift = Math.abs(pane._ssVideo.currentTime - localTime);
if (drift > 0.5) {
pane._ssVideo.currentTime = Math.max(0, localTime);
}
pane._ssVideo.playbackRate = SPEEDS[currentSpeedIdx];
}
// Sync slideshow play/pause state
const origPlay = play;
play = function() {
origPlay();
if (slideshowActive) {
const v = slideshowStage.querySelector('.ss-pane:not(.ss-exit) video');
if (v) { v.playbackRate = SPEEDS[currentSpeedIdx]; v.play(); }
}
};
const origPause = pause;
pause = function() {
origPause();
if (slideshowActive) {
const v = slideshowStage.querySelector('.ss-pane:not(.ss-exit) video');
if (v) v.pause();
}
};
// ─── AES-256 for Encrypted ZIPs ───
// AES S-box
const SB = new Uint8Array([
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
]);
const RCON = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36];
// Generate T-tables for fast AES rounds
const T0=new Uint32Array(256),T1=new Uint32Array(256),T2=new Uint32Array(256),T3=new Uint32Array(256);
(function(){
function xt(a){return((a<<1)^((a>>7)*0x1b))&0xff;}
for(let i=0;i<256;i++){
const s=SB[i],x=xt(s),x3=x^s;
T0[i]=(x<<24|s<<16|s<<8|x3)>>>0;
T1[i]=(x3<<24|x<<16|s<<8|s)>>>0;
T2[i]=(s<<24|x3<<16|x<<8|s)>>>0;
T3[i]=(s<<24|s<<16|x3<<8|x)>>>0;
}
})();
function aesExpandKey(key){
const rk=new Uint32Array(60);
for(let i=0;i<8;i++) rk[i]=(key[4*i]<<24|key[4*i+1]<<16|key[4*i+2]<<8|key[4*i+3])>>>0;
for(let i=8;i<60;i++){
let t=rk[i-1];
if(i%8===0){
t=(SB[(t>>16)&0xff]<<24|SB[(t>>8)&0xff]<<16|SB[t&0xff]<<8|SB[t>>>24])>>>0;
t=(t^(RCON[i/8-1]<<24))>>>0;
}else if(i%8===4){
t=(SB[t>>>24]<<24|SB[(t>>16)&0xff]<<16|SB[(t>>8)&0xff]<<8|SB[t&0xff])>>>0;
}
rk[i]=(rk[i-8]^t)>>>0;
}
return rk;
}
function aesEncBlock(b,rk){
let s0=(b[0]<<24|b[1]<<16|b[2]<<8|b[3])^rk[0],
s1=(b[4]<<24|b[5]<<16|b[6]<<8|b[7])^rk[1],
s2=(b[8]<<24|b[9]<<16|b[10]<<8|b[11])^rk[2],
s3=(b[12]<<24|b[13]<<16|b[14]<<8|b[15])^rk[3];
for(let r=1;r<14;r++){
const t0=T0[s0>>>24]^T1[(s1>>16)&0xff]^T2[(s2>>8)&0xff]^T3[s3&0xff]^rk[4*r],
t1=T0[s1>>>24]^T1[(s2>>16)&0xff]^T2[(s3>>8)&0xff]^T3[s0&0xff]^rk[4*r+1],
t2=T0[s2>>>24]^T1[(s3>>16)&0xff]^T2[(s0>>8)&0xff]^T3[s1&0xff]^rk[4*r+2],
t3=T0[s3>>>24]^T1[(s0>>16)&0xff]^T2[(s1>>8)&0xff]^T3[s2&0xff]^rk[4*r+3];
s0=t0>>>0;s1=t1>>>0;s2=t2>>>0;s3=t3>>>0;
}
const o=new Uint8Array(16),k=56;
o[0]=SB[s0>>>24]^(rk[k]>>>24); o[1]=SB[(s1>>16)&0xff]^((rk[k]>>16)&0xff);
o[2]=SB[(s2>>8)&0xff]^((rk[k]>>8)&0xff); o[3]=SB[s3&0xff]^(rk[k]&0xff);
o[4]=SB[s1>>>24]^(rk[k+1]>>>24); o[5]=SB[(s2>>16)&0xff]^((rk[k+1]>>16)&0xff);
o[6]=SB[(s3>>8)&0xff]^((rk[k+1]>>8)&0xff); o[7]=SB[s0&0xff]^(rk[k+1]&0xff);
o[8]=SB[s2>>>24]^(rk[k+2]>>>24); o[9]=SB[(s3>>16)&0xff]^((rk[k+2]>>16)&0xff);
o[10]=SB[(s0>>8)&0xff]^((rk[k+2]>>8)&0xff); o[11]=SB[s1&0xff]^(rk[k+2]&0xff);
o[12]=SB[s3>>>24]^(rk[k+3]>>>24); o[13]=SB[(s0>>16)&0xff]^((rk[k+3]>>16)&0xff);
o[14]=SB[(s1>>8)&0xff]^((rk[k+3]>>8)&0xff); o[15]=SB[s2&0xff]^(rk[k+3]&0xff);
return o;
}
// AES-CTR decryption with little-endian counter (WinZip AES format)
function aesCtrDecrypt(encKey, data) {
const rk = aesExpandKey(encKey);
const out = new Uint8Array(data.length);
const ctr = new Uint8Array(16);
ctr[0] = 1; // WinZip starts at 1
for (let off = 0; off < data.length; off += 16) {
const ks = aesEncBlock(ctr, rk);
const len = Math.min(16, data.length - off);
for (let j = 0; j < len; j++) out[off + j] = data[off + j] ^ ks[j];
// Increment LE counter (read stored value after increment to handle Uint8Array wrapping)
for (let j = 0; j < 16; j++) { ctr[j] = (ctr[j] + 1) & 0xff; if (ctr[j] !== 0) break; }
}
return out;
}
// ─── ZIP Encrypted Entry Parser ───
function readU16(buf, off) { return buf[off] | (buf[off+1] << 8); }
function readU32(buf, off) { return (buf[off] | (buf[off+1]<<8) | (buf[off+2]<<16) | (buf[off+3]<<24)) >>> 0; }
function isZipEncrypted(buf) {
const d = new Uint8Array(buf);
// Check first local file header
if (d.length < 30) return false;
if (readU32(d, 0) !== 0x04034b50) return false;
const flags = readU16(d, 6);
return (flags & 1) !== 0;
}
// Parse ZIP and extract encrypted entries
function parseEncryptedZip(buf) {
const d = new Uint8Array(buf);
const entries = [];
// Find End of Central Directory
let eocdOff = -1;
for (let i = d.length - 22; i >= 0; i--) {
if (readU32(d, i) === 0x06054b50) { eocdOff = i; break; }
}
if (eocdOff === -1) return entries;
const cdOffset = readU32(d, eocdOff + 16);
const cdEntries = readU16(d, eocdOff + 10);
// Parse central directory
let pos = cdOffset;
for (let e = 0; e < cdEntries; e++) {
if (readU32(d, pos) !== 0x02014b50) break;
const flags = readU16(d, pos + 8);
const method = readU16(d, pos + 10);
const compSize = readU32(d, pos + 20);
const uncompSize = readU32(d, pos + 24);
const nameLen = readU16(d, pos + 28);
const extraLen = readU16(d, pos + 30);
const commentLen = readU16(d, pos + 32);
const localOff = readU32(d, pos + 42);
const name = new TextDecoder().decode(d.slice(pos + 46, pos + 46 + nameLen));
// Parse AES extra field from central directory extra
let aesStrength = 0, actualMethod = 0;
if (method === 99) {
const extraData = d.slice(pos + 46 + nameLen, pos + 46 + nameLen + extraLen);
for (let x = 0; x < extraData.length - 4;) {
const hid = readU16(extraData, x);
const hlen = readU16(extraData, x + 2);
if (hid === 0x9901 && hlen >= 7) {
aesStrength = extraData[x + 8]; // 1=128,2=192,3=256
actualMethod = readU16(extraData, x + 9);
}
x += 4 + hlen;
}
}
if ((flags & 1) && method === 99 && aesStrength > 0) {
// Find data offset from local file header
const lNameLen = readU16(d, localOff + 26);
const lExtraLen = readU16(d, localOff + 28);
const dataOff = localOff + 30 + lNameLen + lExtraLen;
const saltSize = [0, 8, 12, 16][aesStrength];
const salt = d.slice(dataOff, dataOff + saltSize);
const pwVerify = d.slice(dataOff + saltSize, dataOff + saltSize + 2);
const authCode = d.slice(dataOff + compSize - 10, dataOff + compSize);
const encData = d.slice(dataOff + saltSize + 2, dataOff + compSize - 10);
entries.push({
name, compSize, uncompSize, aesStrength, actualMethod,
salt, pwVerify, authCode, encData,
isDir: name.endsWith('/'),
});
}
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
async function deriveAesKey(password, salt, strength) {
const keyLen = [0, 16, 24, 32][strength];
const derivedLen = keyLen * 2 + 2; // encKey + hmacKey + verification
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 1000, hash: 'SHA-1' },
keyMaterial, derivedLen * 8
);
const derived = new Uint8Array(bits);
return {
encKey: derived.slice(0, keyLen),
hmacKey: derived.slice(keyLen, keyLen * 2),
verify: derived.slice(keyLen * 2, keyLen * 2 + 2),
};
}
async function inflateData(data) {
try {
const ds = new DecompressionStream('deflate-raw');
const writer = ds.writable.getWriter();
writer.write(data);
writer.close();
const reader = ds.readable.getReader();
const chunks = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
total += value.length;
}
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.length; }
return out;
} catch {
return data; // fallback: return as-is
}
}
async function decryptZipEntries(buf, password) {
const entries = parseEncryptedZip(buf);
if (entries.length === 0) throw new Error('No encrypted entries found');
// Verify password with first entry
const first = entries[0];
const keys = await deriveAesKey(password, first.salt, first.aesStrength);
if (keys.verify[0] !== first.pwVerify[0] || keys.verify[1] !== first.pwVerify[1]) {
throw new Error('wrong_password');
}
const decrypted = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isDir) continue;
loadingDetail.textContent = `Decrypting ${i + 1}/${entries.length}: ${entry.name.split('/').pop()}`;
const ek = (i === 0) ? keys : await deriveAesKey(password, entry.salt, entry.aesStrength);
let data = aesCtrDecrypt(ek.encKey, entry.encData);
// Decompress if needed
if (entry.actualMethod === 8) {
data = await inflateData(data);
}
decrypted.push({ name: entry.name, data });
}
return decrypted;
}
// ─── Password Prompt ───
function promptPassword(errorMsg) {
return new Promise((resolve, reject) => {
const overlay = document.getElementById('passwordModal');
const input = document.getElementById('pwInput');
const errorEl = document.getElementById('pwError');
const submitBtn = document.getElementById('pwSubmit');
const cancelBtn = document.getElementById('pwCancel');
overlay.classList.remove('hidden');
input.value = '';
errorEl.textContent = errorMsg || '';
input.focus();
function cleanup() {
overlay.classList.add('hidden');
submitBtn.removeEventListener('click', onSubmit);
cancelBtn.removeEventListener('click', onCancel);
input.removeEventListener('keydown', onKey);
}
function onSubmit() {
const pw = input.value;
if (!pw) { errorEl.textContent = 'Please enter a password'; return; }
cleanup();
resolve(pw);
}
function onCancel() { cleanup(); reject(new Error('cancelled')); }
function onKey(e) { if (e.key === 'Enter') onSubmit(); else if (e.key === 'Escape') onCancel(); }
submitBtn.addEventListener('click', onSubmit);
cancelBtn.addEventListener('click', onCancel);
input.addEventListener('keydown', onKey);
});
}
// ─── File Handling ───
const zipFileDataForVerify = new Map();
let lastZipPassword = null; // cached password for multi-ZIP uploads
async function processDecryptedEntries(decrypted, zipName) {
// Process SIGN files first so signatures are available
let zipCertPem = null, zipCertBytes = null;
const zipSigs = new Map();
for (const entry of decrypted) {
const name = entry.name.split('/').pop();
if (!entry.name.includes('SIGN/')) continue;
if (name === 'certificate.pem') {
zipCertBytes = entry.data;
zipCertPem = new TextDecoder().decode(entry.data);
} else if (/\.sig$/i.test(name)) {
const origName = name.replace(/\.sig$/i, '');
zipSigs.set(origName, entry.data);
}
}
// Register this ZIP's certificate and signatures
if (zipCertPem && zipSigs.size > 0) {
const certId = zipCertPem;
if (!signCerts.has(certId)) {
signCerts.set(certId, { pem: zipCertPem, bytes: zipCertBytes, signatures: new Map() });
}
const cert = signCerts.get(certId);
for (const [fn, sig] of zipSigs) cert.signatures.set(fn, sig);
avpLog('info', `Found certificate with ${zipSigs.size} signature(s) in ${zipName}`);
}
// Process metadata and videos
for (const entry of decrypted) {
const name = entry.name.split('/').pop();
if (entry.name.includes('SIGN/')) continue;
if (/\.txt$/i.test(name)) {
loadingDetail.textContent = `Reading ${name}`;
const text = new TextDecoder().decode(entry.data);
pendingMetas.push({ fileName: name, meta: parseMeta(text) });
zipFileDataForVerify.set(name, entry.data);
} else if (/\.(mp4|mov|avi|mkv)$/i.test(name)) {
loadingDetail.textContent = `Processing ${name}`;
if (zipSigs.has(name)) {
zipFileDataForVerify.set(name, entry.data);
}
pendingVideos.push({ fileName: name, blob: new Blob([entry.data]) });
}
}
}
async function processZip(zipBlob, zipName) {
avpLog('info', 'Processing ZIP: ' + zipName);
showLoading('Extracting ZIP...', zipName);
try {
const buf = await zipBlob.arrayBuffer();
// Check if ZIP is encrypted (AES)
if (isZipEncrypted(buf)) {
let password;
let pwError = null;
// Try cached password first
if (lastZipPassword) {
try {
showLoading('Decrypting ZIP...', zipName);
const decrypted = await decryptZipEntries(buf, lastZipPassword);
password = lastZipPassword;
avpLog('success', 'ZIP decrypted with cached password: ' + zipName);
await processDecryptedEntries(decrypted, zipName);
return;
} catch (err) {
if (err.message === 'wrong_password') {
avpLog('info', 'Cached password did not work for: ' + zipName);
// Fall through to manual prompt
} else {
throw err;
}
}
}
while (true) {
try {
password = await promptPassword(pwError);
} catch {
// User cancelled
return;
}
try {
showLoading('Decrypting ZIP...', zipName);
const decrypted = await decryptZipEntries(buf, password);
lastZipPassword = password;
avpLog('success', 'ZIP decrypted successfully: ' + zipName);
await processDecryptedEntries(decrypted, zipName);
break; // success
} catch (err) {
if (err.message === 'wrong_password') {
avpLog('warn', 'Wrong password for ZIP: ' + zipName);
pwError = 'Wrong password, please try again.';
continue; // re-prompt
}
throw err;
}
}
return;
}
// Not encrypted — use JSZip
const zip = await JSZip.loadAsync(buf);
const entries = Object.entries(zip.files).filter(([, f]) => !f.dir);
const txts = [];
const videos = [];
const signFiles = [];
for (const [path, file] of entries) {
const name = path.split('/').pop();
if (path.includes('SIGN/')) {
signFiles.push({ path, name, file });
} else if (/\.txt$/i.test(name)) {
txts.push({ path, name, file });
} else if (/\.(mp4|mov|avi|mkv)$/i.test(name)) {
videos.push({ path, name, file });
}
}
// Extract SIGN/ files
let zipCertPem = null, zipCertBytes = null;
const zipSigs = new Map();
for (const sf of signFiles) {
if (sf.name === 'certificate.pem') {
const bytes = await sf.file.async('uint8array');
zipCertBytes = bytes;
zipCertPem = new TextDecoder().decode(bytes);
} else if (/\.sig$/i.test(sf.name)) {
const origName = sf.name.replace(/\.sig$/i, '');
zipSigs.set(origName, await sf.file.async('uint8array'));
}
}
// Register this ZIP's certificate and signatures
if (zipCertPem && zipSigs.size > 0) {
const certId = zipCertPem;
if (!signCerts.has(certId)) {
signCerts.set(certId, { pem: zipCertPem, bytes: zipCertBytes, signatures: new Map() });
}
const cert = signCerts.get(certId);
for (const [fn, sig] of zipSigs) cert.signatures.set(fn, sig);
avpLog('info', `Found certificate with ${zipSigs.size} signature(s) in ${zipName}`);
}
// Read metadata
for (const t of txts) {
loadingDetail.textContent = `Reading ${t.name}`;
const text = await t.file.async('text');
pendingMetas.push({ fileName: t.name, meta: parseMeta(text) });
zipFileDataForVerify.set(t.name, new TextEncoder().encode(text));
}
// Extract videos — extract as uint8array once if signature exists, otherwise as blob
for (let i = 0; i < videos.length; i++) {
const v = videos[i];
loadingDetail.textContent = `Extracting video ${i + 1}/${videos.length}: ${v.name}`;
if (zipSigs.has(v.name)) {
const raw = await v.file.async('uint8array');
zipFileDataForVerify.set(v.name, raw);
pendingVideos.push({ fileName: v.name, blob: new Blob([raw]) });
} else {
pendingVideos.push({ fileName: v.name, blob: await v.file.async('blob') });
}
}
} catch (err) {
avpLog('error', 'ZIP extraction error: ' + err.message);
}
}
async function handleFiles(fileList) {
if (isImporting) {
avpLog('warn', 'Import already in progress — please wait');
return;
}
isImporting = true;
try {
const files = Array.from(fileList);
const zips = files.filter(f => /\.zip$/i.test(f.name));
const txts = files.filter(f => /\.txt$/i.test(f.name));
const vids = files.filter(f => /\.(mp4|mov|avi|mkv)$/i.test(f.name));
avpLog('info', `Received ${files.length} file(s): ${zips.length} ZIP, ${vids.length} video, ${txts.length} metadata`);
if (zips.length > 0) {
showLoading('Processing ZIP files...', `${zips.length} archive(s)`);
}
for (let i = 0; i < zips.length; i++) {
await processZip(zips[i], `${i + 1}/${zips.length}: ${zips[i].name}`);
}
for (const f of txts) {
const text = await f.text();
pendingMetas.push({ fileName: f.name, meta: parseMeta(text) });
avpLog('info', 'Loaded metadata: ' + f.name);
}
for (const f of vids) {
pendingVideos.push({ fileName: f.name, blob: f });
avpLog('info', 'Loaded video: ' + f.name);
}
flushPending();
hideLoading();
if (signCerts.size > 0) {
runVerification();
}
// Cache session for refresh persistence
saveSessionData();
} catch (err) {
avpLog('error', 'File import error: ' + err.message);
hideLoading();
} finally {
isImporting = false;
}
}
// ─── New Session ───
// Properly destroy a video element to release memory
function destroyVideoEl(videoEl) {
if (!videoEl) return;
videoEl.pause();
videoEl.removeAttribute('src');
videoEl.load(); // forces browser to release buffered data
}
// Guard against concurrent imports
let isImporting = false;
function newSession() {
avpLog('info', 'New session started');
// Clear cached session
clearSessionDB();
// Stop playback
if (isPlaying) pause();
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
// Turn off slideshow BEFORE destroying video elements
if (slideshowActive) toggleSlideshow();
// Destroy all slideshow video elements that may linger
slideshowStage.querySelectorAll('video').forEach(v => destroyVideoEl(v));
slideshowStage.innerHTML = '';
// Properly destroy all video elements and revoke blob URLs
for (const [, ch] of channels) {
for (const seg of ch.segments) {
destroyVideoEl(seg.videoEl);
seg.videoEl = null;
if (seg.url) URL.revokeObjectURL(seg.url);
seg.url = null;
seg.blob = null; // release blob reference for GC
}
ch.segments.length = 0;
ch.cellEl = null;
ch._noSignalEl = null;
ch._tsEl = null;
ch._segInfoEl = null;
}
// Clear data structures
channels.clear();
pendingVideos.length = 0;
pendingMetas.length = 0;
signCerts.clear();
zipFileDataForVerify.clear();
lastZipPassword = null;
zoomState.clear();
verifyCertInfos = [];
verifyResults = [];
verifyStatus = 'none';
gridLayoutOverride = null;
hiddenCameras.clear();
isImporting = false;
layoutPresets.querySelectorAll('.layout-preset-btn').forEach(b => b.classList.remove('active'));
layoutPresets.querySelector('[data-layout="auto"]').classList.add('active');
// Reset global state
globalStart = Infinity;
globalEnd = -Infinity;
globalDuration = 0;
currentTime = 0;
currentSpeedIdx = 2;
expandedChannel = null;
tlZoom = 1;
tlViewCenter = 0;
// Reset DOM — destroy any remaining video elements first
cameraGrid.querySelectorAll('video').forEach(v => destroyVideoEl(v));
cameraGrid.innerHTML = '';
cameraGrid.className = 'camera-grid';
timelineTrack.querySelectorAll('.timeline-segment, .timeline-gap, .timeline-tick, .timeline-tick-label').forEach(el => el.remove());
playhead.style.left = '0%';
camCount.textContent = '';
document.getElementById('tlZoomInfo').textContent = '';
document.getElementById('timelineMinimap').classList.remove('visible');
// Reset verification badge
const badge = document.getElementById('verifyBadge');
const dot = badge.querySelector('.verify-dot');
dot.className = 'verify-dot';
badge.querySelector('.verify-label').textContent = 'Integrity';
// Reset speed display
document.getElementById('speedDisplay').textContent = '1×';
// Hide player and panels, show drop zone
playerArea.classList.remove('active');
playerArea.classList.remove('has-expanded');
dropZone.classList.remove('hidden');
addFilesBtn.style.display = 'none';
document.getElementById('newSessionBtn').style.display = 'none';
toggleLayoutPanel(true);
// Reset timeline labels
document.getElementById('tlStart').textContent = '--:--';
document.getElementById('tlEnd').textContent = '--:--';
// Reset time display
timeDisplay.textContent = '00:00:00 / 00:00:00';
}
// ─── Drag & Drop ───
dropZone.addEventListener('click', () => fileInput.click());
addFilesBtn.addEventListener('click', () => fileInput.click());
document.getElementById('newSessionBtn').addEventListener('click', newSession);
fileInput.addEventListener('change', e => handleFiles(e.target.files));
document.addEventListener('dragover', e => {
e.preventDefault();
if (!dropZone.classList.contains('hidden')) dropZone.classList.add('drag-over');
});
document.addEventListener('dragleave', e => {
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
dropZone.classList.remove('drag-over');
}
});
document.addEventListener('drop', async e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const items = e.dataTransfer.items;
if (items) {
const allFiles = await collectDroppedFiles(items);
if (allFiles.length > 0) await handleFiles(allFiles);
} else {
await handleFiles(e.dataTransfer.files);
}
});
// ─── Verification ───
async function runVerification() {
setVerifyStatus('verifying', 'Verifying...');
verifyResults = [];
verifyCertInfos = [];
avpLog('info', `Starting verification for ${signCerts.size} certificate(s)`);
let allPassed = true;
let totalFiles = 0;
let anyCloudUnverified = false;
for (const [certId, certEntry] of signCerts) {
const certInfo = parseCertificate(certEntry.pem);
if (certInfo.error) {
avpLog('error', 'Invalid certificate: ' + certInfo.error);
setVerifyStatus('failed', 'Invalid Certificate');
verifyResults.push({ certId, fileName: 'certificate.pem', passed: false, detail: certInfo.error });
verifyCertInfos.push({ certId, certInfo: null, cloudResult: null, error: certInfo.error });
allPassed = false;
continue;
}
const certResults = [];
for (const [fileName, sig] of certEntry.signatures) {
const fileData = zipFileDataForVerify.get(fileName);
if (!fileData) continue;
totalFiles++;
const passed = await verifySignature(certEntry.pem, fileData, sig);
certResults.push({ certId, fileName, passed });
avpLog(passed ? 'success' : 'error', `${fileName}: signature ${passed ? 'valid' : 'FAILED'} (${certInfo.commonName})`);
if (!passed) allPassed = false;
}
verifyResults.push(...certResults);
// Cloud verification per certificate
let cloudResult = null;
if (certResults.length > 0 && certResults.every(r => r.passed)) {
setVerifyStatus('verifying', `Checking cloud (${certInfo.commonName})...`);
cloudResult = await verifyCertificateOnline(certEntry.bytes, certInfo.commonName);
avpLog(cloudResult.verified ? 'success' : 'warn', `Cloud check for ${certInfo.commonName}: ${cloudResult.verified ? 'verified' : (cloudResult.error || 'not verified')}`);
if (!cloudResult.verified) anyCloudUnverified = true;
}
verifyCertInfos.push({ certId, certInfo, cloudResult });
}
zipFileDataForVerify.clear();
if (totalFiles === 0) {
setVerifyStatus('none', 'No Signatures');
avpLog('info', 'No file data available for signature verification');
return;
}
if (!allPassed) {
setVerifyStatus('failed', 'Integrity Failed');
} else if (anyCloudUnverified) {
setVerifyStatus('unverified', 'Unverified');
} else {
setVerifyStatus('success', 'Verified');
}
}
function setVerifyStatus(status, label) {
verifyStatus = status;
verifyBadge.className = `verify-badge visible status-${status}`;
verifyLabel.textContent = label;
}
// Modal
verifyBadge.addEventListener('click', () => {
const statusLabels = {
success: 'All files verified — certificates confirmed by Alta cloud.',
unverified: 'All file signatures are valid, but one or more certificates could not be confirmed by the Alta cloud service.',
failed: 'One or more files failed signature verification. The export may have been tampered with.',
verifying: 'Verification in progress...',
none: 'No SIGN data found in the export. Integrity verification is not available.',
};
verifySummary.textContent = statusLabels[verifyStatus] || '';
verifyCertInfoEl.style.display = 'none';
verifyCertInfoEl.textContent = '';
verifyFileList.innerHTML = '';
if (verifyCertInfos.length > 0) {
for (const entry of verifyCertInfos) {
// Device header
const header = document.createElement('li');
header.style.cssText = 'font-size:12px; font-weight:600; padding:8px 0 4px; border-bottom:1px solid var(--border-default); margin-top:8px; display:flex; align-items:center; gap:6px;';
if (entry.certInfo) {
header.textContent = 'Device S/N: ' + entry.certInfo.commonName;
if (entry.cloudResult) {
const cloudTag = document.createElement('span');
cloudTag.style.cssText = `font-size:10px; font-weight:500; padding:1px 6px; border-radius:3px; margin-left:auto;`;
if (entry.cloudResult.verified) {
cloudTag.style.background = 'rgba(32,198,47,0.15)';
cloudTag.style.color = 'var(--status-success)';
cloudTag.textContent = 'Cloud verified';
} else {
cloudTag.style.background = 'rgba(234,163,1,0.15)';
cloudTag.style.color = 'var(--status-warning)';
cloudTag.textContent = entry.cloudResult.error || 'Not verified';
}
header.appendChild(cloudTag);
}
} else {
header.style.color = 'var(--status-error)';
header.textContent = 'Invalid certificate: ' + (entry.error || 'parse error');
}
verifyFileList.appendChild(header);
// File results for this certificate
const certResults = verifyResults.filter(r => r.certId === entry.certId);
for (const r of certResults) {
const li = document.createElement('li');
const icon = document.createElement('span');
icon.className = 'vf-icon';
icon.textContent = r.passed ? '\u2705' : '\u274C';
const name = document.createElement('span');
name.className = 'vf-name';
name.textContent = r.fileName;
li.appendChild(icon);
li.appendChild(name);
verifyFileList.appendChild(li);
}
}
}
verifyModal.classList.remove('hidden');
});
document.getElementById('verifyModalClose').addEventListener('click', () => {
verifyModal.classList.add('hidden');
});
verifyModal.addEventListener('click', (e) => {
if (e.target === verifyModal) verifyModal.classList.add('hidden');
});
function collectDroppedFiles(items) {
return new Promise(resolve => {
const allFiles = [];
let pending = 0;
let hasEntries = false;
function checkDone() {
if (pending === 0 && hasEntries) resolve(allFiles);
}
function processEntry(entry) {
if (entry.isFile) {
pending++;
entry.file(f => {
allFiles.push(f);
pending--;
checkDone();
});
} else if (entry.isDirectory) {
pending++;
const reader = entry.createReader();
readAllEntries(reader, entries => {
for (const ent of entries) processEntry(ent);
pending--;
checkDone();
});
}
}
function readAllEntries(reader, cb) {
const all = [];
function read() {
reader.readEntries(entries => {
if (entries.length === 0) { cb(all); return; }
all.push(...entries);
read();
});
}
read();
}
for (const item of items) {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
hasEntries = true;
processEntry(entry);
}
}
// If no entries found at all, resolve immediately
if (!hasEntries) resolve(allFiles);
});
}
// ─── Restore cached session on page load ───
loadSessionData().then(restored => {
if (restored) avpLog('info', 'Session restored from cache');
});
})();
</script>
</body>
</html>