Files
recorder-player/index.html
T

684 lines
36 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>C41 Archive</title>
<style>
:root { color-scheme: dark; --bg: #0f0f0f; --surface: #272727; --hover: #3d3d3d; --text: #f1f1f1; --dim: #aaa; --accent: #3ea6ff; --red: #ff4e45; }
* { box-sizing: border-box; margin: 0; }
body { font-family: "Roboto", "Arial", sans-serif; background: var(--bg); color: var(--text); }
.page { max-width: 900px; margin: 0 auto; padding: 16px; }
/* header */
.header { display: flex; align-items: center; justify-content: space-between; padding: 8px 0 16px; }
.header h1 { font-size: 18px; font-weight: 500; }
.header select { background: var(--surface); color: var(--text); border: none; padding: 6px 10px; border-radius: 3px; font-size: 14px; cursor: pointer; }
.help { margin-bottom: 14px; background: var(--surface); border-radius: 8px; overflow: hidden; }
.help summary { padding: 11px 16px; color: var(--text); cursor: pointer; font-size: 13px; }
.help-content { padding: 0 16px 14px; color: var(--dim); font-size: 13px; line-height: 1.55; }
.help-content p { margin-top: 8px; }
.help-content strong { color: var(--text); font-weight: 500; }
/* player wrapper */
.player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; }
.player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; }
/* big center play */
.big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(255,255,255,.92); color: #111; border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 12px rgba(0,0,0,.35); }
.player-wrap.paused .big-play { opacity: 1; }
.big-play svg { width: 28px; height: 28px; }
/* bottom gradient */
.controls-bar { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); padding: 18px 12px 10px; }
/* control row */
.ctrl-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ctrl-row button { width: 30px; height: 30px; flex: 0 0 30px; background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; }
.ctrl-row button:hover { background: rgba(255,255,255,.15); }
.ctrl-row svg { width: 22px; height: 22px; fill: currentColor; }
.time-label { font-size: 13px; color: #ddd; font-variant-numeric: tabular-nums; margin-left: 4px; user-select: none; white-space: nowrap; }
.spacer { flex: 1; }
.vol-wrap { display: flex; align-items: center; gap: 4px; }
.vol-wrap input { width: 0; opacity: 0; transition: width .2s, opacity .2s; accent-color: #fff; }
.vol-wrap:hover input, .vol-wrap input:focus { width: 70px; opacity: 1; }
/* timeline below player */
.timeline-section { margin-top: 16px; }
.timeline-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.timeline-header h2 { font-size: 14px; font-weight: 500; color: var(--dim); }
.zoom-btns { display: flex; gap: 4px; }
.zoom-btns button { background: var(--surface); border: none; color: var(--text); padding: 4px 10px; border-radius: 3px; font-size: 12px; cursor: pointer; }
.zoom-btns button:hover { background: var(--hover); }
.timeline-canvas { position: relative; }
.timeline-canvas canvas { display: block; width: 100%; height: 56px; border-radius: 6px; cursor: pointer; }
.hours { display: flex; justify-content: space-between; margin-top: 4px; color: var(--dim); font-size: 10px; user-select: none; }
.legend { display: flex; gap: 16px; margin-top: 10px; font-size: 11px; color: var(--dim); }
.legend span { display: flex; align-items: center; gap: 5px; }
.legend span { cursor: pointer; }
.legend span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; }
.legend .rec::before { background: var(--accent); }
.legend .gap::before { background: #2a2a2a; border: 1px solid #444; }
.legend .highlight::before { background: #ffd43b; }
.legend .system::before { background: #a87900; }
.highlights { margin-top: 18px; background: var(--surface); border-radius: 8px; padding: 14px 16px; }
.highlights h2 { font-size: 14px; font-weight: 500; margin-bottom: 10px; }
.highlight-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.highlight-actions button, .highlight-actions input { background: #333; border: 1px solid #555; color: var(--text); padding: 7px 10px; border-radius: 3px; font-size: 12px; }
.highlight-actions button { cursor: pointer; }
.highlight-actions button:hover { background: #444; }
.mark-time { color: var(--accent); font-size: 12px; min-width: 70px; }
.highlight-list { margin-top: 12px; display: grid; gap: 6px; }
.highlight-item { display: flex; align-items: center; gap: 10px; padding: 8px; background: #1a1a1a; border-radius: 4px; font-size: 12px; }
.highlight-item .highlight-label { flex: 1; }
.highlight-item button { background: none; border: none; color: var(--dim); cursor: pointer; }
/* encoding panel */
.encode-panel { margin-top: 16px; background: var(--surface); border-radius: 8px; overflow: hidden; }
.encode-toggle { width: 100%; background: none; border: none; color: var(--text); padding: 12px 16px; font-size: 13px; font-weight: 500; cursor: pointer; text-align: left; display: flex; align-items: center; gap: 8px; }
.encode-toggle:hover { background: var(--hover); }
.encode-toggle svg { width: 16px; height: 16px; fill: var(--dim); transition: transform .2s; }
.encode-toggle.open svg { transform: rotate(90deg); }
.encode-body { display: none; padding: 0 16px 14px; }
.encode-body.show { display: flex; align-items: center; gap: 12px; }
.encode-body button { background: var(--accent); color: #000; border: none; padding: 7px 16px; border-radius: 3px; font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
.encode-body button:hover { background: #65b8ff; }
.encode-body button:disabled { background: #555; color: #999; cursor: default; }
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
/* upload panel */
.upload-toggle svg { fill: #8ab4ff; }
.upload-body { border-top: 1px solid #1c1c1c; }
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
.upload-drop b { color: var(--accent); font-weight: 500; }
.upload-drop input { display: none; }
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
.upload-file-chip.show { display: flex; }
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
.upload-file-chip .chip-x:hover { color: var(--red); }
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
.upload-queue.show { display: flex; }
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.q-item .q-size { color: var(--dim); white-space: nowrap; }
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
.q-item.ok .q-state { color: #7bd97b; }
.q-item.err .q-state { color: var(--red); }
.q-item.busy .q-state { color: var(--accent); }
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
.upload-btn { position: relative; overflow: hidden; }
.upload-btn.busy { color: transparent; pointer-events: none; }
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.upload-status { transition: color .2s; }
.upload-status.ok { color: #7bd97b; }
.upload-status.err { color: var(--red); }
@media (max-width: 600px) {
.page { padding: 8px; }
.ctrl-row { gap: 3px; }
.ctrl-row button { width: 32px; height: 32px; flex-basis: 32px; }
.ctrl-row svg { width: 20px; height: 20px; }
.time-label { font-size: 11px; }
.ctrl-row .spacer { display: none; }
.vol-wrap input { width: 54px; opacity: 1; }
.hours span:nth-child(even) { display: none; }
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1>C41 Archive</h1>
<select id="date"></select>
</div>
<details class="help">
<summary>How to use this player</summary>
<div class="help-content">
<p><strong>Choose a date:</strong> Select a recording day at the top right.</p>
<p><strong>Find a recording:</strong> Light blue sections contain video. Dark sections are times when nothing was recorded. Click a light blue section to play it.</p>
<p><strong>Move through video:</strong> Use the red bar below the video to move within the current clip. Use the back and forward buttons, or the left and right arrow keys, to jump 10 seconds. Jumps automatically cross clips and skip gaps.</p>
<p><strong>Audio:</strong> Use the speaker button to mute. Hover over it to adjust volume. Press <strong>M</strong> to mute or unmute.</p>
<p><strong>Play and pause:</strong> Press the play button, click the video, or press <strong>Space</strong>.</p>
<p><strong>Timeline zoom:</strong> Press <strong>+</strong> or <strong>-</strong> to see a smaller part of the day in more detail.</p>
<p><strong>Encoding:</strong> Open the Encoding menu and press Start to create browser-friendly copies. Existing copies are skipped and never overwritten.</p>
</div>
</details>
<div class="player-wrap paused" id="player-wrap">
<video id="video" playsinline></video>
<div class="big-play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
<div class="controls-bar">
<div class="ctrl-row">
<button id="btn-play" aria-label="Play">
<svg id="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6zm8-14v14h4V5z"/></svg>
</button>
<button id="btn-back" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2L7 7l5 5V8a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<button id="btn-fwd" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2l5 5-5 5V8a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<div class="vol-wrap">
<button id="btn-vol" aria-label="Volume">
<svg id="icon-vol-on" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.3v7.4a4.5 4.5 0 0 0 2.5-3.7zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1A9 9 0 0 0 14 3.2z"/></svg>
<svg id="icon-vol-off" viewBox="0 0 24 24" style="display:none"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.3v7.4a4.5 4.5 0 0 0 2.5-3.7z"/></svg>
</button>
<input id="volume" type="range" min="0" max="1" step=".01" value=".8">
</div>
<span class="time-label" id="time-cur" style="color:var(--accent);font-weight:500;">--:--:--</span>
<span class="spacer"></span>
</div>
</div>
</div>
<div class="timeline-section">
<div class="timeline-header">
<h2>24h Timeline</h2>
<div class="zoom-btns">
<button id="zoom-out">-</button>
<button id="zoom-in">+</button>
</div>
</div>
<div class="timeline-canvas">
<canvas id="timeline"></canvas>
</div>
<div class="hours" id="hours"></div>
<div class="legend"><span class="rec" data-jump="recorded">Recorded</span><span class="highlight" data-jump="highlight">Highlight</span><span class="system" data-jump="system">System highlight</span><span class="gap" data-jump="gap">No recording</span></div>
</div>
<section class="highlights">
<h2>Highlights</h2>
<div class="highlight-actions">
<button id="mark-start">Mark start</button><span class="mark-time" id="highlight-start">--:--:--</span>
<button id="mark-end">Mark end</button><span class="mark-time" id="highlight-end">--:--:--</span>
<input id="highlight-label" placeholder="Highlight name" maxlength="120">
<button id="save-highlight">Add highlight</button>
</div>
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
</section>
<div class="encode-panel">
<button class="encode-toggle upload-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
<div class="encode-body upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">named HH.MM.SS.mp4 or .avi, multiple files allowed</span>
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
</div>
<div class="upload-queue" id="upload-queue"></div>
<span class="enc-status" style="min-width:110px">Date: <b id="upload-date" style="color:var(--text)">-</b></span>
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
</div>
</div>
<div class="encode-panel">
<button class="encode-toggle" id="encode-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Encoding</button>
<div class="encode-body" id="encode-body">
<button id="encode-btn">Start</button>
<progress id="encode-progress" value="0" max="1"></progress>
<span class="enc-status" id="encode-status">Ready</span>
</div>
</div>
</div>
<script>
const video = document.querySelector("#video");
const wrap = document.querySelector("#player-wrap");
const dateSelect = document.querySelector("#date");
const timeline = document.querySelector("#timeline");
let clips = [];
let activeClip = null;
let initialOffset = 0;
let zoom = 1;
let focus = 43200;
let highlights = [];
let systemHighlights = [];
let highlightStart = null;
let highlightEnd = null;
let encodingWasRunning = false;
// encoding
const encToggle = document.querySelector("#encode-toggle");
encToggle.addEventListener("click", () => {
encToggle.classList.toggle("open");
document.querySelector("#encode-body").classList.toggle("show");
});
async function updateEncodeStatus() {
const r = await fetch("/api/encode/status");
const d = await r.json();
const btn = document.querySelector("#encode-btn");
const prog = document.querySelector("#encode-progress");
btn.disabled = d.running;
btn.textContent = d.running ? "Encoding..." : "Start";
prog.max = d.total || 1;
prog.value = d.completed + d.skipped + d.failed;
document.querySelector("#encode-status").textContent = d.running
? `${d.completed}/${d.total} ${d.skipped} skip ${d.failed} err`
: d.total ? `${d.completed} done ${d.skipped} skip ${d.failed} err` : "Ready";
if (encodingWasRunning && !d.running) loadDate(dateSelect.value);
encodingWasRunning = d.running;
if (d.running) setTimeout(updateEncodeStatus, 1000);
}
document.querySelector("#encode-btn").addEventListener("click", async () => {
await fetch("/api/encode", {method: "POST"});
updateEncodeStatus();
});
// play / pause
function setPlaying(p) {
wrap.classList.toggle("paused", !p);
document.querySelector("#icon-play").style.display = p ? "none" : "";
document.querySelector("#icon-pause").style.display = p ? "" : "none";
}
document.querySelector("#btn-play").addEventListener("click", () => {
if (!activeClip) return;
video.paused ? video.play() : video.pause();
});
wrap.addEventListener("click", e => {
if (e.target.closest(".controls-bar")) return;
if (!activeClip) return;
video.paused ? video.play() : video.pause();
});
video.addEventListener("play", () => setPlaying(true));
video.addEventListener("pause", () => setPlaying(false));
// volume
const volInput = document.querySelector("#volume");
volInput.addEventListener("input", () => { video.volume = +volInput.value; updateVolIcon(); });
document.querySelector("#btn-vol").addEventListener("click", () => {
video.muted = !video.muted;
updateVolIcon();
});
function updateVolIcon() {
const on = !video.muted && video.volume > 0;
document.querySelector("#icon-vol-on").style.display = on ? "" : "none";
document.querySelector("#icon-vol-off").style.display = on ? "none" : "";
}
video.volume = .8;
// time format
function fmt(s) {
s = Math.max(0, Math.floor(s));
const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
return h ? `${h}:${String(m).padStart(2,"0")}:${String(sec).padStart(2,"0")}` : `${m}:${String(sec).padStart(2,"0")}`;
}
function timeOfDay(s) {
s = Math.max(0, Math.floor(s));
return [s/3600, s%3600/60, s%60].map(v => String(Math.floor(v)).padStart(2,"0")).join(":");
}
function archiveTime() {
return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : focus;
}
function jumpToNext(kind) {
const now = archiveTime();
if (kind === "recorded") {
const clip = clips.find(item => item.start > now + 1) || clips[0];
if (clip) { focus = clip.start; playClip(clip, 0); }
return;
}
const items = kind === "system" ? systemHighlights : highlights;
if (kind === "highlight" || kind === "system") {
const item = items.find(entry => entry.start > now + 1) || items[0];
if (!item) return;
const clip = clips.find(entry => item.start >= entry.start && item.start < entry.start + entry.duration)
|| clips.find(entry => entry.start >= item.start);
if (clip) { focus = clip.start; playClip(clip, Math.max(0, item.start - clip.start)); }
return;
}
const gaps = [];
if (clips.length && clips[0].start > 0) gaps.push({start: 0, end: clips[0].start});
clips.forEach((clip, index) => {
const end = clip.start + clip.duration;
const next = clips[index + 1];
if (next && next.start > end) gaps.push({start: end, end: next.start});
});
const gap = gaps.find(item => item.start > now + 1) || gaps[0];
if (gap) {
video.pause(); activeClip = null; focus = gap.start;
document.querySelector("#time-cur").textContent = timeOfDay(gap.start);
drawTimeline();
}
}
document.querySelectorAll(".legend [data-jump]").forEach(item => item.addEventListener("click", () => jumpToNext(item.dataset.jump)));
function setMark(which) {
const value = archiveTime();
if (value === null) return;
if (which === "start") {
highlightStart = value;
document.querySelector("#highlight-start").textContent = timeOfDay(value);
} else {
highlightEnd = value;
document.querySelector("#highlight-end").textContent = timeOfDay(value);
}
}
async function loadHighlights() {
const response = await fetch("/api/highlights");
highlights = (await response.json()).filter(item => item.date === dateSelect.value);
const items = highlights;
drawTimeline();
const list = document.querySelector("#highlight-list");
list.replaceChildren();
if (!items.length) {
list.innerHTML = '<span style="color:var(--dim);font-size:12px">No highlights yet.</span>';
return;
}
items.forEach(item => {
const row = document.createElement("div");
row.className = "highlight-item";
const label = document.createElement("span");
label.className = "highlight-label";
label.textContent = `${item.label} (${timeOfDay(item.start)} - ${timeOfDay(item.end)})`;
const play = document.createElement("button");
play.textContent = "Play";
play.onclick = () => {
const clip = clips.find(c => item.start >= c.start && item.start < c.start + c.duration);
if (clip) playClip(clip, item.start - clip.start);
};
const remove = document.createElement("button");
remove.textContent = "Remove";
remove.onclick = async () => { await fetch(`/api/highlights?id=${item.id}`, {method: "DELETE"}); loadHighlights(); };
row.append(label, play, remove);
list.append(row);
});
}
async function loadSystemHighlights() {
const response = await fetch(`/api/system-highlights?date=${dateSelect.value}`);
systemHighlights = await response.json();
drawTimeline();
}
document.querySelector("#mark-start").onclick = () => setMark("start");
document.querySelector("#mark-end").onclick = () => setMark("end");
document.querySelector("#save-highlight").onclick = async () => {
if (highlightStart === null || highlightEnd === null || highlightStart >= highlightEnd) return;
await fetch("/api/highlights", {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({date: dateSelect.value, start: highlightStart, end: highlightEnd, label: document.querySelector("#highlight-label").value || "Untitled highlight"})});
highlightStart = highlightEnd = null;
document.querySelector("#highlight-start").textContent = "--:--:--";
document.querySelector("#highlight-end").textContent = "--:--:--";
document.querySelector("#highlight-label").value = "";
loadHighlights();
};
// seek buttons
function seekBy(sec) {
if (!activeClip) return;
const cur = activeClip.start + initialOffset + video.currentTime;
const target = cur + sec;
let clip = clips.find(c => target >= c.start && target < c.start + c.duration);
if (!clip) {
clip = sec > 0
? clips.find(c => c.start >= target)
: [...clips].reverse().find(c => c.start + c.duration <= target);
if (!clip) clip = sec > 0 ? clips[clips.length - 1] : clips[0];
}
let offset = target - clip.start;
if (target < clip.start) offset = 0;
if (target >= clip.start + clip.duration) offset = clip.duration - 0.1;
if (clip !== activeClip) return playClip(clip, Math.max(0, offset));
video.currentTime = Math.max(0, Math.min(video.duration || clip.duration, offset));
}
document.querySelector("#btn-back").addEventListener("click", () => seekBy(-10));
document.querySelector("#btn-fwd").addEventListener("click", () => seekBy(10));
// timeline
function drawTimeline() {
const ratio = devicePixelRatio || 1;
const w = timeline.clientWidth;
timeline.width = w * ratio;
timeline.height = 56 * ratio;
const ctx = timeline.getContext("2d");
ctx.scale(ratio, ratio);
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, w, 56);
const winSize = 86400 / zoom;
const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2));
const winEnd = winStart + winSize;
// recorded blocks
ctx.fillStyle = "#3ea6ff";
const rec = new Uint8Array(1440);
clips.forEach(c => {
const a = Math.floor(c.start / 60), b = Math.min(1439, Math.floor((c.start + c.duration - .001) / 60));
for (let i = a; i <= b; i++) rec[i] = 1;
});
rec.forEach((v, i) => {
if (!v) return;
const l = (i * 60 - winStart) / winSize * w;
const r = ((i + 1) * 60 - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
});
ctx.fillStyle = "#ffd43b";
highlights.forEach(item => {
const l = (item.start - winStart) / winSize * w;
const r = (item.end - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
});
ctx.fillStyle = "#a87900";
systemHighlights.forEach(item => {
const l = (item.start - winStart) / winSize * w;
const r = (item.end - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
});
// hour lines
ctx.strokeStyle = "rgba(255,255,255,.08)";
for (let h = Math.ceil(winStart / 3600); h < winEnd / 3600; h++) {
const x = (h * 3600 - winStart) / winSize * w;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 56); ctx.stroke();
}
// playhead
if (activeClip) {
const cur = activeClip.start + initialOffset + (video.currentTime || 0);
const x = (cur - winStart) / winSize * w;
ctx.fillStyle = "#fff";
ctx.fillRect(x - 1.5, 0, 3, 56);
}
// labels
const step = zoom >= 8 ? 1 : zoom >= 2 ? 2 : 4;
const first = Math.ceil(winStart / 3600 / step) * step;
document.querySelector("#hours").innerHTML = Array.from({length: 25}, (_, i) => first + i * step)
.filter(h => h <= winEnd / 3600).map(h => `<span>${timeOfDay(h * 3600)}</span>`).join("");
}
timeline.addEventListener("click", e => {
const winSize = 86400 / zoom;
const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2));
const sec = winStart + e.offsetX / timeline.clientWidth * winSize;
focus = sec;
const systemEvent = systemHighlights.find(item => sec >= item.start && sec < item.end);
if (systemEvent) {
const eventClip = clips.find(clip => clip.start >= systemEvent.start);
if (eventClip) return playClip(eventClip, 0);
}
const savedHighlight = highlights.find(item => sec >= item.start && sec < item.end);
if (savedHighlight) {
const highlightClip = clips.find(clip => savedHighlight.start >= clip.start && savedHighlight.start < clip.start + clip.duration)
|| clips.find(clip => clip.start >= savedHighlight.start);
if (highlightClip) return playClip(highlightClip, Math.max(0, savedHighlight.start - highlightClip.start));
}
const clip = clips.find(c => sec >= c.start && sec < c.start + c.duration)
|| clips.find(c => c.start >= sec) || clips[clips.length - 1];
if (clip) playClip(clip, 0);
});
timeline.addEventListener("mousemove", e => {
const winSize = 86400 / zoom;
const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2));
timeline.title = timeOfDay(winStart + e.offsetX / timeline.clientWidth * winSize);
});
document.querySelector("#zoom-in").addEventListener("click", () => { zoom = Math.min(24, zoom * 2); drawTimeline(); });
document.querySelector("#zoom-out").addEventListener("click", () => { zoom = Math.max(1, zoom / 2); drawTimeline(); });
// load date
async function loadDate(date) {
video.pause(); video.removeAttribute("src"); activeClip = null; setPlaying(false);
const r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`);
const d = await r.json();
clips = d.recordings;
dateSelect.innerHTML = d.dates.map(day => `<option value="${day}">${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6)}</option>`).join("");
dateSelect.value = date || d.dates[0];
document.querySelector("#time-cur").textContent = "--:--:--";
drawTimeline();
loadHighlights();
loadSystemHighlights();
if (!date && d.dates[0]) loadDate(d.dates[0]);
}
function playClip(clip, offset = 0) {
activeClip = clip;
initialOffset = offset;
document.querySelector("#time-cur").textContent = timeOfDay(clip.start + offset);
video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`;
video.play().catch(() => {});
drawTimeline();
}
video.addEventListener("timeupdate", () => {
if (!activeClip) return;
document.querySelector("#time-cur").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime);
drawTimeline();
});
video.addEventListener("loadedmetadata", () => {
if (initialOffset > 0 && video.currentTime < initialOffset) video.currentTime = initialOffset;
});
video.addEventListener("ended", () => {
const i = clips.indexOf(activeClip);
if (i >= 0 && clips[i + 1]) playClip(clips[i + 1]);
});
// keyboard
document.addEventListener("keydown", e => {
if (e.target.tagName === "SELECT" || e.target.tagName === "INPUT") return;
if (e.code === "ArrowLeft") { e.preventDefault(); seekBy(-10); }
else if (e.code === "ArrowRight") { e.preventDefault(); seekBy(10); }
else if (e.code === "Equal" || e.code === "NumpadAdd") { e.preventDefault(); zoom = Math.min(24, zoom * 2); drawTimeline(); }
else if (e.code === "Minus" || e.code === "NumpadSubtract") { e.preventDefault(); zoom = Math.max(1, zoom / 2); drawTimeline(); }
else if (e.code === "Space") { e.preventDefault(); if (activeClip) video.paused ? video.play() : video.pause(); }
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
});
// upload
const uploadToggle = document.querySelector("#upload-toggle");
uploadToggle.addEventListener("click", () => {
uploadToggle.classList.toggle("open");
document.querySelector("#upload-body").classList.toggle("show");
document.querySelector("#upload-date").textContent = dateSelect.value || "-";
});
const uploadFile = document.querySelector("#upload-file");
const uploadBtn = document.querySelector("#upload-btn");
const uploadDrop = document.querySelector("#upload-drop");
const uploadQueue = document.querySelector("#upload-queue");
const uploadStatus = document.querySelector("#upload-status");
let queue = [];
function setStatus(msg, cls) {
uploadStatus.textContent = msg;
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
}
function renderQueue() {
uploadQueue.replaceChildren();
uploadQueue.classList.toggle("show", queue.length > 0);
if (!queue.length) return;
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
const summary = document.createElement("div");
summary.className = "q-count";
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
uploadQueue.append(summary);
queue.forEach(entry => {
const row = document.createElement("div");
row.className = "q-item" + (entry.state ? " " + entry.state : "");
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
const bar = document.createElement("span"); bar.className = "q-bar";
const fill = document.createElement("i"); bar.append(fill);
entry.bar = fill;
const state = document.createElement("span"); state.className = "q-state";
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
row.append(name, size, bar, state);
uploadQueue.append(row);
});
uploadBtn.disabled = !queue.some(e => e.state === "queued");
}
function addFiles(files) {
for (const file of files) {
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
queue.push({file, state: "queued"});
}
renderQueue();
}
function clearDone() {
queue = queue.filter(e => e.state === "err");
renderQueue();
}
uploadDrop.addEventListener("click", () => uploadFile.click());
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
function normName(name) {
const dot = name.lastIndexOf(".");
if (dot <= 0) return null;
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
const ext = name.slice(dot).toLowerCase();
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
}
async function uploadOne(entry) {
const filename = normName(entry.file.name);
if (!filename) {
entry.state = "err"; entry.msg = "bad name";
renderQueue();
return false;
}
entry.state = "busy"; renderQueue();
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`);
xhr.upload.addEventListener("progress", e => {
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
});
xhr.addEventListener("load", () => {
let d = {};
try { d = JSON.parse(xhr.responseText); } catch {}
entry.state = xhr.status < 300 ? "ok" : "err";
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
renderQueue();
resolve(xhr.status < 300);
});
xhr.addEventListener("error", () => {
entry.state = "err"; entry.msg = "network";
renderQueue();
resolve(false);
});
xhr.send(entry.file);
});
}
uploadBtn.addEventListener("click", async () => {
const pending = queue.filter(e => e.state === "queued");
if (!pending.length) return;
uploadBtn.disabled = true;
let okCount = 0, done = 0;
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
while (pending.length) {
const entry = pending.shift();
if (await uploadOne(entry)) okCount++;
done++;
setStatus(`${done}/${pending.length + done} sent...`);
}
});
await Promise.all(workers);
const errs = queue.filter(e => e.state === "err").length;
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
if (okCount) loadDate(dateSelect.value);
if (!errs) clearDone();
});
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
addEventListener("resize", drawTimeline);
loadDate("").catch(() => {});
updateEncodeStatus();
</script>
</body>
</html>