Files
Self-Hosted-Stream-Webcam/index.html
T
2026-09-13 20:24:20 +01:00

1377 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Camera Station</title>
<style>
:root{
--chassis:#0e1116;
--panel:#161b22;
--panel-hi:#1e252e;
--edge:#2a323d;
--ink:#e6e4df;
--ink-dim:#7d8794;
--phosphor:#ffb347;
--tally:#ff3b30;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
--sans: "Helvetica Neue", "Inter", "Segoe UI", system-ui, sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--chassis);
color:var(--ink);
font-family:var(--mono);
font-size:13px;
min-height:100vh;
padding:18px;
display:flex;
justify-content:center;
-webkit-font-smoothing:antialiased;
}
.station{width:100%;max-width:1080px;display:flex;flex-direction:column;gap:14px}
/* ---------- header ---------- */
header{
display:flex;align-items:baseline;justify-content:space-between;
gap:16px;flex-wrap:wrap;
padding-bottom:12px;border-bottom:1px solid var(--edge);
}
.mark{display:flex;align-items:baseline;gap:12px}
.mark h1{
font-family:var(--sans);
font-size:15px;font-weight:700;
letter-spacing:.34em;text-transform:uppercase;
}
.source{color:var(--ink-dim);font-size:12px;letter-spacing:.02em}
.link{color:var(--ink-dim);text-decoration:none;border-bottom:1px dotted var(--edge)}
.link:hover{color:var(--phosphor)}
.signal{
display:flex;align-items:center;gap:8px;
font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim);
}
.readout{display:flex;align-items:center;gap:16px}
.clock{display:flex;align-items:baseline;gap:8px;font-variant-numeric:tabular-nums}
.clock .time{font-size:15px;color:var(--ink);letter-spacing:.06em}
.clock .date{font-size:11px;color:var(--ink-dim);letter-spacing:.16em;text-transform:uppercase}
.lamp{
width:7px;height:7px;border-radius:50%;
background:var(--edge);box-shadow:0 0 0 3px rgba(255,255,255,.03);
}
[data-signal="live"] .lamp{background:var(--phosphor);box-shadow:0 0 9px var(--phosphor)}
[data-signal="live"] .signal{color:var(--phosphor)}
[data-signal="lost"] .lamp{background:var(--tally)}
[data-signal="lost"] .signal{color:var(--tally)}
/* ---------- camera tabs ---------- */
.tabstrip{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}
.tabs{display:flex;gap:8px;flex-wrap:wrap}
.tabtools{display:flex;gap:8px;align-items:center;margin-left:auto}
.tabtools .quiet{
font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
color:var(--ink-dim);background:transparent;border:1px solid var(--edge);
border-radius:2px;padding:7px 11px;text-decoration:none;display:inline-block;
}
.tabtools .quiet:hover{color:var(--ink);border-color:#3c4654}
body[data-multi="no"] .tabs{display:none}
body[data-multi="no"] #previewRate{display:none}
.tab{
display:flex;align-items:center;gap:10px;
padding:7px 12px 7px 7px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;
background:var(--panel);border:1px solid var(--edge);border-radius:2px;color:var(--ink-dim);
}
.tab .thumb{
width:56px;height:32px;border-radius:1px;display:block;flex:none;
background:#0a0d11;border:1px solid #202832;object-fit:cover;
filter:grayscale(.35) brightness(.8);transition:filter .18s ease;
}
.tab[aria-pressed="true"] .thumb{filter:none}
.tab[data-thumb="yes"] .thumb{border-color:#2f3a47}
.tab .label{display:flex;align-items:center;gap:8px;min-width:0}
.tab .dot{width:6px;height:6px;border-radius:50%;background:var(--edge);flex:none}
.tab[data-state="live"] .dot{background:var(--phosphor);box-shadow:0 0 7px var(--phosphor)}
.tab[data-state="lost"] .dot{background:var(--tally)}
.tab[data-state="waiting"] .dot{background:var(--ink-dim)}
.tab[aria-pressed="true"]{background:var(--panel-hi);border-color:#3c4654;color:var(--ink)}
.tab[data-rec="yes"]{border-color:var(--tally)}
.tab[data-rec="yes"]::after{
content:"";width:7px;height:7px;border-radius:1px;background:var(--tally);
animation:breathe 1.6s ease-in-out infinite;
}
.tab:disabled{opacity:.4}
.tab .name{letter-spacing:.16em}
.tab .key{font-size:9px}
/* ---------- tally strip: the one loud element ---------- */
.tally{
height:26px;border:1px solid var(--edge);border-radius:2px;
background:var(--panel);
display:flex;align-items:center;gap:12px;padding:0 12px;
font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--ink-dim);
transition:background .12s, border-color .12s, color .12s;
}
body[data-rec="on"] .tally{
background:var(--tally);border-color:var(--tally);color:#fff;
animation:breathe 1.6s ease-in-out infinite;
}
body[data-rec="on"] .tally .timecode{color:#fff}
@keyframes breathe{50%{filter:brightness(.74)}}
/* The tally strip is uppercase, but the day marker must not be: "3D 04:05:08"
reads as three-dimensional rather than three days. */
.tally .timecode{
margin-left:auto;letter-spacing:.1em;font-variant-numeric:tabular-nums;
text-transform:none;
}
/* ---------- viewport ---------- */
.viewport{
position:relative;background:#000;
border:1px solid var(--edge);border-radius:2px;
aspect-ratio:16/9;overflow:hidden;
display:flex;align-items:center;justify-content:center;
}
.viewport canvas{max-width:100%;max-height:100%;display:block;image-rendering:auto}
.viewport img{display:none}
.viewport[data-zoomed="yes"]{cursor:grab}
.viewport[data-zoomed="yes"][data-dragging="yes"]{cursor:grabbing}
.viewport canvas{touch-action:none}
/* zoom overlay */
.zoom{
position:absolute;left:12px;bottom:12px;z-index:2;
display:flex;align-items:stretch;gap:1px;
background:rgba(10,13,18,.82);border:1px solid var(--edge);border-radius:2px;
backdrop-filter:blur(3px);overflow:hidden;
}
.zoom[hidden]{display:none}
.zoom button{
border:0;border-radius:0;background:transparent;color:var(--ink);
padding:0 12px;min-width:34px;font-size:14px;letter-spacing:0;
justify-content:center;
}
.zoom button:hover:not(:disabled){background:rgba(255,255,255,.07)}
.zoom button:disabled{opacity:.28}
.zoom #zoomFit{font-size:10px;letter-spacing:.18em;padding:0 12px;border-left:1px solid var(--edge)}
.zoom .level{
display:flex;flex-direction:column;justify-content:center;
padding:6px 12px;min-width:104px;text-align:center;
border-left:1px solid var(--edge);border-right:1px solid var(--edge);
}
.zoom .level b{font-size:13px;font-weight:600;color:var(--phosphor);font-variant-numeric:tabular-nums}
.zoom .level i{font-style:normal;font-size:9px;letter-spacing:.1em;color:var(--ink-dim);white-space:nowrap}
.zoom[data-past="yes"] .level i{color:#8a6a2f}
/* viewfinder brackets */
.bracket{position:absolute;width:20px;height:20px;border:1px solid var(--phosphor);opacity:.5;pointer-events:none}
.bracket.tl{top:10px;left:10px;border-right:0;border-bottom:0}
.bracket.tr{top:10px;right:10px;border-left:0;border-bottom:0}
.bracket.bl{bottom:10px;left:10px;border-right:0;border-top:0}
.bracket.br{bottom:10px;right:10px;border-left:0;border-top:0}
.placeholder{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
flex-direction:column;gap:8px;text-align:center;padding:20px;
color:var(--ink-dim);font-size:11px;letter-spacing:.2em;text-transform:uppercase;
}
.placeholder .detail{letter-spacing:.02em;text-transform:none;font-size:12px;max-width:38ch;line-height:1.6}
.placeholder[hidden]{display:none}
.shutter{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none}
.shutter.fire{animation:flash .22s ease-out}
@keyframes flash{0%{opacity:.85}100%{opacity:0}}
/* ---------- controls ---------- */
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:stretch}
button{
font-family:var(--mono);font-size:12px;
letter-spacing:.18em;text-transform:uppercase;
color:var(--ink);background:var(--panel);
border:1px solid var(--edge);border-radius:2px;
padding:12px 20px;cursor:pointer;
display:flex;align-items:center;gap:9px;
transition:background .12s,border-color .12s,color .12s;
}
button:hover:not(:disabled){background:var(--panel-hi);border-color:#3c4654}
button:disabled{opacity:.35;cursor:not-allowed}
button:focus-visible{outline:2px solid var(--phosphor);outline-offset:2px}
.key{color:var(--ink-dim);font-size:10px;letter-spacing:.1em;border:1px solid var(--edge);border-radius:2px;padding:1px 5px}
#record .dot{width:9px;height:9px;border-radius:50%;background:var(--tally);flex:none}
body[data-rec="on"] #record{background:var(--tally);border-color:var(--tally);color:#fff}
body[data-rec="on"] #record .dot{background:#fff;border-radius:1px}
body[data-rec="on"] #record .key{border-color:rgba(255,255,255,.4);color:rgba(255,255,255,.75)}
#stamp[aria-pressed="true"]{color:var(--phosphor);border-color:#4a4030;background:var(--panel-hi)}
.seg{display:flex;margin-left:auto;border:1px solid var(--edge);border-radius:2px;overflow:hidden}
.seg button{border:0;border-radius:0;padding:12px 14px;letter-spacing:.14em;color:var(--ink-dim)}
.seg button[aria-pressed="true"]{background:var(--panel-hi);color:var(--phosphor)}
/* ---------- telemetry ---------- */
.telemetry{
display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));
gap:1px;background:var(--edge);border:1px solid var(--edge);border-radius:2px;
}
.cell{background:var(--panel);padding:10px 12px;display:flex;flex-direction:column;gap:5px}
.cell dt{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim)}
.cell dd{font-size:14px;color:var(--phosphor);font-variant-numeric:tabular-nums}
.cell.rate{grid-column:span 2}
.rate .row{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
.rate .scale{font-size:10px;color:var(--ink-dim);letter-spacing:.1em}
#meter{display:block;width:100%;height:26px;margin-top:2px}
@media (max-width:700px){ .cell.rate{grid-column:span 2} }
/* ---------- captures ---------- */
.captures h2{
font-family:var(--sans);font-size:11px;font-weight:700;
letter-spacing:.28em;text-transform:uppercase;color:var(--ink-dim);
margin-bottom:10px;
}
.roll{display:flex;gap:10px;overflow-x:auto;padding-bottom:6px}
.roll .empty{color:var(--ink-dim);font-size:12px;line-height:1.6}
.shot{flex:none;width:132px;text-decoration:none;color:var(--ink-dim);display:block}
.shot img{width:132px;height:74px;object-fit:cover;background:#000;border:1px solid var(--edge);border-radius:2px;display:block}
.shot:hover img{border-color:var(--phosphor)}
.shot em{display:block;font-style:normal;font-size:10px;letter-spacing:.16em;
text-transform:uppercase;color:var(--ink);margin-top:6px}
.shot span{display:block;font-size:10px;letter-spacing:.08em;margin-top:2px}
footer{color:var(--ink-dim);font-size:11px;line-height:1.8;border-top:1px solid var(--edge);padding-top:12px}
kbd{font-family:var(--mono);border:1px solid var(--edge);border-radius:2px;padding:0 4px}
@media (max-width:560px){
body{padding:12px}
.controls button{flex:1;justify-content:center}
.seg{margin-left:0;width:100%}
.seg button{flex:1;justify-content:center}
}
@media (prefers-reduced-motion:reduce){
*{animation:none !important;transition:none !important}
}
</style>
</head>
<body data-signal="waiting" data-rec="off">
<div class="station">
<header>
<div class="mark">
<h1>Camera</h1>
<span class="source" id="source">&nbsp;</span>
</div>
<div class="readout">
<div class="clock">
<span class="time" id="clockTime">--:--:--</span>
<span class="date" id="clockDate">&nbsp;</span>
</div>
<div class="signal"><span class="lamp"></span><span id="signalText">Connecting</span></div>
</div>
</header>
<nav class="tabstrip">
<div class="tabs" id="tabs" aria-label="Cameras"></div>
<div class="tabtools">
<button class="quiet" id="previewRate">Previews</button>
<a class="quiet link" href="/files">Files</a>
<a class="quiet link" href="/config">Set up cameras</a>
</div>
</nav>
<div class="tally">
<span id="tallyLabel">Standby</span>
<span class="timecode" id="timecode">00:00:00</span>
</div>
<div class="viewport">
<img id="feed" alt="">
<canvas id="frame" width="1280" height="720"></canvas>
<span class="bracket tl"></span><span class="bracket tr"></span>
<span class="bracket bl"></span><span class="bracket br"></span>
<div class="shutter" id="shutter"></div>
<div class="zoom" id="zoom" hidden data-past="no">
<button id="zoomOut" aria-label="Zoom out" disabled>&minus;</button>
<span class="level">
<b id="zoomLevel">1.0&times;</b>
<i id="zoomDetail">&nbsp;</i>
</span>
<button id="zoomIn" aria-label="Zoom in">+</button>
<button id="zoomFit" disabled>Fit</button>
</div>
<div class="placeholder" id="placeholder">
<span id="phTitle">Waiting for signal</span>
<span class="detail" id="phDetail">Opening the stream.</span>
</div>
</div>
<div class="controls">
<button id="record" disabled>
<span class="dot"></span><span id="recordLabel">Start recording</span><span class="key">R</span>
</button>
<button id="capture" disabled>
Take photo <span class="key">Space</span>
</button>
<button id="stamp" aria-pressed="false" disabled>Stamp time</button>
<div class="seg" role="group" aria-label="Photo format">
<button id="fmtPng" aria-pressed="true">PNG</button>
<button id="fmtJpg" aria-pressed="false">JPEG</button>
</div>
</div>
<dl class="telemetry">
<div class="cell"><dt>Resolution</dt><dd id="mRes"></dd></div>
<div class="cell rate">
<div class="row">
<dt>Frame rate</dt>
<span class="scale" id="mScale">last 60s</span>
</div>
<dd id="mFps"></dd>
<canvas id="meter" width="600" height="52"></canvas>
</div>
<div class="cell"><dt>Uptime</dt><dd id="mUptime">00:00:00</dd></div>
<div class="cell"><dt>Recorded</dt><dd id="mSize"></dd></div>
<div class="cell"><dt>Photos</dt><dd id="mShots">0</dd></div>
</dl>
<section class="captures">
<h2>Photos saved on the server</h2>
<div class="roll" id="roll">
<p class="empty">Photos are stored on the server as <em>date_time_camera</em>. This list clears when you reload; the files do not.</p>
</div>
</section>
<footer id="footer">
<kbd>R</kbd> record &nbsp;·&nbsp; <kbd>Space</kbd> photo &nbsp;·&nbsp;
<kbd>1</kbd> <kbd>2</kbd> switch camera &nbsp;·&nbsp;
<kbd>+</kbd> <kbd>&minus;</kbd> zoom, <kbd>0</kbd> fit, arrows pan &nbsp;·&nbsp;
Recording runs on the server and continues when this page is closed.
Photos are saved on the server automatically.
</footer>
</div>
<script>
(function () {
"use strict";
var MAX_BACKING = 4096; // don't allocate absurd canvases on huge displays
var MAX_ZOOM = 12;
// What you actually see. Backed at device pixels, drawn straight from the
// active camera's master frame.
var view = document.getElementById("frame");
var vctx = view.getContext("2d");
// 8x8 downscale used only to notice when a new JPEG frame has landed.
var probe = document.createElement("canvas");
probe.width = probe.height = 8;
var pctx = probe.getContext("2d", { willReadFrequently: true });
var el = {
viewport: document.querySelector(".viewport"),
tabs: document.getElementById("tabs"),
previewRate: document.getElementById("previewRate"),
source: document.getElementById("source"),
signal: document.getElementById("signalText"),
tally: document.getElementById("tallyLabel"),
timecode: document.getElementById("timecode"),
record: document.getElementById("record"),
recordLabel: document.getElementById("recordLabel"),
capture: document.getElementById("capture"),
placeholder: document.getElementById("placeholder"),
phTitle: document.getElementById("phTitle"),
phDetail: document.getElementById("phDetail"),
shutter: document.getElementById("shutter"),
roll: document.getElementById("roll"),
res: document.getElementById("mRes"),
fps: document.getElementById("mFps"),
uptime: document.getElementById("mUptime"),
size: document.getElementById("mSize"),
shots: document.getElementById("mShots"),
fmtPng: document.getElementById("fmtPng"),
fmtJpg: document.getElementById("fmtJpg"),
clockTime: document.getElementById("clockTime"),
clockDate: document.getElementById("clockDate"),
scale: document.getElementById("mScale"),
meter: document.getElementById("meter"),
stamp: document.getElementById("stamp"),
zoom: document.getElementById("zoom"),
zoomIn: document.getElementById("zoomIn"),
zoomOut: document.getElementById("zoomOut"),
zoomFit: document.getElementById("zoomFit"),
zoomLevel: document.getElementById("zoomLevel"),
zoomDetail: document.getElementById("zoomDetail")
};
var cameras = [];
var active = null;
var shots = 0;
var format = "png";
var stampOn = false;
var cssW = 0, cssH = 0;
// How often a camera you are NOT watching refreshes its thumbnail. This is a
// per-viewer preference rather than a server setting: a phone and a desktop
// looking at the same cameras can reasonably want different answers.
var PREVIEW_RATES = [
{ ms: 5000, label: "5s" },
{ ms: 15000, label: "15s" },
{ ms: 60000, label: "60s" },
{ ms: 0, label: "off" }
];
var previewRate = 1;
try {
var stored = window.localStorage.getItem("cameraStation.previewRate");
if (stored !== null) previewRate = Math.min(Math.max(parseInt(stored, 10) || 0, 0),
PREVIEW_RATES.length - 1);
} catch (e) { /* private browsing; the default is fine */ }
var previewMs = PREVIEW_RATES[previewRate].ms;
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function pad(n) { return n < 10 ? "0" + n : "" + n; }
// ------------------------------------------------------------------
// camera
// ------------------------------------------------------------------
function Camera(def, index) {
this.id = def.id;
this.name = def.name || ("Camera " + (index + 1));
this.kind = def.kind || "mjpeg";
this.host = def.host || "";
this.available = def.available !== false;
this.reason = def.reason || null;
this.index = index;
// Each camera keeps its own master frame at its own native resolution.
this.src = document.createElement("canvas");
this.sctx = this.src.getContext("2d");
this.img = document.createElement("img");
this.img.alt = "";
this.img.decoding = "async";
el.viewport.appendChild(this.img);
this.zoom = 1; this.cx = 0.5; this.cy = 0.5;
this.signal = "idle";
this.badge = "Idle";
this.title = "Not connected";
this.detail = "Select this camera to start watching.";
this.lastSig = -1;
this.lastFrameAt = 0;
this.firstFrameAt = 0;
this.fpsWindow = 0;
this.fpsMark = 0;
this.fps = null;
this.history = [];
this.retryIn = 1000;
this.stallRetry = 0;
this.connected = false;
// Recording happens on the server, so the page only mirrors its state.
this.activeFlag = def.active === true;
this.recording = def.recording === true;
this.since = def.since || null;
this.recordError = def.recordError || null;
this.busy = false;
var self = this;
this.img.onerror = function () {
if (!self.connected) return;
self.setSignal("lost", "No signal", "Cannot reach " + self.name,
self.kind === "rtsp"
? "The server could not decode this RTSP camera. Check the address and credentials in server.py, and that ffmpeg is installed. Retrying."
: "The stream did not respond. Check that the camera is running. Retrying.");
setTimeout(function () { self.connect(false); }, self.retryIn);
self.retryIn = Math.min(self.retryIn * 2, 10000);
};
this.buildTab();
if (!this.available) {
this.setSignal("lost", "Unavailable", this.name + " is unavailable",
this.reason || "This camera cannot be used.");
}
}
Camera.prototype.buildTab = function () {
var self = this;
var tab = document.createElement("button");
tab.className = "tab";
tab.dataset.state = "idle";
tab.setAttribute("aria-pressed", "false");
tab.disabled = !this.available;
tab.title = this.available ? (this.kind + " · " + this.host)
: (this.reason || "Unavailable");
var thumb = document.createElement("canvas");
thumb.className = "thumb";
thumb.width = 112; thumb.height = 63; // 16:9 backing, sized up for sharpness
var dot = document.createElement("span"); dot.className = "dot";
var name = document.createElement("span"); name.className = "name";
name.textContent = this.name;
var key = document.createElement("span"); key.className = "key";
key.textContent = String(this.index + 1);
var text = document.createElement("span"); text.className = "label";
text.appendChild(dot); text.appendChild(name);
tab.appendChild(thumb); tab.appendChild(text); tab.appendChild(key);
this.thumb = thumb;
this.tctx = thumb.getContext("2d");
this.thumbAt = 0;
this.thumbPending = false;
this.hasThumb = false;
tab.addEventListener("click", function () { select(self); });
el.tabs.appendChild(tab);
this.tab = tab;
};
Camera.prototype.needsFeed = function () {
// The server records on its own, so the page only needs the feed it shows.
return this.available && this === active;
};
Camera.prototype.connect = function (announce) {
if (!this.available) return;
this.connected = true;
if (announce) {
this.setSignal("waiting", "Connecting", "Waiting for " + this.name,
this.kind === "rtsp"
? "Opening the RTSP stream. The first frame can take a few seconds."
: "Opening the stream.");
}
this.img.src = "/stream?cam=" + encodeURIComponent(this.id) + "&t=" + Date.now();
};
Camera.prototype.disconnect = function () {
// Dropping the connection also frees the server: an RTSP camera with no
// viewers stops its ffmpeg process instead of decoding into the void.
this.connected = false;
this.img.removeAttribute("src");
this.lastSig = -1;
this.firstFrameAt = 0;
this.history = [];
this.fps = null;
this.signal = "idle";
this.badge = "Idle";
this.title = "Not connected";
this.detail = "Select this camera to start watching.";
this.paintTab();
};
Camera.prototype.setSignal = function (kind, badge, title, detail) {
this.signal = kind;
this.badge = badge;
if (title) { this.title = title; this.detail = detail; }
this.paintTab();
if (this === active) paint();
};
Camera.prototype.paintTab = function () {
if (!this.tab) return;
this.tab.dataset.state = this.signal;
this.tab.dataset.rec = this.recording ? "yes" : "no";
this.tab.setAttribute("aria-pressed", String(this === active));
};
// Centre-crop whatever we are given so every camera's thumbnail fills the
// same box, whatever shape that camera's picture happens to be.
Camera.prototype.drawThumb = function (source, sw, sh) {
if (!this.tctx || !sw || !sh) return;
var t = this.thumb;
var want = t.width / t.height;
var have = sw / sh;
var cw = sw, ch = sh, cx = 0, cy = 0;
if (have > want) { cw = Math.round(sh * want); cx = Math.round((sw - cw) / 2); }
else { ch = Math.round(sw / want); cy = Math.round((sh - ch) / 2); }
this.tctx.imageSmoothingEnabled = true;
this.tctx.imageSmoothingQuality = "high";
try {
this.tctx.drawImage(source, cx, cy, cw, ch, 0, 0, t.width, t.height);
this.hasThumb = true;
this.tab.dataset.thumb = "yes";
} catch (e) { /* a frame that is not ready yet */ }
};
// Cameras we are not watching are not connected, so their preview comes from
// a periodic still instead. This costs real work for an RTSP camera — the
// server has to run ffmpeg briefly — which is why the rate is adjustable and
// why the camera on screen is refreshed from its own canvas for free.
Camera.prototype.refreshThumb = function (now) {
if (!this.available || this.thumbPending) return;
if (previewMs === 0 && this.hasThumb) return;
var due = previewMs === 0 ? 0 : previewMs;
if (this.hasThumb && now - this.thumbAt < due) return;
if (!this.hasThumb && now - this.thumbAt < 3000) return; // don't hammer a dead camera
var self = this;
this.thumbPending = true;
this.thumbAt = now;
var img = new Image();
img.onload = function () {
self.drawThumb(img, img.naturalWidth, img.naturalHeight);
self.thumbPending = false;
};
img.onerror = function () { self.thumbPending = false; };
img.src = "/snapshot?cam=" + encodeURIComponent(this.id) + "&t=" + Math.round(now);
};
Camera.prototype.crop = function () {
var w = this.src.width, h = this.src.height;
var cw = Math.max(16, Math.round(w / this.zoom));
var ch = Math.max(16, Math.round(h / this.zoom));
var cx = Math.round(clamp(this.cx * w - cw / 2, 0, w - cw));
var cy = Math.round(clamp(this.cy * h - ch / 2, 0, h - ch));
return { x: cx, y: cy, w: cw, h: ch };
};
Camera.prototype.clampCentre = function () {
var half = 1 / (2 * this.zoom);
this.cx = clamp(this.cx, half, 1 - half);
this.cy = clamp(this.cy, half, 1 - half);
};
// ------------------------------------------------------------------
// camera selection
// ------------------------------------------------------------------
function select(cam) {
if (!cam || !cam.available || cam === active) return;
active = cam;
cameras.forEach(function (c) { c.paintTab(); });
updateConnections();
measure();
paint();
render();
}
function updateConnections() {
cameras.forEach(function (c) {
if (c.needsFeed() && !c.connected) c.connect(true);
else if (!c.needsFeed() && c.connected) c.disconnect();
});
}
// Everything the header, tally and telemetry show belongs to the active
// camera, so they are repainted from one place.
function paint() {
if (!active) return;
el.source.textContent = active.kind + " · " + active.host;
el.signal.textContent = active.badge;
document.body.dataset.signal = active.signal;
var live = active.signal === "live";
if (live) {
el.placeholder.hidden = true;
// These are per-camera values and the draw loop only refreshes them when
// they change, so a camera you switch back to would otherwise keep
// showing the blanks left behind by the one you were just watching.
if (active.src.width) {
el.res.textContent = active.src.width + "\u00d7" + active.src.height;
el.viewport.style.aspectRatio = active.src.width + " / " + active.src.height;
}
el.fps.textContent = active.fps === null ? "—" : active.fps + " fps";
} else {
el.placeholder.hidden = false;
el.phTitle.textContent = active.title;
el.phDetail.textContent = active.detail;
el.res.textContent = "—";
el.fps.textContent = "—";
}
el.capture.disabled = !live;
el.stamp.disabled = !live;
// Recording is a server job, so the button stays usable even if this page
// has momentarily lost the picture.
el.zoom.hidden = !live;
document.body.dataset.rec = active.recording ? "on" : "off";
el.tally.textContent = active.recording
? "Recording " + active.name + " to the server"
: active.activeFlag ? "Starting the recorder\u2026" : "Standby";
el.recordLabel.textContent = active.activeFlag ? "Stop recording" : "Start recording";
el.record.disabled = active.busy || !active.available;
if (!active.recording) el.timecode.textContent = "00:00:00";
drawMeter();
}
// ------------------------------------------------------------------
// rendering
// ------------------------------------------------------------------
function render() {
if (!active) return;
var w = active.src.width, h = active.src.height;
if (!w || !h || !cssW) return;
var dpr = window.devicePixelRatio || 1;
var bw = Math.min(Math.round(cssW * dpr), MAX_BACKING);
var bh = Math.max(1, Math.round(bw * h / w));
if (view.width !== bw || view.height !== bh) {
view.width = bw;
view.height = bh;
}
var c = active.crop();
vctx.imageSmoothingEnabled = true;
vctx.imageSmoothingQuality = "high";
vctx.drawImage(active.src, c.x, c.y, c.w, c.h, 0, 0, view.width, view.height);
if (stampOn) drawStamp(vctx, view.width, view.height);
var displayScale = view.width / c.w;
el.zoomLevel.textContent =
(active.zoom < 10 ? active.zoom.toFixed(1) : Math.round(active.zoom)) + "\u00d7";
el.zoomDetail.textContent = c.w + "\u00d7" + c.h + " px · " +
(displayScale <= 1.05 ? "1:1" : "screen " + displayScale.toFixed(1) + "\u00d7");
el.zoom.dataset.past = displayScale > 2.5 ? "yes" : "no";
el.zoomOut.disabled = active.zoom <= 1.0001;
el.zoomIn.disabled = active.zoom >= MAX_ZOOM - 0.0001;
el.zoomFit.disabled = active.zoom <= 1.0001;
el.viewport.dataset.zoomed = active.zoom > 1.0001 ? "yes" : "no";
}
function tick(now) {
requestAnimationFrame(tick);
for (var i = 0; i < cameras.length; i++) {
var cam = cameras[i];
if (!cam.connected) continue;
var w = cam.img.naturalWidth, h = cam.img.naturalHeight;
if (!w || !h) continue;
if (cam.src.width !== w || cam.src.height !== h) {
cam.src.width = w;
cam.src.height = h;
cam.clampCentre();
if (cam === active) {
el.res.textContent = w + "\u00d7" + h;
el.viewport.style.aspectRatio = w + " / " + h;
measure();
}
}
cam.sctx.drawImage(cam.img, 0, 0, w, h);
// Signature of the raw frame: no stamp, no zoom, so neither can be
// mistaken for the picture actually changing.
pctx.drawImage(cam.src, 0, 0, 8, 8);
var px = pctx.getImageData(0, 0, 8, 8).data;
var sig = 0;
for (var j = 0; j < px.length; j += 4) {
sig = (sig * 31 + px[j] + px[j + 1] * 3 + px[j + 2] * 7) & 0x7fffffff;
}
if (sig !== cam.lastSig) {
cam.lastSig = sig;
cam.lastFrameAt = now;
cam.fpsWindow++;
if (!cam.firstFrameAt) cam.firstFrameAt = now;
if (cam.signal !== "live") {
cam.retryIn = 1000;
cam.setSignal("live", "Live");
}
}
if (now - cam.fpsMark >= 1000) {
if (cam.fpsMark) {
cam.fps = cam.fpsWindow;
cam.history.push(cam.fpsWindow);
if (cam.history.length > 60) cam.history.shift();
if (cam === active) {
el.fps.textContent = cam.fpsWindow + " fps";
drawMeter();
}
}
cam.fpsWindow = 0;
cam.fpsMark = now;
}
if (cam.signal === "live" && now - cam.lastFrameAt > 5000) {
cam.setSignal("lost", "Stalled", "The picture from " + cam.name + " stopped updating",
"The connection is open but no new frames are arriving. Reconnecting.");
cam.stallRetry = now;
cam.connect(false);
} else if (cam.signal === "lost" && cam.stallRetry && now - cam.stallRetry > 5000) {
cam.stallRetry = now;
cam.connect(false); // keep trying, without disturbing the message on screen
}
}
render();
// The camera on screen gets a free thumbnail from the frame we already have.
if (active && active.src.width && now - active.thumbAt > 500) {
active.thumbAt = now;
active.drawThumb(active.src, active.src.width, active.src.height);
}
if (cameras.length > 1) {
for (var k = 0; k < cameras.length; k++) {
var other = cameras[k];
if (other !== active && !other.connected) other.refreshThumb(now);
}
}
if (active) {
el.uptime.textContent = active.firstFrameAt
? duration((now - active.firstFrameAt) / 1000) : "00:00:00";
if (active.recording && active.since) {
el.timecode.textContent = duration(Date.now() / 1000 - active.since);
}
}
}
// ------------------------------------------------------------------
// zoom and pan (per camera)
// ------------------------------------------------------------------
function setZoom(z, anchorX, anchorY) {
if (!active) return;
var next = clamp(z, 1, MAX_ZOOM);
if (Math.abs(next - active.zoom) < 0.0001) return;
if (anchorX === undefined) { anchorX = 0.5; anchorY = 0.5; }
var beforeW = 1 / active.zoom;
var pointX = (active.cx - beforeW / 2) + anchorX * beforeW;
var pointY = (active.cy - beforeW / 2) + anchorY * beforeW;
var afterW = 1 / next;
active.cx = pointX - anchorX * afterW + afterW / 2;
active.cy = pointY - anchorY * afterW + afterW / 2;
active.zoom = next;
active.clampCentre();
render();
}
function panBy(dxCss, dyCss) {
if (!active || !cssW || !cssH) return;
active.cx -= (dxCss / cssW) / active.zoom;
active.cy -= (dyCss / cssH) / active.zoom;
active.clampCentre();
render();
}
function resetZoom() {
if (!active) return;
active.zoom = 1;
active.cx = active.cy = 0.5;
render();
}
function anchorFrom(e) {
var r = el.viewport.getBoundingClientRect();
return {
x: clamp((e.clientX - r.left) / r.width, 0, 1),
y: clamp((e.clientY - r.top) / r.height, 0, 1)
};
}
// The zoom controls sit inside the viewport, so pointer gestures must ignore
// them. Without this the drag handler captures the pointer and the buttons
// never receive a click at all.
function onControls(e) { return !!(e.target.closest && e.target.closest(".zoom")); }
el.viewport.addEventListener("wheel", function (e) {
if (!active || active.signal !== "live" || onControls(e)) return;
e.preventDefault();
var a = anchorFrom(e);
setZoom(active.zoom * Math.pow(1.0018, -e.deltaY), a.x, a.y);
}, { passive: false });
var pointers = {}, pinchStart = null, dragFrom = null;
el.viewport.addEventListener("pointerdown", function (e) {
if (!active || active.signal !== "live" || onControls(e)) return;
el.viewport.setPointerCapture(e.pointerId);
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
var ids = Object.keys(pointers);
if (ids.length === 2) {
var a = pointers[ids[0]], b = pointers[ids[1]];
pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom: active.zoom };
dragFrom = null;
} else if (active.zoom > 1) {
dragFrom = { x: e.clientX, y: e.clientY };
el.viewport.dataset.dragging = "yes";
}
});
el.viewport.addEventListener("pointermove", function (e) {
if (!pointers[e.pointerId]) return;
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
var ids = Object.keys(pointers);
if (ids.length === 2 && pinchStart) {
var a = pointers[ids[0]], b = pointers[ids[1]];
var dist = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchStart.dist > 0) {
var an = anchorFrom({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 });
setZoom(pinchStart.zoom * (dist / pinchStart.dist), an.x, an.y);
}
} else if (dragFrom) {
panBy(e.clientX - dragFrom.x, e.clientY - dragFrom.y);
dragFrom = { x: e.clientX, y: e.clientY };
}
});
function endPointer(e) {
delete pointers[e.pointerId];
if (Object.keys(pointers).length < 2) pinchStart = null;
if (!Object.keys(pointers).length) {
dragFrom = null;
delete el.viewport.dataset.dragging;
}
}
el.viewport.addEventListener("pointerup", endPointer);
el.viewport.addEventListener("pointercancel", endPointer);
el.viewport.addEventListener("dblclick", function (e) {
if (!active || active.signal !== "live" || onControls(e)) return;
var a = anchorFrom(e);
if (active.zoom > 1.0001) resetZoom(); else setZoom(2, a.x, a.y);
});
function paintPreviewRate() {
previewMs = PREVIEW_RATES[previewRate].ms;
el.previewRate.textContent = "Previews " + PREVIEW_RATES[previewRate].label;
el.previewRate.title = previewMs
? "Cameras you are not watching refresh every " + PREVIEW_RATES[previewRate].label +
". Refreshing an RTSP camera costs CPU, so slower is cheaper."
: "Previews for cameras you are not watching are frozen at their last picture.";
}
el.previewRate.addEventListener("click", function () {
previewRate = (previewRate + 1) % PREVIEW_RATES.length;
try { window.localStorage.setItem("cameraStation.previewRate", String(previewRate)); }
catch (e) { /* nothing to do */ }
paintPreviewRate();
});
el.zoomIn.addEventListener("click", function () { setZoom(active.zoom * 1.5); });
el.zoomOut.addEventListener("click", function () { setZoom(active.zoom / 1.5); });
el.zoomFit.addEventListener("click", resetZoom);
function measure() {
var r = el.viewport.getBoundingClientRect();
cssW = r.width;
cssH = r.height;
render();
}
if (window.ResizeObserver) new ResizeObserver(measure).observe(el.viewport);
window.addEventListener("resize", measure);
// ------------------------------------------------------------------
// small helpers
// ------------------------------------------------------------------
// Both timers count things that now run for weeks: a camera left streaming,
// and a recording that continues with no browser open. Minutes and seconds
// alone would quietly wrap back to zero every hour, so days are carried and
// the hour field never rolls over.
function duration(seconds) {
var s = Math.max(0, Math.floor(seconds));
var days = Math.floor(s / 86400);
var hh = Math.floor((s % 86400) / 3600);
var mm = Math.floor((s % 3600) / 60);
var ss = s % 60;
var clock = pad(hh) + ":" + pad(mm) + ":" + pad(ss);
return days ? days + "d " + clock : clock;
}
function fileStamp() {
var d = new Date();
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + "-" +
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
}
function slug(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "camera";
}
function bytes(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
return (n / 1073741824).toFixed(2) + " GB";
}
function download(blob, name) {
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
}
// ---------- clock ----------
var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function timeText(d) {
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
}
function tickClock() {
var d = new Date();
el.clockTime.textContent = timeText(d);
el.clockDate.textContent = DAYS[d.getDay()] + " " + d.getDate() + " " + MONTHS[d.getMonth()];
}
// Drawn onto each output separately rather than onto the master frame, so it
// is always sized to the picture it lands on and never gets magnified.
function drawStamp(c, w, h) {
var d = new Date();
var text = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
" " + timeText(d);
var size = Math.max(11, Math.round(w / 46));
var padX = Math.round(size * 0.6);
c.font = "600 " + size + "px " + getComputedStyle(document.body).getPropertyValue("--mono");
c.textBaseline = "alphabetic";
var tw = c.measureText(text).width;
var boxH = Math.round(size * 1.9);
var x = w - tw - padX * 2 - size * 0.6;
var y = h - boxH - size * 0.6;
c.fillStyle = "rgba(0,0,0,0.55)";
c.fillRect(x, y, tw + padX * 2, boxH);
c.fillStyle = "#ffb347";
c.fillText(text, x + padX, y + boxH - Math.round(size * 0.62));
}
// ---------- fps meter ----------
function drawMeter() {
var css = el.meter.getBoundingClientRect();
if (!css.width) return;
var dpr = window.devicePixelRatio || 1;
var w = Math.round(css.width * dpr), h = Math.round(css.height * dpr);
if (el.meter.width !== w || el.meter.height !== h) {
el.meter.width = w;
el.meter.height = h;
}
var m = el.meter.getContext("2d");
m.clearRect(0, 0, w, h);
var data = active ? active.history : [];
if (!data.length) {
el.scale.textContent = "last 60s";
return;
}
var peak = Math.max.apply(null, data);
var top = peak <= 5 ? 5 : peak <= 10 ? 10 : peak <= 15 ? 15 :
peak <= 30 ? 30 : peak <= 60 ? 60 : Math.ceil(peak / 30) * 30;
el.scale.textContent = "0" + top + " fps · last 60s";
m.strokeStyle = "#2a323d";
m.lineWidth = 1 * dpr;
m.beginPath();
m.moveTo(0, h - 0.5 * dpr); m.lineTo(w, h - 0.5 * dpr);
m.moveTo(0, Math.round(h / 2) + 0.5); m.lineTo(w, Math.round(h / 2) + 0.5);
m.stroke();
var slots = 60, slot = w / slots;
var barW = Math.max(1 * dpr, slot - 1.5 * dpr);
var median = data.slice().sort(function (a, b) { return a - b; })[Math.floor(data.length / 2)];
for (var i = 0; i < data.length; i++) {
var v = data[i];
// A second with no frames must still draw something, or the red
// "nothing arrived" marker — the most important thing this meter can
// report — would be a zero-height rectangle and never visible at all.
var bh = v > 0 ? Math.max(1.5 * dpr, (v / top) * (h - 2 * dpr)) : 3 * dpr;
var x = (slots - data.length + i) * slot;
m.fillStyle = v === 0 ? "#ff3b30"
: (median >= 4 && v < median * 0.6) ? "#8a6a2f"
: "#ffb347";
m.globalAlpha = i === data.length - 1 ? 1 : 0.75;
m.fillRect(x, h - bh - 1 * dpr, barW, bh);
}
m.globalAlpha = 1;
}
// ---------- photos ----------
function takePhoto() {
if (!active || active.signal !== "live") return;
var cam = active;
var c = cam.crop();
// Written at the crop's own pixel count, straight from the master frame,
// so zooming yields a smaller file with every original pixel intact
// rather than a large one padded out with invented detail.
var out = document.createElement("canvas");
out.width = c.w; out.height = c.h;
var octx = out.getContext("2d");
octx.drawImage(cam.src, c.x, c.y, c.w, c.h, 0, 0, c.w, c.h);
if (stampOn) drawStamp(octx, c.w, c.h);
var type = format === "jpg" ? "image/jpeg" : "image/png";
// The picture is sent to the server, which names and stores it. Nothing is
// downloaded and nothing asks where to put it; the roll below links to the
// saved copy if you want it on this machine too.
out.toBlob(function (blob) {
if (!blob) return;
fetch("/api/photo?cam=" + encodeURIComponent(cam.id), {
method: "POST",
headers: { "Content-Type": type },
body: blob
}).then(function (r) {
return r.text().then(function (body) {
if (!r.ok) throw new Error(body || "The server would not store it.");
return JSON.parse(body);
});
}).then(function (res) {
addToRoll(blob, res.name, res.url, c, cam);
note("Saved " + res.name + " on the server.");
}).catch(function (err) {
note("Photo not saved: " + (err.message || err));
});
}, type, 0.95);
el.shutter.classList.remove("fire");
void el.shutter.offsetWidth;
el.shutter.classList.add("fire");
}
function addToRoll(blob, name, href, c, cam) {
var empty = el.roll.querySelector(".empty");
if (empty) empty.remove();
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.className = "shot";
a.href = href; // the copy stored on the server
a.download = name;
a.title = "Download " + name + " from the server";
var img = document.createElement("img");
img.src = url;
img.alt = name;
var who = document.createElement("em");
who.textContent = cam.name;
var label = document.createElement("span");
label.textContent = name;
a.appendChild(img); a.appendChild(who); a.appendChild(label);
el.roll.insertBefore(a, el.roll.firstChild);
el.shots.textContent = ++shots;
}
function setFormat(fmt) {
format = fmt;
el.fmtPng.setAttribute("aria-pressed", String(fmt === "png"));
el.fmtJpg.setAttribute("aria-pressed", String(fmt === "jpg"));
}
// ---------- recording ----------
// Recording is done by the server, continuously, so it keeps running when
// this page is closed. The button here only asks the server to start or stop.
function setActive(cam, wanted) {
if (!cam || cam.busy) return;
cam.busy = true;
paint();
fetch("/api/active", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: cam.id, active: wanted })
}).then(function (r) {
return r.text().then(function (body) {
if (!r.ok) throw new Error(body || "The server refused that.");
return JSON.parse(body);
});
}).then(function (state) {
applyState(cam, state);
note(wanted
? cam.name + " is recording to the server. Old footage is removed once the limit is reached."
: cam.name + " has stopped recording. What is already saved is kept.");
}).catch(function (err) {
note(String(err.message || err));
}).finally(function () {
cam.busy = false;
paint();
cam.paintTab();
});
}
function applyState(cam, state) {
cam.activeFlag = state.active === true;
cam.recording = state.recording === true;
cam.since = state.since || null;
cam.recordError = state.recordError || null;
cam.paintTab();
if (cam === active) paint();
}
var DEFAULT_FOOTER = "<kbd>R</kbd> record &nbsp;·&nbsp; <kbd>Space</kbd> photo &nbsp;·&nbsp; " +
"<kbd>1</kbd> <kbd>2</kbd> switch camera &nbsp;·&nbsp; " +
"<kbd>+</kbd> <kbd>&minus;</kbd> zoom, <kbd>0</kbd> fit, arrows pan &nbsp;·&nbsp; " +
"Recording runs on the server and continues when this page is closed. " +
"Photos are saved on the server automatically.";
var noteTimer;
function note(text) {
var footer = document.getElementById("footer");
footer.textContent = text;
clearTimeout(noteTimer);
noteTimer = setTimeout(function () { footer.innerHTML = DEFAULT_FOOTER; }, 8000);
}
// ---------- wiring ----------
el.record.addEventListener("click", function () {
if (active) setActive(active, !active.activeFlag);
});
el.capture.addEventListener("click", takePhoto);
el.fmtPng.addEventListener("click", function () { setFormat("png"); });
el.fmtJpg.addEventListener("click", function () { setFormat("jpg"); });
el.stamp.addEventListener("click", function () {
stampOn = !stampOn;
el.stamp.setAttribute("aria-pressed", String(stampOn));
note(stampOn
? "Photos and recordings now carry the time in the corner of the picture."
: "Time stamp off. The picture is saved exactly as the camera sends it.");
});
window.addEventListener("resize", drawMeter);
tickClock();
setInterval(tickClock, 1000);
document.addEventListener("keydown", function (e) {
if (e.target.tagName === "INPUT" || e.metaKey || e.ctrlKey || e.altKey) return;
var step = 40;
if (e.code === "Space") { e.preventDefault(); takePhoto(); }
else if (e.key === "r" || e.key === "R") {
e.preventDefault();
if (active) setActive(active, !active.activeFlag);
}
else if (e.key >= "1" && e.key <= "9") {
var idx = parseInt(e.key, 10) - 1;
if (cameras[idx]) { e.preventDefault(); select(cameras[idx]); }
}
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(active.zoom * 1.5); }
else if (e.key === "-" || e.key === "_") { e.preventDefault(); setZoom(active.zoom / 1.5); }
else if (e.key === "0") { e.preventDefault(); resetZoom(); }
else if (e.key === "ArrowLeft") { e.preventDefault(); panBy(step, 0); }
else if (e.key === "ArrowRight") { e.preventDefault(); panBy(-step, 0); }
else if (e.key === "ArrowUp") { e.preventDefault(); panBy(0, step); }
else if (e.key === "ArrowDown") { e.preventDefault(); panBy(0, -step); }
});
// ---------- start ----------
function signature(defs) {
return defs.map(function (d) {
return [d.id, d.name, d.kind, d.host, d.available !== false].join("~");
}).join("|");
}
function retire(cam) {
cam.disconnect();
if (cam.img && cam.img.parentNode) cam.img.parentNode.removeChild(cam.img);
if (cam.tab && cam.tab.parentNode) cam.tab.parentNode.removeChild(cam.tab);
}
// Rebuild the camera list in place. Cameras that did not change are carried
// over untouched, so editing one camera does not drop the picture, the zoom
// or the frame history of the others.
function build(defs) {
var existing = {};
cameras.forEach(function (c) { existing[c.id] = c; });
var next = defs.map(function (d, i) {
var cam = existing[d.id];
if (cam && cam.kind === d.kind && cam.host === d.host) {
delete existing[d.id];
cam.name = d.name;
cam.available = d.available !== false;
cam.reason = d.reason || null;
applyState(cam, d);
} else {
if (cam) { retire(cam); delete existing[d.id]; }
cam = new Camera(d, i);
}
cam.index = i;
return cam;
});
Object.keys(existing).forEach(function (k) { retire(existing[k]); });
cameras = next;
cameras.forEach(function (c, i) {
c.tab.querySelector(".name").textContent = c.name;
c.tab.querySelector(".key").textContent = String(i + 1);
c.tab.disabled = !c.available;
el.tabs.appendChild(c.tab); // re-appending puts them back in order
});
if (cameras.indexOf(active) === -1) {
active = cameras.filter(function (c) { return c.available; })[0] || cameras[0] || null;
}
document.body.dataset.multi = cameras.length > 1 ? "yes" : "no";
cameras.forEach(function (c) { c.paintTab(); });
updateConnections();
measure();
paint();
}
function start(defs) {
if (!defs.length) {
el.phTitle.textContent = "No cameras configured";
el.phDetail.textContent = "Add one on the set-up page.";
paintPreviewRate();
return;
}
lastSignature = signature(defs);
build(defs);
paintPreviewRate();
requestAnimationFrame(tick);
watchForChanges();
}
// The set-up page can change the cameras while this page is open, so check
// for that rather than making people reload. A recording is never interrupted
// for it: the update waits until recording stops.
var lastSignature = "";
function watchForChanges() {
setInterval(function () {
// Recording is unaffected by rebuilding the list, so nothing to defer.
fetch("/cameras")
.then(function (r) { return r.json(); })
.then(function (defs) {
var sig = signature(defs);
if (sig !== lastSignature) {
lastSignature = sig;
build(defs);
note("Camera list updated from the set-up page.");
return;
}
// Recording can be started from the set-up page or another browser,
// so keep the button and the tally in step with the server.
defs.forEach(function (d) {
var cam = cameras.filter(function (c) { return c.id === d.id; })[0];
if (cam && !cam.busy) applyState(cam, d);
});
})
.catch(function () { /* server restarting, most likely */ });
}, 3000);
}
fetch("/cameras")
.then(function (r) { return r.json(); })
.then(start)
.catch(function () {
// An older server, or one that could not answer: fall back to a single feed.
start([{ id: "cam1", name: "Camera 1", kind: "mjpeg", host: "camera", available: true }]);
});
})();
</script>
</body>
</html>