(function () { "use strict"; // ---- DOM refs ---- 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 nowPlaying = document.getElementById("nowPlaying"); const npName = document.getElementById("npName"); const npTime = document.getElementById("npTime"); const npLocation = document.getElementById("npLocation"); const prevBtn = document.getElementById("prevBtn"); const nextBtn = document.getElementById("nextBtn"); const firstBtn = document.getElementById("firstBtn"); const regionBar = document.getElementById("regionBar"); const csBtns = document.querySelectorAll(".cs-btn[data-cam]"); // ---- State ---- let allVideos = {}; // { camKey: { display, videos: [...] } } let byTimestamp = {}; // { timestamp: { camKey: clip } } let currentClip = null; // currently playing clip object let currentCam = "front"; // active camera key // ---- Static noise canvas ---- const noiseCtx = noiseCanvas.getContext("2d"); let noiseAnimId = null; let noiseRunning = false; function noiseStart() { if (noiseRunning) return; noiseRunning = true; noiseCanvas.classList.remove("hidden"); noiseTick(); } function noiseStop() { noiseRunning = false; noiseCanvas.classList.add("hidden"); if (noiseAnimId) { cancelAnimationFrame(noiseAnimId); noiseAnimId = null; } } function noiseTick() { if (!noiseRunning) return; let w = noiseCanvas.width; let h = noiseCanvas.height; const rect = noiseCanvas.getBoundingClientRect(); const targetW = Math.max(1, Math.min(160, (rect.width * 0.25) | 0)); const ratio = rect.height / Math.max(1, rect.width); const 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; } const imageData = noiseCtx.createImageData(w, h); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const 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); } // ---- Helpers ---- 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", }); } // ---- Prefetch ---- const prefetchedUrls = {}; function prefetchVideo(url) { if (!url || prefetchedUrls[url]) return; prefetchedUrls[url] = true; const link = document.createElement("link"); link.rel = "prefetch"; link.as = "video"; link.href = url; document.head.appendChild(link); } function schedulePrefetch(clip) { const doPrefetch = function () { // Sibling angles at same timestamp const group = byTimestamp[clip.timestamp] || {}; Object.keys(group).forEach(function (key) { if (key !== clip.cam) prefetchVideo(group[key].url); }); // Next clip in same camera (oldest-first) const sameCam = (allVideos[clip.cam] && allVideos[clip.cam].videos) || []; const 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); } } // ---- Prev/Next navigation ---- function findClipIndex(clip) { const sameCam = (allVideos[clip.cam] && allVideos[clip.cam].videos) || []; return sameCam.findIndex(function (c) { return c.timestamp === clip.timestamp && c.filename === clip.filename; }); } function prevClip() { if (!currentClip) return; const sameCam = (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; const idx = findClipIndex(currentClip); if (idx > 0) playClip(sameCam[idx - 1]); } function nextClip() { if (!currentClip) return; const sameCam = (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; const idx = findClipIndex(currentClip); if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]); } function firstClip() { if (!currentClip) return; const sameCam = (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; if (sameCam.length > 0) playClip(sameCam[0]); } prevBtn.addEventListener("click", prevClip); nextBtn.addEventListener("click", nextClip); firstBtn.addEventListener("click", firstClip); // ---- Playback ---- function playClip(clip) { if (!clip) return; currentClip = clip; currentCam = clip.cam; player.src = clip.url; player.load(); noiseStart(); let prefetchDone = false; function onPlaying() { noiseStop(); if (prefetchDone) return; prefetchDone = true; player.removeEventListener("playing", onPlaying); schedulePrefetch(clip); } player.addEventListener("playing", onPlaying); player.play().catch(function () { 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); npLocation.textContent = clip.location || ""; updateCamButtons(clip.cam); } function switchCam(camKey) { if (!currentClip) return; const group = byTimestamp[currentClip.timestamp] || {}; const target = group[camKey]; if (!target) return; const savedTime = player.currentTime || 0; const wasPlaying = !player.paused; currentClip = target; currentCam = camKey; player.src = target.url; player.load(); badgeText.textContent = target.camDisplay; npName.textContent = target.filename; npLocation.textContent = target.location || ""; 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 () {}); }); updateCamButtons(camKey); } function updateCamButtons(activeKey) { csBtns.forEach(function (btn) { const key = btn.getAttribute("data-cam"); btn.classList.toggle("active", key === activeKey); }); } // ---- Auto-play next clip in same camera ---- player.addEventListener("ended", function () { if (!currentClip) return; const sameCam = (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; const idx = findClipIndex(currentClip); if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]); }); // ---- Camera button clicks ---- document.querySelector(".cs-grid").addEventListener("click", function (e) { const btn = e.target.closest(".cs-btn[data-cam]"); if (!btn || btn.disabled) return; const camKey = btn.getAttribute("data-cam"); if (camKey === currentCam) return; // already active // Try same timestamp first, else fall back to oldest in that camera const group = currentClip ? byTimestamp[currentClip.timestamp] || {} : {}; const sameTs = group[camKey]; if (sameTs) { switchCam(camKey); } else { // No same-timestamp match: play oldest clip for this camera const videos = (allVideos[camKey] && allVideos[camKey].videos) || []; if (videos.length > 0) playClip(videos[0]); } }); // ---- Keyboard shortcuts: 1-6 for camera angles, ArrowLeft/Right for prev/next ---- const camOrder = [ "front", "left_repeater", "right_repeater", "back", "left_pillar", "right_pillar", ]; document.addEventListener("keydown", function (e) { if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return; if (e.key >= "1" && e.key <= "6") { const idx = +e.key - 1; const camKey = camOrder[idx]; if (!camKey) return; const group = currentClip ? byTimestamp[currentClip.timestamp] || {} : {}; const sameTs = group[camKey]; if (sameTs) { switchCam(camKey); } else { const videos = (allVideos[camKey] && allVideos[camKey].videos) || []; if (videos.length > 0) playClip(videos[0]); } } if (e.key === "ArrowLeft") { e.preventDefault(); prevClip(); } if (e.key === "ArrowRight") { e.preventDefault(); nextClip(); } }); // ---- Region filter ---- let regionData = {}; function fetchRegions() { fetch("/api/regions") .then(function (r) { return r.json(); }) .then(function (data) { regionData = data.regions || {}; buildRegionButtons(); }) .catch(function (err) { console.error("Failed to load regions:", err); }); } function buildRegionButtons() { // Remove existing region buttons, but preserve #firstBtn (no data-region) var existing = regionBar.querySelectorAll( ".region-btn[data-region]:not([data-region=''])", ); existing.forEach(function (b) { b.remove(); }); Object.keys(regionData).forEach(function (name) { var btn = document.createElement("button"); btn.className = "region-btn"; btn.setAttribute("data-region", name); btn.textContent = name + " (" + regionData[name].count + ")"; regionBar.appendChild(btn); }); } regionBar.addEventListener("click", function (e) { var btn = e.target.closest(".region-btn"); if (!btn) return; var region = btn.getAttribute("data-region"); // Update active state regionBar.querySelectorAll(".region-btn").forEach(function (b) { b.classList.toggle("active", b === btn); }); if (!region) return; // "All" selected — no jump var info = regionData[region]; if (!info || !info.first_timestamp) return; // Jump to first front-camera clip for this region var frontVideos = (allVideos["front"] && allVideos["front"].videos) || []; var clip = frontVideos.find(function (c) { return c.timestamp === info.first_timestamp; }); if (clip) playClip(clip); }); // ---- Init: fetch full video data, then auto-play front ---- function init() { // Fetch regions data for the filter bar fetchRegions(); fetch("/api/videos") .then(function (r) { return r.json(); }) .then(function (data) { allVideos = data; // Build byTimestamp index Object.keys(data).forEach(function (camKey) { const videos = data[camKey].videos || []; videos.forEach(function (v) { v.cam = camKey; v.camDisplay = data[camKey].display; if (!byTimestamp[v.timestamp]) byTimestamp[v.timestamp] = {}; byTimestamp[v.timestamp][camKey] = v; }); }); // Auto-play front if video element has a src if (player.src && player.src !== window.location.href) { const frontVideos = (allVideos["front"] && allVideos["front"].videos) || []; if (frontVideos.length > 0) { playClip(frontVideos[0]); } } }) .catch(function (err) { console.error("Failed to load video data:", err); }); } // Start noise immediately noiseStart(); init(); })();