4086 lines
152 KiB
HTML
4086 lines
152 KiB
HTML
<!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; }
|
||
|
||
/* Dewarp button & canvas */
|
||
.dewarp-btn {
|
||
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
|
||
background: rgba(0,0,0,0.75); border: 1px solid var(--border-default); color: var(--text-secondary);
|
||
padding: 4px 12px; border-radius: 4px; font-size: 11px; font-weight: 600;
|
||
cursor: pointer; z-index: 5; opacity: 0; transition: opacity 0.15s;
|
||
display: flex; align-items: center; gap: 5px; letter-spacing: 0.3px;
|
||
pointer-events: none;
|
||
}
|
||
.cam-cell:hover .dewarp-btn { opacity: 1; pointer-events: auto; }
|
||
.dewarp-btn:hover { background: rgba(0,0,0,0.9); color: var(--text-primary); border-color: var(--accent-primary); box-shadow: var(--shadow-focus); }
|
||
.dewarp-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); opacity: 1; pointer-events: auto; }
|
||
.dewarp-btn svg { width: 14px; height: 14px; fill: currentColor; }
|
||
.dewarp-canvas {
|
||
position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1;
|
||
display: none;
|
||
}
|
||
.dewarp-canvas.active { display: block; }
|
||
.cam-cell.dewarped video { visibility: hidden; }
|
||
.cam-cell.dewarped .no-signal { z-index: 0; }
|
||
.cam-cell.dewarped { cursor: grab; }
|
||
.cam-cell.dewarped.panning { cursor: grabbing; }
|
||
|
||
/* 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>←</kbd><kbd>→</kbd> Frame step</span>
|
||
<span><kbd>Shift</kbd>+<kbd>←</kbd><kbd>→</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>×</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">🔒</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, '');
|
||
}
|
||
|
||
// ─── 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 = 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 = 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;
|
||
|
||
// Dewarp button
|
||
const dewarpBtn = document.createElement('button');
|
||
dewarpBtn.className = 'dewarp-btn';
|
||
dewarpBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg> Dewarp';
|
||
dewarpBtn.title = 'Toggle fisheye dewarp';
|
||
dewarpBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleDewarp(cell, ch.name);
|
||
});
|
||
cell.appendChild(dewarpBtn);
|
||
|
||
setupDragHandle(cell);
|
||
|
||
cell.addEventListener('click', (e) => {
|
||
if (magActive) return;
|
||
if (e.target.closest('.drag-handle')) return;
|
||
if (e.target.closest('.dewarp-btn')) return;
|
||
// Don't expand when dewarped (click is used for pan)
|
||
const _ds = dewarpState.get(ch.name);
|
||
if (_ds && _ds.active) 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 (skip if dewarped)
|
||
if (ch.cellEl) {
|
||
const _ds = dewarpState.get(ch.name);
|
||
if (!_ds || !_ds.active) 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();
|
||
|
||
// Dewarp zoom: adjust FOV
|
||
const ds = getDewarpForCell(cell);
|
||
if (ds && ds.active) {
|
||
const delta = e.deltaY > 0 ? 0.05 : -0.05;
|
||
ds.fov = Math.max(0.2, Math.min(Math.PI, ds.fov + delta));
|
||
return;
|
||
}
|
||
|
||
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;
|
||
// Dewarp: reset view on double-click
|
||
const ds = getDewarpForCell(cell);
|
||
if (ds && ds.active) {
|
||
e.stopPropagation();
|
||
ds.yaw = 0;
|
||
ds.pitch = 0;
|
||
ds.fov = Math.PI * 0.5;
|
||
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
|
||
if (e.target.closest('.dewarp-btn')) return; // dewarp button takes priority
|
||
const cell = e.target.closest('.cam-cell');
|
||
if (!cell) return;
|
||
|
||
// Dewarp pan
|
||
const ds = getDewarpForCell(cell);
|
||
if (ds && ds.active) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dewarpPanCell = cell;
|
||
dewarpPanStartX = e.clientX;
|
||
dewarpPanStartY = e.clientY;
|
||
dewarpPanStartYaw = ds.yaw;
|
||
dewarpPanStartPitch = ds.pitch;
|
||
cell.classList.add('panning');
|
||
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 => {
|
||
// Dewarp pan
|
||
if (dewarpPanCell) {
|
||
const ds = getDewarpForCell(dewarpPanCell);
|
||
if (ds) {
|
||
const rect = dewarpPanCell.getBoundingClientRect();
|
||
const sensitivity = ds.fov / rect.width;
|
||
ds.yaw = dewarpPanStartYaw - (e.clientX - dewarpPanStartX) * sensitivity;
|
||
ds.pitch = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45,
|
||
dewarpPanStartPitch + (e.clientY - dewarpPanStartY) * sensitivity));
|
||
}
|
||
return;
|
||
}
|
||
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 (dewarpPanCell) {
|
||
dewarpPanCell.classList.remove('panning');
|
||
dewarpPanCell = null;
|
||
}
|
||
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();
|
||
});
|
||
|
||
// ─── Fisheye Dewarp (WebGL) ───
|
||
|
||
const dewarpState = new Map(); // keyed by channel name
|
||
|
||
function getDewarpState(chName) {
|
||
if (!dewarpState.has(chName)) {
|
||
dewarpState.set(chName, {
|
||
active: false,
|
||
canvas: null,
|
||
gl: null,
|
||
program: null,
|
||
texture: null,
|
||
animFrameId: null,
|
||
// View: yaw/pitch in radians, fov in radians
|
||
yaw: 0,
|
||
pitch: 0,
|
||
fov: Math.PI * 0.5, // 90° default FOV
|
||
});
|
||
}
|
||
return dewarpState.get(chName);
|
||
}
|
||
|
||
const DEWARP_VS = `
|
||
attribute vec2 aPos;
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vUv = aPos * 0.5 + 0.5;
|
||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||
}
|
||
`;
|
||
|
||
// Equidistant fisheye → rectilinear with pan/tilt/zoom
|
||
// Maps each output pixel to a direction, then finds the corresponding
|
||
// point on the fisheye source image.
|
||
const DEWARP_FS = `
|
||
precision highp float;
|
||
varying vec2 vUv;
|
||
uniform sampler2D uTex;
|
||
uniform float uYaw; // horizontal pan (radians)
|
||
uniform float uPitch; // vertical tilt (radians)
|
||
uniform float uFov; // field of view (radians)
|
||
uniform float uAspect; // video aspect ratio (w/h)
|
||
|
||
void main() {
|
||
// Map UV to normalized device coords centered at 0
|
||
vec2 ndc = (vUv - 0.5) * 2.0;
|
||
ndc.x *= uAspect;
|
||
|
||
// Convert screen point to 3D ray direction (rectilinear)
|
||
float halfFov = uFov * 0.5;
|
||
float d = 1.0 / tan(halfFov);
|
||
vec3 ray = normalize(vec3(ndc.x, ndc.y, d));
|
||
|
||
// Rotate ray by pitch (around X axis)
|
||
float cp = cos(uPitch), sp = sin(uPitch);
|
||
ray = vec3(ray.x, cp * ray.y - sp * ray.z, sp * ray.y + cp * ray.z);
|
||
|
||
// Rotate ray by yaw (around Y axis)
|
||
float cy = cos(uYaw), sy = sin(uYaw);
|
||
ray = vec3(cy * ray.x + sy * ray.z, ray.y, -sy * ray.x + cy * ray.z);
|
||
|
||
// 3D ray → equidistant fisheye coordinates
|
||
// theta = angle from optical axis (Z+), phi = azimuth
|
||
float theta = acos(clamp(ray.z, -1.0, 1.0));
|
||
float phi = atan(ray.y, ray.x);
|
||
|
||
// Equidistant projection: r = theta / (pi/2) maps hemisphere to unit circle
|
||
// We use pi to support full 360° fisheye
|
||
float r = theta / 3.14159265;
|
||
|
||
// Map to texture coordinates (fisheye circle centered in frame)
|
||
vec2 fishUv = vec2(0.5) + r * vec2(cos(phi), sin(phi));
|
||
|
||
// Account for non-square video: fisheye circle fits the shorter dimension
|
||
// If video is wider than tall, squeeze x; if taller, squeeze y
|
||
if (uAspect > 1.0) {
|
||
fishUv.x = 0.5 + (fishUv.x - 0.5) / uAspect;
|
||
} else {
|
||
fishUv.y = 0.5 + (fishUv.y - 0.5) * uAspect;
|
||
}
|
||
|
||
// Clamp to valid range and fade edges
|
||
float dist = length(fishUv - 0.5) * 2.0;
|
||
if (fishUv.x < 0.0 || fishUv.x > 1.0 || fishUv.y < 0.0 || fishUv.y > 1.0 || dist > 1.0) {
|
||
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
|
||
return;
|
||
}
|
||
|
||
// Flip Y for WebGL texture coordinates
|
||
fishUv.y = 1.0 - fishUv.y;
|
||
gl_FragColor = texture2D(uTex, fishUv);
|
||
}
|
||
`;
|
||
|
||
function compileShader(gl, type, src) {
|
||
const s = gl.createShader(type);
|
||
gl.shaderSource(s, src);
|
||
gl.compileShader(s);
|
||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||
avpLog('error', 'Shader compile error: ' + gl.getShaderInfoLog(s));
|
||
gl.deleteShader(s);
|
||
return null;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function initDewarpGL(cell) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.className = 'dewarp-canvas';
|
||
cell.appendChild(canvas);
|
||
|
||
const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false });
|
||
if (!gl) { avpLog('error', 'WebGL not available'); return null; }
|
||
|
||
const vs = compileShader(gl, gl.VERTEX_SHADER, DEWARP_VS);
|
||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, DEWARP_FS);
|
||
const prog = gl.createProgram();
|
||
gl.attachShader(prog, vs);
|
||
gl.attachShader(prog, fs);
|
||
gl.linkProgram(prog);
|
||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||
avpLog('error', 'Program link error: ' + gl.getProgramInfoLog(prog));
|
||
return null;
|
||
}
|
||
gl.useProgram(prog);
|
||
|
||
// Full-screen quad
|
||
const buf = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||
const aPos = gl.getAttribLocation(prog, 'aPos');
|
||
gl.enableVertexAttribArray(aPos);
|
||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||
|
||
// Texture
|
||
const tex = gl.createTexture();
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
|
||
return { canvas, gl, program: prog, texture: tex };
|
||
}
|
||
|
||
function renderDewarpFrame(chName) {
|
||
const ds = dewarpState.get(chName);
|
||
if (!ds || !ds.active) return;
|
||
|
||
const ch = channels.get(chName);
|
||
if (!ch || !ch.cellEl) return;
|
||
|
||
const video = ch.cellEl.querySelector('video:not(.hidden-video)');
|
||
if (!video || video.readyState < 2) {
|
||
ds.animFrameId = requestAnimationFrame(() => renderDewarpFrame(chName));
|
||
return;
|
||
}
|
||
|
||
const { canvas, gl, program, texture } = ds;
|
||
|
||
// Resize canvas to match cell
|
||
const rect = ch.cellEl.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = Math.round(rect.width * dpr);
|
||
const h = Math.round(rect.height * dpr);
|
||
if (canvas.width !== w || canvas.height !== h) {
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
gl.viewport(0, 0, w, h);
|
||
}
|
||
|
||
// Upload video frame
|
||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
||
|
||
// Set uniforms
|
||
gl.useProgram(program);
|
||
gl.uniform1f(gl.getUniformLocation(program, 'uYaw'), ds.yaw);
|
||
gl.uniform1f(gl.getUniformLocation(program, 'uPitch'), ds.pitch);
|
||
gl.uniform1f(gl.getUniformLocation(program, 'uFov'), ds.fov);
|
||
|
||
const videoAspect = video.videoWidth / (video.videoHeight || 1);
|
||
gl.uniform1f(gl.getUniformLocation(program, 'uAspect'), videoAspect);
|
||
|
||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||
|
||
ds.animFrameId = requestAnimationFrame(() => renderDewarpFrame(chName));
|
||
}
|
||
|
||
function toggleDewarp(cell, chName) {
|
||
const ds = getDewarpState(chName);
|
||
ds.active = !ds.active;
|
||
|
||
const btn = cell.querySelector('.dewarp-btn');
|
||
if (btn) btn.classList.toggle('active', ds.active);
|
||
|
||
if (ds.active) {
|
||
// Initialize WebGL if needed
|
||
if (!ds.gl) {
|
||
const init = initDewarpGL(cell);
|
||
if (!init) { ds.active = false; btn?.classList.remove('active'); return; }
|
||
Object.assign(ds, init);
|
||
}
|
||
ds.canvas.classList.add('active');
|
||
cell.classList.add('dewarped');
|
||
// Reset normal CSS zoom — dewarp has its own zoom
|
||
resetZoom(cell);
|
||
// Start render loop
|
||
renderDewarpFrame(chName);
|
||
} else {
|
||
// Stop render loop
|
||
if (ds.animFrameId) { cancelAnimationFrame(ds.animFrameId); ds.animFrameId = null; }
|
||
if (ds.canvas) ds.canvas.classList.remove('active');
|
||
cell.classList.remove('dewarped');
|
||
// Reset dewarp view
|
||
ds.yaw = 0;
|
||
ds.pitch = 0;
|
||
ds.fov = Math.PI * 0.5;
|
||
}
|
||
}
|
||
|
||
// ─── Dewarp zoom (scroll wheel) and pan (click+drag) ───
|
||
let dewarpPanCell = null;
|
||
let dewarpPanStartX = 0, dewarpPanStartY = 0;
|
||
let dewarpPanStartYaw = 0, dewarpPanStartPitch = 0;
|
||
|
||
function getDewarpForCell(cell) {
|
||
const chName = findChForCell(cell);
|
||
return chName ? dewarpState.get(chName) : null;
|
||
}
|
||
|
||
// ─── 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;
|
||
|
||
// Cleanup dewarp state
|
||
for (const [, ds] of dewarpState) {
|
||
if (ds.animFrameId) cancelAnimationFrame(ds.animFrameId);
|
||
if (ds.gl) {
|
||
const ext = ds.gl.getExtension('WEBGL_lose_context');
|
||
if (ext) ext.loseContext(); // force WebGL context release
|
||
ds.gl.deleteTexture(ds.texture);
|
||
ds.gl.deleteProgram(ds.program);
|
||
}
|
||
}
|
||
dewarpState.clear();
|
||
|
||
// 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>
|