(function () { "use strict"; const RAW = JSON.parse( document.getElementById("videoData").textContent || "{}", ); const cameras = {}; const clips = []; Object.keys(RAW).forEach(function (key) { const cam = RAW[key]; cameras[key] = cam.display; (cam.videos || []).forEach(function (v) { clips.push({ cam: key, camDisplay: cam.display, filename: v.filename, timestamp: v.timestamp, rel_path: v.rel_path, url: v.url, }); }); }); // Index clips by timestamp for angle synchronization const byTimestamp = {}; clips.forEach(function (c) { if (!byTimestamp[c.timestamp]) byTimestamp[c.timestamp] = {}; byTimestamp[c.timestamp][c.cam] = c; }); const player = document.getElementById("player"); const noiseCanvas = document.getElementById("noiseCanvas"); const placeholder = document.getElementById("videoPlaceholder"); const badge = document.getElementById("videoBadge"); const badgeText = document.getElementById("badgeText"); const clipListEl = document.getElementById("clipList"); const searchBox = document.getElementById("searchBox"); const clearSearch = document.getElementById("clearSearch"); const camTabsEl = document.getElementById("camTabs"); const sortSelect = document.getElementById("sortSelect"); const resultCount = document.getElementById("resultCount"); const camSwitchBtns = document.getElementById("camSwitchButtons"); const nowPlaying = document.getElementById("nowPlaying"); const npName = document.getElementById("npName"); const npTime = document.getElementById("npTime"); document.getElementById("countAll").textContent = clips.length; let activeCam = "all"; let query = ""; let sortMode = "oldest"; let current = null; // ---- Static noise canvas for loading indicator ---- var noiseCtx = noiseCanvas.getContext("2d"); var noiseAnimId = null; var noiseRunning = false; function noiseStart() { if (noiseRunning) return; noiseRunning = true; noiseCanvas.classList.remove("hidden"); noiseAnimId = null; noiseTick(); } function noiseStop() { noiseRunning = false; noiseCanvas.classList.add("hidden"); if (noiseAnimId) { cancelAnimationFrame(noiseAnimId); noiseAnimId = null; } } function noiseTick() { if (!noiseRunning) return; var w = noiseCanvas.width; var h = noiseCanvas.height; var rect = noiseCanvas.getBoundingClientRect(); // Render at reduced resolution (160px wide) for performance, // CSS image-rendering: pixelated handles the upscale. var targetW = Math.max(1, Math.min(160, (rect.width * 0.25) | 0)); var ratio = rect.height / Math.max(1, rect.width); var targetH = Math.max(1, (targetW * ratio) | 0); if (w !== targetW || h !== targetH) { noiseCanvas.width = targetW; noiseCanvas.height = targetH; w = targetW; h = targetH; } if (w === 0 || h === 0) { noiseAnimId = requestAnimationFrame(noiseTick); return; } var imageData = noiseCtx.createImageData(w, h); var data = imageData.data; for (var i = 0; i < data.length; i += 4) { var v = (Math.random() * 255) | 0; data[i] = v; data[i + 1] = v; data[i + 2] = v; data[i + 3] = 255; } noiseCtx.putImageData(imageData, 0, 0); noiseAnimId = requestAnimationFrame(noiseTick); } // Start noise immediately on page load (no video selected yet) noiseStart(); // ---- Prefetch helpers ---- // Track already-prefetched URLs so we don't re-fetch var prefetchedUrls = {}; /** * Prefetch a video URL using a element. * The browser downloads the resource in the background at low priority * and caches it for instant playback later. */ function prefetchVideo(url) { if (!url || prefetchedUrls[url]) return; prefetchedUrls[url] = true; var link = document.createElement("link"); link.rel = "prefetch"; link.as = "video"; link.href = url; document.head.appendChild(link); } /** * After the current video starts playing, prefetch: * 1. All sibling camera angles for the same timestamp * 2. The next clip in the same camera's timeline */ function schedulePrefetch(clip) { // Use requestIdleCallback to run after the browser has handled the // current frame (i.e. the video has started loading). Fall back to a // short setTimeout if requestIdleCallback isn't available. var doPrefetch = function () { // 1. Sibling camera angles (same timestamp, different cameras) var group = byTimestamp[clip.timestamp] || {}; Object.keys(group).forEach(function (key) { if (key !== clip.cam) { prefetchVideo(group[key].url); } }); // 2. Next clip in the same camera's timeline var sameCam = clips .filter(function (c) { return c.cam === clip.cam; }) .sort(function (a, b) { if (sortMode === "name") return a.filename.localeCompare(b.filename); var da = tsToDate(a.timestamp), db = tsToDate(b.timestamp); return (da ? da.getTime() : 0) - (db ? db.getTime() : 0); }); var idx = sameCam.findIndex(function (c) { return c.timestamp === clip.timestamp && c.filename === clip.filename; }); if (idx > -1 && sameCam[idx + 1]) { prefetchVideo(sameCam[idx + 1].url); } }; if (typeof requestIdleCallback === "function") { requestIdleCallback(doPrefetch, { timeout: 2000 }); } else { setTimeout(doPrefetch, 100); } } function tsToDate(ts) { const m = /^(\d{4})-(\d{2})-(\d{2})[_ ](\d{2})-(\d{2})-(\d{2})/.exec( ts || "", ); if (!m) return null; return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); } function prettyTs(ts) { const d = tsToDate(ts); if (!d) return ts || "Unknown"; return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c]; }); } function getFiltered() { let out = clips.filter(function (c) { if (activeCam !== "all" && c.cam !== activeCam) return false; if (query) { const hay = ( c.filename + " " + c.timestamp + " " + c.camDisplay + " " + prettyTs(c.timestamp) ).toLowerCase(); if (hay.indexOf(query) === -1) return false; } return true; }); out.sort(function (a, b) { if (sortMode === "name") return a.filename.localeCompare(b.filename); const da = tsToDate(a.timestamp), db = tsToDate(b.timestamp); const ta = da ? da.getTime() : 0, tb = db ? db.getTime() : 0; return sortMode === "oldest" ? ta - tb : tb - ta; }); return out; } function render() { const list = getFiltered(); resultCount.textContent = list.length + (list.length === 1 ? " clip" : " clips"); if (!list.length) { clipListEl.innerHTML = '
No clips match your filters.
'; return; } let html = ""; if (activeCam === "all") { const groups = {}; list.forEach(function (c) { (groups[c.cam] = groups[c.cam] || []).push(c); }); Object.keys(groups).forEach(function (key) { html += groupHeader(key, groups[key].length); groups[key].forEach(function (c) { html += clipRow(c); }); }); } else { html += groupHeader(activeCam, list.length); list.forEach(function (c) { html += clipRow(c); }); } clipListEl.innerHTML = html; highlightCurrent(); } function groupHeader(key, n) { return ( '
' + esc(cameras[key] || key) + " · " + n + "" + "
" ); } function clipRow(c) { const id = c.cam + "|" + c.timestamp + "|" + c.filename; return ( '
' + '
' + '' + "
" + '
' + '
' + esc(c.filename) + "
" + '
' + esc(c.camDisplay) + "" + "" + esc(prettyTs(c.timestamp)) + "
" + "
" + '
' + '' + "
" + "
" ); } function playClip(clip) { if (!clip) return; current = clip; player.src = clip.url; player.load(); // Show noise while loading, stop when video starts playing noiseStart(); var prefetchDone = false; function onPlaying() { noiseStop(); if (prefetchDone) return; prefetchDone = true; player.removeEventListener("playing", onPlaying); schedulePrefetch(clip); } player.addEventListener("playing", onPlaying); player.play().catch(function () { // Even if autoplay is blocked, still prefetch for when the user clicks play noiseStop(); if (!prefetchDone) { prefetchDone = true; player.removeEventListener("playing", onPlaying); schedulePrefetch(clip); } }); placeholder.hidden = true; badge.classList.add("visible"); badgeText.textContent = clip.camDisplay; nowPlaying.hidden = false; npName.textContent = clip.filename; npTime.textContent = prettyTs(clip.timestamp); buildCamSwitch(clip); highlightCurrent(); } function buildCamSwitch(clip) { const group = byTimestamp[clip.timestamp] || {}; const availKeys = Object.keys(group); let html = ""; Object.keys(cameras).forEach(function (key) { const has = group[key]; const isActive = key === clip.cam; html += '"; }); if (availKeys.length <= 1) { html += '' + "Only one camera view recorded for this timestamp."; } camSwitchBtns.innerHTML = html; } function switchCam(key) { if (!current) return; const group = byTimestamp[current.timestamp] || {}; const target = group[key]; if (!target) return; const savedTime = player.currentTime || 0; const wasPlaying = !player.paused; current = target; player.src = target.url; player.load(); badgeText.textContent = target.camDisplay; npName.textContent = target.filename; // Preserve video playback timestamp position across camera angles player.addEventListener("loadedmetadata", function onMeta() { player.removeEventListener("loadedmetadata", onMeta); try { if (savedTime && savedTime < player.duration) { player.currentTime = savedTime; } } catch (e) {} if (wasPlaying) player.play().catch(function () {}); }); camSwitchBtns.querySelectorAll("button").forEach(function (b) { b.classList.toggle("active", b.getAttribute("data-switch") === key); }); highlightCurrent(); } function highlightCurrent() { const rows = clipListEl.querySelectorAll(".clip"); rows.forEach(function (r) { const isCur = current && r.getAttribute("data-cam") === current.cam && r.getAttribute("data-ts") === current.timestamp && r.getAttribute("data-fn") === current.filename; r.classList.toggle("playing", !!isCur); }); } clipListEl.addEventListener("click", function (e) { const row = e.target.closest(".clip"); if (!row) return; playClip({ cam: row.getAttribute("data-cam"), camDisplay: cameras[row.getAttribute("data-cam")], filename: row.getAttribute("data-fn"), timestamp: row.getAttribute("data-ts"), url: row.getAttribute("data-url"), }); }); camSwitchBtns.addEventListener("click", function (e) { const btn = e.target.closest("button[data-switch]"); if (!btn || btn.disabled) return; switchCam(btn.getAttribute("data-switch")); }); camTabsEl.addEventListener("click", function (e) { const tab = e.target.closest(".tab"); if (!tab) return; camTabsEl.querySelectorAll(".tab").forEach(function (t) { t.classList.remove("active"); }); tab.classList.add("active"); activeCam = tab.getAttribute("data-cam"); render(); }); let searchTimer; searchBox.addEventListener("input", function () { clearSearch.classList.toggle("visible", !!searchBox.value); clearTimeout(searchTimer); searchTimer = setTimeout(function () { query = searchBox.value.trim().toLowerCase(); render(); }, 120); }); clearSearch.addEventListener("click", function () { searchBox.value = ""; query = ""; clearSearch.classList.remove("visible"); render(); searchBox.focus(); }); sortSelect.addEventListener("change", function () { sortMode = sortSelect.value; render(); }); // Keyboard Shortcuts: 1-9 (Camera Switch), J/K (Next/Prev Clip) document.addEventListener("keydown", function (e) { if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return; if (e.key >= "1" && e.key <= "9") { const keys = Object.keys(cameras); const idx = +e.key - 1; if (current && keys[idx]) switchCam(keys[idx]); } else if (e.key === "j" || e.key === "k") { const list = getFiltered(); if (!list.length) return; let i = list.findIndex(function (c) { return ( current && c.cam === current.cam && c.timestamp === current.timestamp && c.filename === current.filename ); }); i = e.key === "j" ? Math.min(list.length - 1, i + 1) : Math.max(0, i - 1); if (list[i]) playClip(list[i]); } }); // Auto-play next clip of the same camera when video finishes player.addEventListener("ended", function () { if (!current) return; // Build a list of clips for the SAME camera, sorted like the sidebar const sameCam = clips .filter(function (c) { return c.cam === current.cam; }) .sort(function (a, b) { if (sortMode === "name") return a.filename.localeCompare(b.filename); const da = tsToDate(a.timestamp), db = tsToDate(b.timestamp); const ta = da ? da.getTime() : 0, tb = db ? db.getTime() : 0; return sortMode === "oldest" ? ta - tb : tb - ta; }); const i = sameCam.findIndex(function (c) { return ( c.cam === current.cam && c.timestamp === current.timestamp && c.filename === current.filename ); }); if (i > -1 && sameCam[i + 1]) playClip(sameCam[i + 1]); }); render(); })();