527 lines
27 KiB
HTML
527 lines
27 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; }
|
|
.empty-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: #222; background: #f1f1f1; pointer-events: none; }
|
|
.empty-state svg { width: 52px; height: 52px; fill: #222; }
|
|
.empty-state span { font-size: 14px; }
|
|
.player-wrap.has-video .empty-state { display: none; }
|
|
|
|
/* 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; }
|
|
|
|
@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="empty-state" id="empty-state"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h10a2 2 0 0 1 2 2v2.2l3-1.7v9l-3-1.7V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v10h10V7H4zm12 4.5v2.1l1-.6v-1z"/></svg><span>Select a recording from the timeline</span></div>
|
|
<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" 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; wrap.classList.remove("has-video"); 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(); }
|
|
});
|
|
|
|
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
|
addEventListener("resize", drawTimeline);
|
|
loadDate("").catch(() => {});
|
|
updateEncodeStatus();
|
|
</script>
|
|
</body>
|
|
</html>
|