524 lines
20 KiB
HTML
524 lines
20 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Camera Station · Set up</title>
|
|
<style>
|
|
:root{
|
|
--chassis:#0e1116;
|
|
--panel:#161b22;
|
|
--panel-hi:#1e252e;
|
|
--edge:#2a323d;
|
|
--ink:#e6e4df;
|
|
--ink-dim:#7d8794;
|
|
--phosphor:#ffb347;
|
|
--tally:#ff3b30;
|
|
--good:#3ddc84;
|
|
--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}
|
|
body{
|
|
margin:0;padding:28px 22px 60px;background:var(--chassis);color:var(--ink);
|
|
font-family:var(--mono);font-size:13px;line-height:1.5;
|
|
display:flex;flex-direction:column;gap:20px;align-items:center;
|
|
}
|
|
.wrap{width:100%;max-width:900px;display:flex;flex-direction:column;gap:18px}
|
|
|
|
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
|
h1{font-size:13px;letter-spacing:.34em;text-transform:uppercase;margin:0;font-weight:600}
|
|
.sub{color:var(--ink-dim);font-size:11px;letter-spacing:.1em}
|
|
a.back{
|
|
color:var(--phosphor);text-decoration:none;font-size:11px;
|
|
letter-spacing:.18em;text-transform:uppercase;border-bottom:1px solid #4a3a1c;padding-bottom:2px;
|
|
}
|
|
a.back:hover{border-bottom-color:var(--phosphor)}
|
|
|
|
.note{
|
|
background:var(--panel);border:1px solid var(--edge);border-left:2px solid var(--phosphor);
|
|
padding:12px 15px;color:var(--ink-dim);font-size:11.5px;line-height:1.65;border-radius:2px;
|
|
}
|
|
.note b{color:var(--ink);font-weight:600}
|
|
.note.warn{border-left-color:var(--tally)}
|
|
|
|
.rows{display:flex;flex-direction:column;gap:10px}
|
|
.row{
|
|
background:var(--panel);border:1px solid var(--edge);border-radius:2px;
|
|
padding:13px 14px;display:grid;gap:10px;
|
|
grid-template-columns:minmax(130px,190px) 1fr auto;align-items:start;
|
|
}
|
|
.row[data-kind="rtsp"]{border-left:2px solid #3a4a63}
|
|
.row[data-kind="mjpeg"]{border-left:2px solid #4a3a1c}
|
|
|
|
label{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
label span{font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim)}
|
|
input{
|
|
font-family:var(--mono);font-size:12.5px;color:var(--ink);
|
|
background:var(--chassis);border:1px solid var(--edge);border-radius:2px;
|
|
padding:9px 10px;width:100%;min-width:0;
|
|
}
|
|
input:focus{outline:none;border-color:#4a5768;background:#0b0e12}
|
|
input::placeholder{color:#4d5561}
|
|
|
|
.rowbtns{display:flex;gap:5px;align-items:center;padding-top:19px}
|
|
button{
|
|
font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
|
color:var(--ink);background:var(--panel-hi);border:1px solid var(--edge);
|
|
border-radius:2px;padding:9px 12px;cursor:pointer;white-space:nowrap;
|
|
}
|
|
button:hover:not(:disabled){background:#28313d}
|
|
button:disabled{opacity:.35;cursor:default}
|
|
button.icon{padding:9px 10px;min-width:34px;text-align:center}
|
|
button.danger:hover:not(:disabled){border-color:var(--tally);color:var(--tally)}
|
|
button.primary{background:var(--phosphor);color:#171310;border-color:var(--phosphor);font-weight:700}
|
|
button.primary:hover:not(:disabled){background:#ffc266}
|
|
|
|
button.toggle{min-width:118px;justify-content:center}
|
|
button.toggle[data-on="yes"]{
|
|
background:#3a1512;border-color:var(--tally);color:#ff9d95;font-weight:700;
|
|
}
|
|
button.toggle[data-on="yes"]::before{
|
|
content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
|
|
background:var(--tally);margin-right:8px;vertical-align:middle;
|
|
animation:pulse 1.6s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
|
|
.storage{
|
|
background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:15px 16px;
|
|
display:flex;flex-direction:column;gap:12px;
|
|
}
|
|
.storage h2{margin:0;font-size:11px;letter-spacing:.24em;text-transform:uppercase;font-weight:600}
|
|
.bar{height:8px;background:var(--chassis);border:1px solid var(--edge);border-radius:2px;overflow:hidden}
|
|
.bar i{display:block;height:100%;background:var(--phosphor);transition:width .4s ease}
|
|
.bar[data-full="yes"] i{background:var(--tally)}
|
|
.usage{font-size:11px;color:var(--ink-dim);letter-spacing:.06em}
|
|
.usage b{color:var(--ink);font-weight:600}
|
|
.files{display:flex;flex-direction:column;gap:4px;max-height:230px;overflow:auto}
|
|
.files a{
|
|
display:flex;justify-content:space-between;gap:12px;text-decoration:none;
|
|
color:var(--ink-dim);font-size:11px;padding:6px 8px;border:1px solid transparent;border-radius:2px;
|
|
}
|
|
.files a:hover{color:var(--ink);border-color:var(--edge);background:var(--chassis)}
|
|
.files a span:last-child{color:var(--ink-dim);white-space:nowrap}
|
|
|
|
.result{
|
|
grid-column:1 / -1;font-size:11px;letter-spacing:.05em;padding:8px 10px;border-radius:2px;
|
|
background:var(--chassis);border:1px solid var(--edge);color:var(--ink-dim);
|
|
word-break:break-word;
|
|
}
|
|
.result[data-state="ok"]{color:var(--good);border-color:#1d4530}
|
|
.result[data-state="bad"]{color:#ff8a80;border-color:#4d2320}
|
|
.result[data-state="busy"]{color:var(--phosphor)}
|
|
.result[hidden]{display:none}
|
|
|
|
.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
.actions .spacer{flex:1}
|
|
.status{font-size:11px;letter-spacing:.08em;color:var(--ink-dim)}
|
|
.status[data-state="ok"]{color:var(--good)}
|
|
.status[data-state="bad"]{color:#ff8a80}
|
|
|
|
.empty{
|
|
border:1px dashed var(--edge);border-radius:2px;padding:26px;text-align:center;
|
|
color:var(--ink-dim);font-size:11.5px;
|
|
}
|
|
@media (max-width:720px){
|
|
.row{grid-template-columns:1fr}
|
|
.rowbtns{padding-top:0;flex-wrap:wrap}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<header>
|
|
<div>
|
|
<h1>Camera Station · Set up</h1>
|
|
<div class="sub" id="sub">Loading…</div>
|
|
</div>
|
|
<nav style="display:flex;gap:10px">
|
|
<a class="back" href="/files">Files</a>
|
|
<a class="back" href="/">← Viewer</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<div class="note" id="hint">
|
|
Add as many cameras as you like. An address starting <b>http://</b> is treated as an
|
|
MJPEG feed and relayed untouched; one starting <b>rtsp://</b> is decoded by ffmpeg on
|
|
this machine. <b>Test</b> pulls a single frame so you can check a camera before saving.
|
|
</div>
|
|
|
|
<div class="note" id="ffmpegWarn" hidden>
|
|
ffmpeg was not found, so RTSP cameras cannot be decoded. MJPEG cameras still work.
|
|
</div>
|
|
|
|
<div class="note warn" id="lockWarn" hidden>
|
|
This server was started with <b>--lock-config</b>, so cameras can be viewed here but not changed.
|
|
</div>
|
|
|
|
<div class="rows" id="rows"></div>
|
|
<div class="empty" id="empty" hidden>No cameras yet. Add one below.</div>
|
|
|
|
<div class="actions">
|
|
<button id="add">+ Add camera</button>
|
|
<button id="testAll">Test all</button>
|
|
<span class="spacer"></span>
|
|
<span class="status" id="status"></span>
|
|
<button id="revert">Revert</button>
|
|
<button class="primary" id="save">Save changes</button>
|
|
</div>
|
|
|
|
<div class="storage">
|
|
<h2>Stored footage <a href="/files" style="color:var(--phosphor);text-decoration:none;font-size:10px;letter-spacing:.18em">browse all →</a></h2>
|
|
<div class="bar" id="bar"><i style="width:0"></i></div>
|
|
<div class="usage" id="usage">Reading…</div>
|
|
<div class="files" id="files"></div>
|
|
</div>
|
|
|
|
<div class="note">
|
|
Recording a camera writes its stream straight to disk without re-encoding, so the
|
|
files are exactly what the camera sent and cost very little CPU. Footage is cut into
|
|
timestamped pieces; once the size limit is reached the oldest pieces are deleted so
|
|
the total never grows past it.
|
|
</div>
|
|
|
|
<div class="note">
|
|
Saved passwords are shown as bullets and are never sent to this page. Leave the bullets
|
|
alone to keep the existing password, or type over them to set a new one.
|
|
Cameras are stored in <b>cameras.json</b> next to server.py.
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var rowsEl = document.getElementById("rows");
|
|
var emptyEl = document.getElementById("empty");
|
|
var statusEl = document.getElementById("status");
|
|
var subEl = document.getElementById("sub");
|
|
var saveBtn = document.getElementById("save");
|
|
var revertBtn = document.getElementById("revert");
|
|
var addBtn = document.getElementById("add");
|
|
var testAllBtn = document.getElementById("testAll");
|
|
|
|
var cameras = []; // what is on screen
|
|
var saved = []; // what the server last confirmed
|
|
var locked = false;
|
|
var ffmpegOk = true;
|
|
|
|
function kindOf(url) {
|
|
return /^rtsps?:\/\//i.test(url || "") ? "rtsp" : "mjpeg";
|
|
}
|
|
|
|
function setStatus(text, state) {
|
|
statusEl.textContent = text || "";
|
|
if (state) statusEl.dataset.state = state; else delete statusEl.dataset.state;
|
|
}
|
|
|
|
function dirty() {
|
|
return JSON.stringify(cameras.map(function (c) { return [c.name, c.url]; })) !==
|
|
JSON.stringify(saved.map(function (c) { return [c.name, c.url]; }));
|
|
}
|
|
|
|
function refreshButtons() {
|
|
var changed = dirty();
|
|
saveBtn.disabled = locked || !changed;
|
|
revertBtn.disabled = !changed;
|
|
addBtn.disabled = locked;
|
|
emptyEl.hidden = cameras.length > 0;
|
|
}
|
|
|
|
function render() {
|
|
rowsEl.textContent = "";
|
|
cameras.forEach(function (cam, index) {
|
|
var row = document.createElement("div");
|
|
row.className = "row";
|
|
row.dataset.kind = kindOf(cam.url);
|
|
|
|
var nameLabel = document.createElement("label");
|
|
var nameSpan = document.createElement("span");
|
|
nameSpan.textContent = "Name";
|
|
var nameInput = document.createElement("input");
|
|
nameInput.value = cam.name;
|
|
nameInput.placeholder = "Camera " + (index + 1);
|
|
nameInput.disabled = locked;
|
|
nameInput.addEventListener("input", function () {
|
|
cam.name = nameInput.value;
|
|
refreshButtons();
|
|
});
|
|
nameLabel.appendChild(nameSpan);
|
|
nameLabel.appendChild(nameInput);
|
|
|
|
var urlLabel = document.createElement("label");
|
|
var urlSpan = document.createElement("span");
|
|
urlSpan.textContent = "Address";
|
|
var urlInput = document.createElement("input");
|
|
urlInput.value = cam.url;
|
|
urlInput.spellcheck = false;
|
|
urlInput.placeholder = "http://192.168.1.50:8080 or rtsp://user:pass@192.168.1.60:554/stream";
|
|
urlInput.disabled = locked;
|
|
urlInput.addEventListener("input", function () {
|
|
cam.url = urlInput.value;
|
|
row.dataset.kind = kindOf(cam.url);
|
|
refreshButtons();
|
|
});
|
|
urlLabel.appendChild(urlSpan);
|
|
urlLabel.appendChild(urlInput);
|
|
|
|
var btns = document.createElement("div");
|
|
btns.className = "rowbtns";
|
|
|
|
var rec = document.createElement("button");
|
|
rec.className = "toggle";
|
|
rec.dataset.on = cam.active ? "yes" : "no";
|
|
rec.textContent = cam.active ? "Recording" : "Not recording";
|
|
rec.title = cam.active
|
|
? "This camera is being recorded to disk continuously. Click to stop."
|
|
: "Click to record this camera continuously, even when nobody is watching.";
|
|
rec.disabled = locked || !cam.id;
|
|
rec.addEventListener("click", function () { toggleActive(cam, rec); });
|
|
|
|
var testBtn = document.createElement("button");
|
|
testBtn.textContent = "Test";
|
|
testBtn.addEventListener("click", function () { testOne(cam, row, testBtn); });
|
|
|
|
var up = document.createElement("button");
|
|
up.className = "icon"; up.textContent = "\u2191";
|
|
up.title = "Move up";
|
|
up.disabled = locked || index === 0;
|
|
up.addEventListener("click", function () { move(index, -1); });
|
|
|
|
var down = document.createElement("button");
|
|
down.className = "icon"; down.textContent = "\u2193";
|
|
down.title = "Move down";
|
|
down.disabled = locked || index === cameras.length - 1;
|
|
down.addEventListener("click", function () { move(index, 1); });
|
|
|
|
var del = document.createElement("button");
|
|
del.className = "icon danger"; del.textContent = "\u00d7";
|
|
del.title = "Remove this camera";
|
|
del.disabled = locked;
|
|
del.addEventListener("click", function () {
|
|
cameras.splice(index, 1);
|
|
render();
|
|
setStatus("Removed. Save to apply.", null);
|
|
});
|
|
|
|
btns.appendChild(rec); btns.appendChild(testBtn); btns.appendChild(up);
|
|
btns.appendChild(down); btns.appendChild(del);
|
|
|
|
var result = document.createElement("div");
|
|
result.className = "result";
|
|
result.hidden = true;
|
|
|
|
row.appendChild(nameLabel);
|
|
row.appendChild(urlLabel);
|
|
row.appendChild(btns);
|
|
row.appendChild(result);
|
|
rowsEl.appendChild(row);
|
|
});
|
|
refreshButtons();
|
|
}
|
|
|
|
function toggleActive(cam, button) {
|
|
if (!cam.id) {
|
|
setStatus("Save this camera first, then it can be recorded.", "bad");
|
|
return;
|
|
}
|
|
button.disabled = true;
|
|
fetch("/api/active", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: cam.id, active: !cam.active })
|
|
}).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) {
|
|
cam.active = state.active;
|
|
var match = saved.filter(function (c) { return c.id === cam.id; })[0];
|
|
if (match) match.active = state.active; // this is saved immediately, so it is not a pending edit
|
|
button.dataset.on = cam.active ? "yes" : "no";
|
|
button.textContent = cam.active ? "Recording" : "Not recording";
|
|
setStatus(cam.active
|
|
? cam.name + " is now recorded continuously."
|
|
: cam.name + " is no longer being recorded.", "ok");
|
|
loadStorage();
|
|
}).catch(function (err) {
|
|
setStatus(String(err.message || err), "bad");
|
|
}).finally(function () {
|
|
button.disabled = locked;
|
|
refreshButtons();
|
|
});
|
|
}
|
|
|
|
function move(index, delta) {
|
|
var target = index + delta;
|
|
if (target < 0 || target >= cameras.length) return;
|
|
var moved = cameras.splice(index, 1)[0];
|
|
cameras.splice(target, 0, moved);
|
|
render();
|
|
}
|
|
|
|
function showResult(row, text, state) {
|
|
var box = row.querySelector(".result");
|
|
box.hidden = false;
|
|
box.textContent = text;
|
|
if (state) box.dataset.state = state; else delete box.dataset.state;
|
|
}
|
|
|
|
function testOne(cam, row, button) {
|
|
showResult(row, "Pulling a frame\u2026", "busy");
|
|
if (button) button.disabled = true;
|
|
return fetch("/api/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url: cam.url })
|
|
}).then(function (r) { return r.json(); })
|
|
.then(function (res) {
|
|
if (res.ok) {
|
|
var size = res.width ? res.width + "\u00d7" + res.height : "picture received";
|
|
showResult(row, "Working \u00b7 " + (res.kind || "") + " \u00b7 " + size, "ok");
|
|
} else {
|
|
showResult(row, res.message || "No picture came back.", "bad");
|
|
}
|
|
})
|
|
.catch(function () { showResult(row, "Could not reach this server.", "bad"); })
|
|
.finally(function () { if (button) button.disabled = false; });
|
|
}
|
|
|
|
function testAll() {
|
|
var rows = rowsEl.querySelectorAll(".row");
|
|
testAllBtn.disabled = true;
|
|
// One at a time: several RTSP probes at once would fight for the CPU and
|
|
// make each other look slow.
|
|
var chain = Promise.resolve();
|
|
cameras.forEach(function (cam, i) {
|
|
chain = chain.then(function () { return testOne(cam, rows[i], null); });
|
|
});
|
|
chain.finally(function () { testAllBtn.disabled = false; });
|
|
}
|
|
|
|
function save() {
|
|
setStatus("Saving\u2026", null);
|
|
saveBtn.disabled = true;
|
|
fetch("/api/config", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ cameras: cameras })
|
|
}).then(function (r) {
|
|
return r.text().then(function (body) {
|
|
if (!r.ok) throw new Error(body || ("Save failed (" + r.status + ")"));
|
|
return JSON.parse(body);
|
|
});
|
|
}).then(function (res) {
|
|
cameras = res.cameras.map(function (c) { return Object.assign({}, c); });
|
|
saved = res.cameras.map(function (c) { return Object.assign({}, c); });
|
|
render();
|
|
setStatus("Saved. The viewer updates within a few seconds.", "ok");
|
|
}).catch(function (err) {
|
|
setStatus(String(err.message || err), "bad");
|
|
refreshButtons();
|
|
});
|
|
}
|
|
|
|
addBtn.addEventListener("click", function () {
|
|
cameras.push({ id: "", name: "", url: "" });
|
|
render();
|
|
var inputs = rowsEl.querySelectorAll(".row:last-child input");
|
|
if (inputs.length) inputs[0].focus();
|
|
});
|
|
|
|
revertBtn.addEventListener("click", function () {
|
|
cameras = saved.map(function (c) { return Object.assign({}, c); });
|
|
render();
|
|
setStatus("Reverted to the saved cameras.", null);
|
|
});
|
|
|
|
saveBtn.addEventListener("click", save);
|
|
testAllBtn.addEventListener("click", testAll);
|
|
|
|
window.addEventListener("beforeunload", function (e) {
|
|
if (dirty()) { e.preventDefault(); e.returnValue = ""; }
|
|
});
|
|
|
|
function human(n) {
|
|
if (!n) return "0 B";
|
|
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 when(ts) {
|
|
if (!ts) return "";
|
|
var d = new Date(ts * 1000);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function loadStorage() {
|
|
fetch("/api/storage")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (s) {
|
|
var pct = s.limit ? Math.min(100, (s.used / s.limit) * 100) : 0;
|
|
var bar = document.getElementById("bar");
|
|
bar.firstElementChild.style.width = pct.toFixed(1) + "%";
|
|
bar.dataset.full = pct > 92 ? "yes" : "no";
|
|
var parts = [human(s.used) + " of " + human(s.limit),
|
|
s.segments + (s.segments === 1 ? " file" : " files")];
|
|
if (s.photoCount) parts.push(s.photoCount + " photos (" + human(s.photoBytes) + ")");
|
|
document.getElementById("usage").innerHTML =
|
|
"<b>" + parts[0] + "</b> \u00b7 " + parts.slice(1).join(" \u00b7 ");
|
|
|
|
var list = document.getElementById("files");
|
|
list.textContent = "";
|
|
(s.recent || []).forEach(function (f) {
|
|
var a = document.createElement("a");
|
|
a.href = "/recordings/" + encodeURIComponent(f.folder) + "/" + encodeURIComponent(f.name);
|
|
var left = document.createElement("span");
|
|
left.textContent = f.folder + " \u00b7 " + f.name;
|
|
var right = document.createElement("span");
|
|
right.textContent = human(f.size) + " " + when(f.mtime);
|
|
a.appendChild(left); a.appendChild(right);
|
|
list.appendChild(a);
|
|
});
|
|
if (!(s.recent || []).length) {
|
|
var none = document.createElement("a");
|
|
none.textContent = "Nothing recorded yet.";
|
|
none.removeAttribute("href");
|
|
list.appendChild(none);
|
|
}
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("usage").textContent = "Could not read storage.";
|
|
});
|
|
}
|
|
|
|
loadStorage();
|
|
setInterval(loadStorage, 10000);
|
|
|
|
fetch("/api/config")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
locked = !!data.locked;
|
|
ffmpegOk = data.ffmpeg !== false;
|
|
cameras = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
|
|
saved = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
|
|
document.getElementById("lockWarn").hidden = !locked;
|
|
document.getElementById("ffmpegWarn").hidden = ffmpegOk;
|
|
subEl.textContent = cameras.length + (cameras.length === 1 ? " camera" : " cameras");
|
|
render();
|
|
})
|
|
.catch(function () {
|
|
subEl.textContent = "Could not load the current cameras.";
|
|
setStatus("Is server.py still running?", "bad");
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|