Files
Self-Hosted-Stream-Webcam/index.html
T

1377 lines
52 KiB
HTML
Raw Normal View History

2026-09-13 20:24:20 +01:00
<!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>