406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
(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();
|
|
})();
|