1377 lines
52 KiB
HTML
1377 lines
52 KiB
HTML
<!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"> </span>
|
||
</div>
|
||
<div class="readout">
|
||
<div class="clock">
|
||
<span class="time" id="clockTime">--:--:--</span>
|
||
<span class="date" id="clockDate"> </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>−</button>
|
||
<span class="level">
|
||
<b id="zoomLevel">1.0×</b>
|
||
<i id="zoomDetail"> </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 · <kbd>Space</kbd> photo ·
|
||
<kbd>1</kbd> <kbd>2</kbd> switch camera ·
|
||
<kbd>+</kbd> <kbd>−</kbd> zoom, <kbd>0</kbd> fit, arrows pan ·
|
||
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 · <kbd>Space</kbd> photo · " +
|
||
"<kbd>1</kbd> <kbd>2</kbd> switch camera · " +
|
||
"<kbd>+</kbd> <kbd>−</kbd> zoom, <kbd>0</kbd> fit, arrows pan · " +
|
||
"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>
|