24155a8492
Add a separate `record` field so a camera can be registered but fully
off, or on but not recording:
- enabled (Вкл): camera on/off. Disabled = no go2rtc stream (no live),
no recording; entry is just stored. build_streams/sync_all/reconcile
skip disabled cameras; live grid shows "камера выключена".
- record (REC): write segments to disk. Recorder runs only when
enabled AND record.
Unified POST /api/cameras/{id}/toggle {enabled?, record?} (replaces the
/record endpoint); go2rtc is touched only when `enabled` changes, so
flipping REC doesn't blip live. Camera table gets Вкл + Запись toggle
columns; add/edit forms get both switches.
load_config migrates the old schema (no `record` key): old `enabled`
meant "record" with live always on -> enabled=True, record=old enabled.
VERSION -> 0.0.126.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1814 lines
90 KiB
JavaScript
1814 lines
90 KiB
JavaScript
"use strict";
|
||
|
||
// ── общие хелперы ───────────────────────────────────────────────
|
||
async function api(path) {
|
||
const r = await fetch(path);
|
||
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||
if (!r.ok) throw new Error(path + " -> " + r.status);
|
||
return r.json();
|
||
}
|
||
function post(path) { return fetch(path, { method: "POST" }); }
|
||
|
||
// ── клиентские настройки (этот браузер), localStorage ───────────
|
||
const PREFS_KEY = "corevision.prefs";
|
||
// mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод
|
||
const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2, rc: "vbr", qp: 23 };
|
||
const PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив
|
||
// общие пресеты перекодирования — выставляют все параметры профиля разом
|
||
const TR_PRESETS = {
|
||
SOFT: { codec: "vp9", profile: "baseline", bitrate: "500k", keyint: 2, preset: "ultrafast" },
|
||
MEDIUM: { codec: "h264", profile: "baseline", bitrate: "8000k", keyint: 2, preset: "ultrafast" },
|
||
HARD: { codec: "h264", profile: "main", bitrate: "16000k", keyint: 1, preset: "superfast" },
|
||
};
|
||
const TR_PRESET_COLORS = { SOFT: "#e03131", MEDIUM: "#e9c46a", HARD: "#ffffff" }; // цветомаркировка профилей
|
||
function matchPreset(pr) { // имя пресета, совпадающего с параметрами
|
||
return Object.keys(TR_PRESETS).find((k) => {
|
||
const p = TR_PRESETS[k];
|
||
return p.codec === pr.codec && p.bitrate === pr.bitrate && +p.keyint === +pr.keyint &&
|
||
p.preset === pr.preset && (pr.codec === "vp9" || p.profile === pr.profile);
|
||
}) || "";
|
||
}
|
||
const PREFS_DEFAULT = {
|
||
prof1: { ...PROFILE_DEFAULT, mode: "player" }, // 1 поток (основной)
|
||
prof2: { ...PROFILE_DEFAULT, mode: "player" }, // 2 поток (суб)
|
||
profA: { ...PROFILE_DEFAULT, mode: "transcode" }, // архив
|
||
};
|
||
|
||
// ── текущий пользователь / роли ─────────────────────────────────
|
||
const ROLE_RANK = { operator: 1, admin: 2, superadmin: 3 };
|
||
const ROLE_RU = { operator: "оператор", admin: "админ", superadmin: "суперадмин" };
|
||
let CURRENT_USER = null;
|
||
async function fetchMe() {
|
||
try { CURRENT_USER = await api("/api/me"); } catch (e) { CURRENT_USER = null; }
|
||
return CURRENT_USER;
|
||
}
|
||
function roleAtLeast(min) {
|
||
const r = CURRENT_USER && CURRENT_USER.role;
|
||
return !!r && (ROLE_RANK[r] || 0) >= (ROLE_RANK[min] || 99);
|
||
}
|
||
function clientPrefs() {
|
||
try { return { ...PREFS_DEFAULT, ...(JSON.parse(localStorage.getItem(PREFS_KEY)) || {}) }; }
|
||
catch (e) { return { ...PREFS_DEFAULT }; }
|
||
}
|
||
function setClientPref(key, val) {
|
||
const p = clientPrefs(); p[key] = val;
|
||
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||
}
|
||
function transcodeProfile(which) { // which: "1" | "2" | "a"
|
||
const p = clientPrefs();
|
||
return { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}) };
|
||
}
|
||
function setProfilePref(which, patch) {
|
||
const p = clientPrefs();
|
||
p[PROF_KEY[which]] = { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}), ...patch };
|
||
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||
}
|
||
function transcodeQueryFor(which) { // строка запроса под профиль
|
||
const e = encodeURIComponent, pr = transcodeProfile(which);
|
||
return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` +
|
||
`&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`;
|
||
}
|
||
function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode"
|
||
const m = transcodeProfile(which).mode;
|
||
return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player";
|
||
}
|
||
// раскладки: маппинг камер по ячейкам, на размерность (этот браузер)
|
||
function getLayout(dim) {
|
||
try { return (JSON.parse(localStorage.getItem("corevision.layout")) || {})[dim] || null; }
|
||
catch (e) { return null; }
|
||
}
|
||
function setLayout(dim, arr) {
|
||
let m; try { m = JSON.parse(localStorage.getItem("corevision.layout")) || {}; } catch (e) { m = {}; }
|
||
m[dim] = arr;
|
||
localStorage.setItem("corevision.layout", JSON.stringify(m));
|
||
}
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
function fmtTime(ts) {
|
||
const d = new Date(ts * 1000);
|
||
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
||
}
|
||
function fmtDur(s) {
|
||
if (!s) return "—";
|
||
const m = Math.floor(s / 60), sec = Math.round(s % 60);
|
||
return m + ":" + pad(sec);
|
||
}
|
||
function fmtSize(b) {
|
||
if (!b) return "—";
|
||
const mb = b / 1048576;
|
||
return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(0) + " MB";
|
||
}
|
||
function fmtUptime(s) {
|
||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||
if (d) return `${d}д ${h}ч ${m}м`;
|
||
if (h) return `${h}ч ${m}м`;
|
||
return `${m}м`;
|
||
}
|
||
const STATE_RU = { online: "запись", retrying: "переподключение", offline: "выключена", starting: "запуск" };
|
||
function dotClass(state) { return "dot " + (state || "offline"); }
|
||
|
||
// ── шапка: часы + статистика + выход ────────────────────────────
|
||
function startClock() {
|
||
const el = document.getElementById("clock");
|
||
if (!el) return;
|
||
const tick = () => {
|
||
const d = new Date();
|
||
el.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
||
};
|
||
tick(); setInterval(tick, 1000);
|
||
}
|
||
function ensureVersionEl() {
|
||
const aside = document.querySelector("aside");
|
||
if (aside && !document.getElementById("app-version")) {
|
||
const d = document.createElement("div");
|
||
d.id = "app-version"; d.className = "app-version";
|
||
aside.appendChild(d);
|
||
}
|
||
}
|
||
async function refreshStats() {
|
||
const el = document.getElementById("sys-stats");
|
||
try {
|
||
const s = await api("/api/system");
|
||
if (el) el.innerHTML =
|
||
`CPU <b>${s.cpu_percent}%</b> RAM <b>${s.ram_percent}%</b>` +
|
||
` Диск <b>${s.disk.used_gb}/${s.disk.total_gb} GB</b>`;
|
||
const v = document.getElementById("app-version");
|
||
if (v && s.version) v.textContent = "v" + s.version;
|
||
} catch (e) { /* нет связи / 401 — молчим */ }
|
||
}
|
||
// ограничение подменю раскладок (режим 16+) — на любой странице с .subnav
|
||
async function applyGridMenuLimit() {
|
||
const items = document.querySelectorAll(".subnav .subitem[data-grid]");
|
||
if (!items.length) return;
|
||
let maxDim = 4;
|
||
try { const opt = await api("/api/optimization"); maxDim = opt.mode_16plus ? 8 : 4; }
|
||
catch (e) { /* нет связи — лимит 16 */ }
|
||
items.forEach((a) => { a.hidden = +a.dataset.grid > maxDim; });
|
||
}
|
||
|
||
function bindLogout() {
|
||
const btn = document.getElementById("logout");
|
||
if (!btn) return;
|
||
btn.onclick = async () => { await post("/api/logout"); location.href = "/login"; };
|
||
}
|
||
|
||
// ── статусный WebSocket (общий) ─────────────────────────────────
|
||
function connectStatusWS(onCameras) {
|
||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||
const ws = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||
ws.onmessage = (ev) => {
|
||
const msg = JSON.parse(ev.data);
|
||
if (msg.type === "status") onCameras(msg.cameras);
|
||
else if (msg.type === "camera") onCameras([msg.camera]);
|
||
};
|
||
ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000);
|
||
}
|
||
|
||
// ── go2rtc плеер ────────────────────────────────────────────────
|
||
let _rtcLoaded = null;
|
||
function loadRtcComponent(go2rtcUrl) {
|
||
if (_rtcLoaded) return _rtcLoaded;
|
||
_rtcLoaded = new Promise((resolve, reject) => {
|
||
const s = document.createElement("script");
|
||
s.src = go2rtcUrl.replace(/\/$/, "") + "/video-rtc.js";
|
||
s.onload = resolve;
|
||
s.onerror = () => reject(new Error("go2rtc video-rtc.js не загрузился"));
|
||
document.head.appendChild(s);
|
||
});
|
||
return _rtcLoaded;
|
||
}
|
||
// ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ──
|
||
// Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен,
|
||
// сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU).
|
||
const HEVC_WORKER_URL = "/static/vendor/hevcjs/hevc-player-worker.js";
|
||
const HEVC_WASM_JS = "/static/vendor/hevcjs/hevc-decode.esm.js";
|
||
const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm";
|
||
|
||
class HevcWasmPlayer {
|
||
constructor(box, wsUrl, useMain, onFail) {
|
||
this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null;
|
||
this.closed = false; this.worker = null; this.ws = null;
|
||
const cv = document.createElement("canvas");
|
||
cv.className = "wc-canvas";
|
||
// размер буфера рендера: main крупнее (для разворота на весь экран), sub мелкий
|
||
cv.width = useMain ? 1600 : 640;
|
||
cv.height = useMain ? 900 : 360;
|
||
box.appendChild(cv);
|
||
this.canvas = cv;
|
||
let off;
|
||
try { off = cv.transferControlToOffscreen(); } catch (e) { this._fail("offscreen"); return; }
|
||
try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; }
|
||
this.worker.onmessage = (e) => {
|
||
const m = e.data;
|
||
if (m.type === "ready") this._openWS();
|
||
else if (m.type === "err") console.warn("HEVC worker:", m.msg);
|
||
};
|
||
this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); };
|
||
this.worker.postMessage(
|
||
{ type: "init", canvas: off, wasmUrl: HEVC_WASM_JS, wasmBinaryUrl: HEVC_WASM_BIN }, [off]);
|
||
}
|
||
_openWS() {
|
||
if (this.closed) return;
|
||
try {
|
||
this.ws = new WebSocket(this.wsUrl);
|
||
this.ws.binaryType = "arraybuffer";
|
||
this.ws.onmessage = (ev) => { if (!this.closed && this.worker) this.worker.postMessage({ type: "data", buf: ev.data }, [ev.data]); };
|
||
this.ws.onerror = () => { /**/ };
|
||
this.ws.onclose = () => { /**/ };
|
||
} catch (e) { /**/ }
|
||
}
|
||
_fail(reason) {
|
||
if (this.closed) return;
|
||
const f = this.onFail; this.onFail = null;
|
||
this.destroy();
|
||
if (f) f(reason);
|
||
}
|
||
resize() { /* canvas масштабируется CSS (object-fit), буфер рендера фиксирован */ }
|
||
destroy() {
|
||
this.closed = true;
|
||
try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ }
|
||
this.ws = null;
|
||
if (this.worker) {
|
||
try { this.worker.postMessage({ type: "stop" }); } catch (e) { /**/ }
|
||
try { this.worker.terminate(); } catch (e) { /**/ }
|
||
this.worker = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleFullscreen(el) {
|
||
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
||
if (fsEl) {
|
||
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
||
} else if (el.requestFullscreen || el.webkitRequestFullscreen) {
|
||
(el.requestFullscreen || el.webkitRequestFullscreen).call(el);
|
||
}
|
||
}
|
||
|
||
// умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра
|
||
let _hevc = null;
|
||
function hevcSupported() {
|
||
if (_hevc !== null) return _hevc;
|
||
let ok = false;
|
||
try {
|
||
const v = document.createElement("video");
|
||
const t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"');
|
||
if (t === "probably" || t === "maybe") ok = true;
|
||
if (!ok && window.MediaSource && MediaSource.isTypeSupported) {
|
||
ok = MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') ||
|
||
MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
_hevc = ok;
|
||
return ok;
|
||
}
|
||
|
||
// нативный плеер go2rtc (open-source video-rtc.js): MSE/WebRTC, поддерживает H.265
|
||
function makePlayer(go2rtcUrl, srcName, transcode) {
|
||
const el = document.createElement("video-stream");
|
||
// mse раньше webrtc — в Chrome HEVC доступен только в MSE; webrtc — фолбэк (Safari)
|
||
el.setAttribute("mode", transcode ? "webrtc,mse" : "mse,webrtc");
|
||
el.setAttribute("background", "false");
|
||
const base = go2rtcUrl.replace(/\/$/, "");
|
||
el.src = new URL(base + "/api/ws?src=" + encodeURIComponent(srcName));
|
||
return el;
|
||
}
|
||
|
||
// ── свой WebCodecs-плеер H.265 (аппаратный декод на GPU клиента, 0% CPU сервера) ──
|
||
// есть ли в браузере аппаратный HEVC-декодер через WebCodecs (кэш Promise)
|
||
let _hevcHw = null;
|
||
function hevcHwSupported() {
|
||
if (_hevcHw) return _hevcHw;
|
||
_hevcHw = (async () => {
|
||
if (!("VideoDecoder" in window) || !window.isSecureContext) return false;
|
||
const cands = ["hvc1.1.6.L153.B0", "hev1.1.6.L153.B0", "hvc1.2.4.L153.B0"];
|
||
for (const codec of cands) {
|
||
try {
|
||
const r = await VideoDecoder.isConfigSupported(
|
||
{ codec, codedWidth: 3200, codedHeight: 1800, hardwareAcceleration: "prefer-hardware" });
|
||
if (r && r.supported) return true;
|
||
} catch (e) { /* пробуем следующий */ }
|
||
}
|
||
return false;
|
||
})();
|
||
return _hevcHw;
|
||
}
|
||
|
||
// строка кодека из HEVCDecoderConfigurationRecord (hvcC)
|
||
function hevcCodecFromHvcC(hvcC) {
|
||
try {
|
||
const profileIdc = hvcC[1] & 0x1f;
|
||
const tier = ((hvcC[1] >> 5) & 1) ? "H" : "L";
|
||
const level = hvcC[12] || 153;
|
||
return `hvc1.${profileIdc || 1}.6.${tier}${level}.B0`;
|
||
} catch (e) { return "hvc1.1.6.L153.B0"; }
|
||
}
|
||
|
||
// демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры
|
||
class FlvHevcDemuxer {
|
||
constructor({ onConfig, onSample }) {
|
||
this.onConfig = onConfig; this.onSample = onSample;
|
||
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
||
}
|
||
push(ab) {
|
||
const inc = new Uint8Array(ab);
|
||
if (this.buf.length === 0) { this.buf = inc; }
|
||
else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; }
|
||
this._parse();
|
||
}
|
||
_u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; }
|
||
_parse() {
|
||
const b = this.buf, len = b.length;
|
||
if (!this.headerDone) {
|
||
if (len < 9) return;
|
||
const dataOffset = this._u32(5); // обычно 9
|
||
this.buf = b.slice(dataOffset); this.headerDone = true;
|
||
return this._parse();
|
||
}
|
||
let o = 0;
|
||
while (true) {
|
||
if (o + 4 + 11 > len) break; // 4 байта PrevTagSize + 11 байт TagHeader
|
||
const ts = o + 4;
|
||
const tagType = b[ts] & 0x1f;
|
||
const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3];
|
||
const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize;
|
||
if (bodyEnd > len) break; // тег ещё не дочитан
|
||
if (tagType === 9) {
|
||
const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6];
|
||
this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs);
|
||
}
|
||
o = bodyEnd;
|
||
}
|
||
if (o > 0) this.buf = b.slice(o);
|
||
}
|
||
_videoTag(body, tsMs) {
|
||
if (body.length < 5) return;
|
||
const b0 = body[0], isEx = (b0 & 0x80) !== 0;
|
||
if (isEx) {
|
||
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
||
const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]);
|
||
if (fourcc !== "hvc1" && fourcc !== "hev1") return;
|
||
let off = 5;
|
||
if (packetType === 1) off += 3; // CompositionTime
|
||
const payload = body.subarray(off);
|
||
if (packetType === 0) this._config(payload);
|
||
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
||
} else {
|
||
const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f;
|
||
if (codecId !== 12 && codecId !== 7) return; // 12=HEVC legacy, 7=AVC
|
||
const pkt = body[1], payload = body.subarray(5);
|
||
if (pkt === 0) this._config(payload);
|
||
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
||
}
|
||
}
|
||
_config(hvcC) {
|
||
if (this.gotConfig || hvcC.length < 13) return;
|
||
this.gotConfig = true;
|
||
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
|
||
}
|
||
_frames(payload, isKey, tsMs) {
|
||
if (!this.gotConfig || payload.length === 0) return;
|
||
this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 });
|
||
}
|
||
}
|
||
|
||
// плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → <canvas>
|
||
class WebCodecsHevcPlayer {
|
||
constructor(box, wsUrl, onFail) {
|
||
this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null;
|
||
this.closed = false; this.configured = false; this.pending = null; this.rafId = 0;
|
||
this.canvas = document.createElement("canvas");
|
||
this.canvas.className = "wc-canvas";
|
||
box.appendChild(this.canvas);
|
||
this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true });
|
||
this._start();
|
||
}
|
||
_fail(reason) {
|
||
if (this.closed) return;
|
||
const f = this.onFail; this.onFail = null;
|
||
this.destroy();
|
||
if (f) f(reason);
|
||
}
|
||
_start() {
|
||
if (!("VideoDecoder" in window)) { this._fail("no-webcodecs"); return; }
|
||
try {
|
||
this.decoder = new VideoDecoder({
|
||
output: (frame) => {
|
||
if (this.closed) { frame.close(); return; }
|
||
if (this.pending) this.pending.close(); // живое видео — держим только последний кадр
|
||
this.pending = frame;
|
||
if (!this.rafId) this.rafId = requestAnimationFrame(() => this._paint());
|
||
},
|
||
error: (e) => { console.warn("VideoDecoder", e && e.message); this._fail("decode-error"); },
|
||
});
|
||
} catch (e) { this._fail("ctor"); return; }
|
||
|
||
this.demux = new FlvHevcDemuxer({
|
||
onConfig: (hvcC, codec) => {
|
||
console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length);
|
||
try {
|
||
this.decoder.configure({ codec, description: hvcC, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" });
|
||
this.configured = true;
|
||
console.log("WC: configure() ok, state=", this.decoder.state);
|
||
} catch (e) { console.warn("WC: configure threw:", e && (e.message || e)); this._fail("configure:" + (e && e.name || "")); }
|
||
},
|
||
onSample: ({ data, type, timestampUs }) => {
|
||
if (!this.configured || this.closed || this.decoder.state !== "configured") return;
|
||
// отстаём → ждём ближайший ключевой кадр (чистая граница GOP, без битых ссылок)
|
||
if (this.decoder.decodeQueueSize > 6) this.waitKey = true;
|
||
if (this.waitKey) { if (type !== "key") return; this.waitKey = false; }
|
||
try {
|
||
this.decoder.decode(new EncodedVideoChunk({ type, timestamp: timestampUs, data }));
|
||
if (!this._fed) { this._fed = true; console.log("WC: first decode() sent, type=", type, "bytes=", data.length); }
|
||
} catch (e) { console.warn("WC: decode threw:", e && (e.message || e)); }
|
||
},
|
||
});
|
||
|
||
try {
|
||
this.ws = new WebSocket(this.wsUrl);
|
||
this.ws.binaryType = "arraybuffer";
|
||
this.ws.onmessage = (ev) => { if (!this.closed) { try { this.demux.push(ev.data); } catch (e) { /**/ } } };
|
||
this.ws.onerror = () => { /**/ };
|
||
this.ws.onclose = () => { if (!this.closed && !this.configured) this._fail("ws-closed"); };
|
||
} catch (e) { this._fail("ws"); }
|
||
}
|
||
_paint() {
|
||
this.rafId = 0;
|
||
const f = this.pending; this.pending = null;
|
||
if (!f) return;
|
||
if (this.canvas.width !== f.displayWidth) this.canvas.width = f.displayWidth;
|
||
if (this.canvas.height !== f.displayHeight) this.canvas.height = f.displayHeight;
|
||
try { this.ctx.drawImage(f, 0, 0); } catch (e) { /**/ }
|
||
f.close();
|
||
}
|
||
resize() { /* canvas масштабируется CSS object-fit, явный ресайз не нужен */ }
|
||
destroy() {
|
||
this.closed = true;
|
||
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = 0; }
|
||
if (this.pending) { try { this.pending.close(); } catch (e) { /**/ } this.pending = null; }
|
||
try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ }
|
||
try { if (this.decoder && this.decoder.state !== "closed") this.decoder.close(); } catch (e) { /**/ }
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница LIVE VIEW
|
||
// ════════════════════════════════════════════════════════════════
|
||
const Live = {
|
||
cameras: [], dim: 2, layout: 4, transcode: false, go2rtcUrl: "", selected: [], singleMain: false, maxDim: 8, playing: true,
|
||
|
||
async init() {
|
||
const data = await api("/api/cameras");
|
||
this.cameras = data.cameras;
|
||
// режим каждого потока (свой плеер / транскод) — настройка этого браузера
|
||
this.mode1 = profileMode("1"); // 1 поток (основной)
|
||
this.mode2 = profileMode("2"); // 2 поток (суб)
|
||
// серверная политика (общая для всех клиентов)
|
||
let opt = {};
|
||
try { opt = await api("/api/optimization"); } catch (e) { /* дефолты */ }
|
||
this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл
|
||
this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64
|
||
this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || "";
|
||
// встроенный плеер (hevc.js WASM) грузит свой worker по требованию — предзагрузка не нужна
|
||
if (this.go2rtcUrl && (this.mode1 === "native" || this.mode2 === "native")) { // «как есть»
|
||
try { await loadRtcComponent(this.go2rtcUrl); } catch (e) { console.warn(e); }
|
||
}
|
||
|
||
this.applyMaxDim();
|
||
this.setDim(this.initialDim(), false);
|
||
this.bindSubnav();
|
||
this.bindFullscreenResize();
|
||
this.renderGrid();
|
||
connectStatusWS((cams) => {
|
||
cams.forEach((u) => {
|
||
const c = this.cameras.find((x) => x.id === u.camera_id);
|
||
if (c) c.status = u;
|
||
});
|
||
this.updateDots();
|
||
});
|
||
},
|
||
|
||
applyMaxDim() {
|
||
// скрыть пункты подменю с числом больше лимита (режим 16+)
|
||
document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => {
|
||
a.hidden = +a.dataset.grid > this.maxDim;
|
||
});
|
||
},
|
||
|
||
initialDim() {
|
||
const max = this.maxDim || 8;
|
||
const q = parseInt(new URLSearchParams(location.search).get("grid"), 10);
|
||
if (q >= 2 && q <= 8) return Math.min(q, max);
|
||
const saved = parseInt(localStorage.getItem("corevision.grid"), 10);
|
||
return saved >= 2 ? Math.min(saved, max) : 2;
|
||
},
|
||
|
||
setDim(dim, rerender = true) {
|
||
this.dim = Math.min(this.maxDim || 8, Math.max(2, dim));
|
||
this.layout = this.dim * this.dim;
|
||
localStorage.setItem("corevision.grid", String(this.dim));
|
||
// приоритет — сохранённая раскладка (страница "Раскладка"); иначе автозаполнение
|
||
const saved = getLayout(this.dim);
|
||
if (Array.isArray(saved) && saved.length === this.layout) {
|
||
this.selected = saved.slice();
|
||
} else {
|
||
this.selected = this.selected.slice(0, this.layout);
|
||
while (this.selected.length < this.layout) {
|
||
const next = this.cameras.find((c) => !this.selected.includes(c.id));
|
||
if (!next) break;
|
||
this.selected.push(next.id);
|
||
}
|
||
}
|
||
document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) =>
|
||
a.classList.toggle("active", +a.dataset.grid === this.dim));
|
||
if (rerender) this.renderGrid();
|
||
},
|
||
|
||
bindSubnav() {
|
||
document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => {
|
||
a.onclick = (e) => {
|
||
e.preventDefault(); // на странице live меняем без перезагрузки
|
||
history.replaceState(null, "", "/?grid=" + a.dataset.grid);
|
||
this.setDim(+a.dataset.grid);
|
||
};
|
||
});
|
||
const fs = document.getElementById("fs-grid");
|
||
if (fs) fs.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("grid")); };
|
||
const pt = document.getElementById("play-toggle");
|
||
if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; }
|
||
},
|
||
|
||
bindFullscreenResize() {
|
||
if (this._fsBound) return;
|
||
this._fsBound = true;
|
||
const onFs = () => {
|
||
// выход из фуллскрина (в т.ч. по ESC) — снять «соло», вернуться к сетке
|
||
if (!(document.fullscreenElement || document.webkitFullscreenElement)) this.clearSolo();
|
||
this.resizePlayers();
|
||
};
|
||
document.addEventListener("fullscreenchange", onFs);
|
||
document.addEventListener("webkitfullscreenchange", onFs);
|
||
},
|
||
|
||
updatePlayBtn() {
|
||
const pt = document.getElementById("play-toggle");
|
||
if (pt) {
|
||
pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр";
|
||
pt.classList.toggle("active", !this.playing);
|
||
}
|
||
},
|
||
|
||
setPlaying(on) {
|
||
this.playing = on;
|
||
this.updatePlayBtn();
|
||
this.renderGrid(); // playing=false → плееры не создаются, сетка останавливается
|
||
},
|
||
|
||
transcodeVideo(cam, useMain) { // серверный транскод H.264/VP9 → <video>
|
||
const v = document.createElement("video");
|
||
v.dataset.kind = "transcode";
|
||
v.autoplay = true; v.muted = true; v.playsInline = true;
|
||
// профиль 1 — основной поток, профиль 2 — субпоток
|
||
const q = useMain ? `stream=main&${transcodeQueryFor("1")}` : transcodeQueryFor("2");
|
||
v.src = `/api/cameras/${cam.id}/live.mp4?${q}`;
|
||
return v;
|
||
},
|
||
|
||
buildPlayer(cam, useMain) {
|
||
const which = useMain ? "1" : "2"; // профиль потока
|
||
const mode = profileMode(which);
|
||
if (mode === "transcode") return this.transcodeVideo(cam, useMain);
|
||
if (mode === "native") { // «как есть»: нативный go2rtc-плеер
|
||
if (!this.go2rtcUrl) return null;
|
||
const src = useMain ? (cam.id + "_main") : cam.live.src;
|
||
const el = makePlayer(this.go2rtcUrl, src, false); // браузер сам декодирует HEVC
|
||
el.dataset.kind = "native";
|
||
return el;
|
||
}
|
||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||
const url = `${proto}://${location.host}/api/cameras/${cam.id}/flv?stream=${useMain ? "main" : "sub"}`;
|
||
const box = document.createElement("div");
|
||
box.className = "hevc-player";
|
||
const startWasm = () => { // встроенный плеер: hevc.js WASM в Web Worker
|
||
box.dataset.kind = "wasm";
|
||
box._hevc = new HevcWasmPlayer(box, url, useMain, () => {
|
||
if (box.isConnected) box.innerHTML = '<div class="empty">плеер не запустился</div>';
|
||
});
|
||
};
|
||
if (mode === "webcodecs") { // свой плеер: WebCodecs (HW), 0% CPU сервера
|
||
box.dataset.kind = "webcodecs";
|
||
const fallback = (msg) => {
|
||
if (!box.isConnected) return;
|
||
const h = document.createElement("div");
|
||
h.className = "hevc-hint";
|
||
h.textContent = msg;
|
||
box.appendChild(h);
|
||
startWasm(); // запасной — WASM-декод в воркере
|
||
};
|
||
hevcHwSupported().then((hw) => {
|
||
if (!box.isConnected) return;
|
||
if (!hw) { // нет аппаратного HEVC в этом браузере
|
||
fallback("Нет аппаратного H.265 (WebCodecs) в этом браузере — запасной WASM-декодер");
|
||
return;
|
||
}
|
||
box._wc = new WebCodecsHevcPlayer(box, url, (reason) =>
|
||
fallback("WebCodecs ошибка (" + reason + ") — запасной WASM-декодер"));
|
||
});
|
||
return box;
|
||
}
|
||
// встроенный плеер: hevc.js (клиентский WASM-декод H.265 + тайлы, в Web Worker)
|
||
startWasm();
|
||
return box;
|
||
},
|
||
|
||
// уничтожить плеер-инстансы внутри ячейки (WASM-воркер/WebCodecs держат worker/decoder)
|
||
destroyTilePlayers(tile) {
|
||
tile.querySelectorAll(".hevc-player").forEach((b) => {
|
||
if (b._wc) { try { b._wc.destroy(); } catch (e) { /* ignore */ } b._wc = null; }
|
||
if (b._hevc) { try { b._hevc.destroy(); } catch (e) { /* ignore */ } b._hevc = null; }
|
||
});
|
||
},
|
||
|
||
// отрисовать медиа ячейки (плеер + подсказка про HEVC / плашки)
|
||
setTileMedia(tile, cam, useMain) {
|
||
this.destroyTilePlayers(tile);
|
||
tile.querySelectorAll("video, video-stream, .hevc-player, .empty, .hevc-hint").forEach((e) => e.remove());
|
||
if (cam && !cam.enabled) { // камера выключена — ни записи, ни live
|
||
const d = document.createElement("div");
|
||
d.className = "empty"; d.textContent = "камера выключена";
|
||
tile.insertBefore(d, tile.firstChild); return;
|
||
}
|
||
if (!this.playing) {
|
||
const d = document.createElement("div");
|
||
d.className = "empty"; d.textContent = "⏸ просмотр остановлен";
|
||
tile.insertBefore(d, tile.firstChild); return;
|
||
}
|
||
const p = this.buildPlayer(cam, useMain);
|
||
if (!p) {
|
||
const d = document.createElement("div");
|
||
d.className = "empty"; d.textContent = "go2rtc недоступен";
|
||
tile.insertBefore(d, tile.firstChild); return;
|
||
}
|
||
tile.insertBefore(p, tile.firstChild);
|
||
// подсказка только для нативного go2rtc-плеера, если браузер не умеет HEVC
|
||
if (p.dataset.kind === "native" && !hevcSupported()) {
|
||
const h = document.createElement("div");
|
||
h.className = "hevc-hint";
|
||
h.textContent = "Браузер не декодирует H.265 — выберите «встроенный плеер» в настройках";
|
||
tile.appendChild(h);
|
||
}
|
||
},
|
||
|
||
renderGrid() {
|
||
const grid = document.getElementById("grid");
|
||
grid.className = "grid";
|
||
grid.style.gridTemplateColumns = `repeat(${this.dim}, 1fr)`;
|
||
grid.style.gridTemplateRows = `repeat(${this.dim}, 1fr)`;
|
||
grid.ondblclick = (e) => { // двойной клик по ячейке → «соло» (камера на весь экран)
|
||
if (e.target.closest(".tile-btn")) return;
|
||
const tile = e.target.closest(".tile");
|
||
if (tile) this.toggleSolo(tile);
|
||
};
|
||
grid.onclick = (e) => { // кнопка переключения на основной поток
|
||
const btn = e.target.closest(".tile-btn");
|
||
if (!btn) return;
|
||
if (!this.playing) return; // просмотр остановлен — переключать нечего
|
||
const tile = btn.closest(".tile");
|
||
const cam = this.cameras.find((c) => c.id === btn.dataset.cam);
|
||
if (!cam) return;
|
||
const useMain = tile.dataset.main !== "1";
|
||
// ограничение: не более одного основного потока в сетке
|
||
if (useMain && this.singleMain) {
|
||
grid.querySelectorAll('.tile[data-main="1"]').forEach((t) => {
|
||
if (t !== tile) this.applyStream(t, false);
|
||
});
|
||
}
|
||
this.applyStream(tile, useMain);
|
||
};
|
||
grid.innerHTML = "";
|
||
for (let i = 0; i < this.layout; i++) {
|
||
const id = this.selected[i];
|
||
const tile = document.createElement("div");
|
||
tile.className = "tile";
|
||
const cam = this.cameras.find((c) => c.id === id);
|
||
if (cam) {
|
||
const st = (cam.status && cam.status.state) || "offline";
|
||
const label = document.createElement("div");
|
||
label.className = "label"; label.dataset.cam = cam.id;
|
||
label.innerHTML = `<span class="${dotClass(st)}"></span>${cam.name}`;
|
||
tile.appendChild(label);
|
||
const hd = document.createElement("button");
|
||
hd.className = "tile-btn"; hd.dataset.cam = cam.id;
|
||
hd.textContent = "2"; hd.title = "Переключить на основной поток (1)";
|
||
tile.appendChild(hd);
|
||
this.setTileMedia(tile, cam, false);
|
||
} else {
|
||
tile.innerHTML = '<div class="no-link">NO LINK</div>';
|
||
}
|
||
grid.appendChild(tile);
|
||
}
|
||
},
|
||
|
||
applyStream(tile, useMain) { // переключает ячейку на осн./субпоток
|
||
const btn = tile.querySelector(".tile-btn");
|
||
const cam = btn && this.cameras.find((c) => c.id === btn.dataset.cam);
|
||
if (!cam) return;
|
||
tile.dataset.main = useMain ? "1" : "0";
|
||
if (btn) {
|
||
btn.textContent = useMain ? "1" : "2";
|
||
btn.classList.toggle("active", useMain);
|
||
}
|
||
this.setTileMedia(tile, cam, useMain);
|
||
},
|
||
|
||
resizePlayers() { // пересчитать canvas плееров после смены размера ячейки
|
||
setTimeout(() => {
|
||
document.querySelectorAll(".hevc-player").forEach((b) => {
|
||
if (b._wc && typeof b._wc.resize === "function") { try { b._wc.resize(); } catch (e) { /**/ } }
|
||
if (b._hevc && typeof b._hevc.resize === "function") { try { b._hevc.resize(); } catch (e) { /**/ } }
|
||
});
|
||
window.dispatchEvent(new Event("resize"));
|
||
}, 120);
|
||
},
|
||
|
||
clearSolo() {
|
||
const grid = document.getElementById("grid");
|
||
if (!grid) return;
|
||
grid.classList.remove("solo");
|
||
grid.querySelectorAll(".tile.solo").forEach((t) => t.classList.remove("solo"));
|
||
},
|
||
|
||
toggleSolo(tile) { // одна камера на весь экран (внутри полноэкранного режима)
|
||
const grid = document.getElementById("grid");
|
||
const wasSolo = grid.classList.contains("solo") && tile.classList.contains("solo");
|
||
this.clearSolo();
|
||
if (!wasSolo) { // включить «соло» этой ячейки
|
||
grid.classList.add("solo");
|
||
tile.classList.add("solo");
|
||
// ещё не в полноэкранном — войти, чтобы камера заняла весь экран (выход — ESC)
|
||
if (!(document.fullscreenElement || document.webkitFullscreenElement)) toggleFullscreen(grid);
|
||
} // повторный двойной клик — назад к сетке (фуллскрин сохраняется)
|
||
this.resizePlayers();
|
||
},
|
||
|
||
updateDots() {
|
||
document.querySelectorAll(".tile .label[data-cam]").forEach((lbl) => {
|
||
const cam = this.cameras.find((c) => c.id === lbl.dataset.cam);
|
||
const dot = lbl.querySelector(".dot");
|
||
if (cam && dot) dot.className = dotClass((cam.status && cam.status.state) || "offline");
|
||
});
|
||
},
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница АРХИВ
|
||
// ════════════════════════════════════════════════════════════════
|
||
// ── плеер архива в стиле Playerjs: своя панель управления над <video> ──
|
||
// (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen,
|
||
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
|
||
class ArchivePlayer {
|
||
// opts: { mode:"transcode"|"file", duration, srcFor(startSec), src, offset, onEnded }
|
||
// transcode — поток без длительности/Range: ведём виртуальный таймлайн на всю длину
|
||
// сегмента (duration из БД), перемотка = перезапуск транскода с -ss (srcFor).
|
||
// file — нативный seekable H.265 (HW-клиенты): обычный v.currentTime / v.duration.
|
||
constructor(wrap, opts = {}) {
|
||
this.mode = opts.mode === "transcode" ? "transcode" : "file";
|
||
this.total = opts.duration || 0; // полная длина сегмента, сек
|
||
this.srcFor = opts.srcFor || null; // (startSec) => url (для transcode)
|
||
this.startOffset = opts.offset || 0; // смещение начала текущего потока (transcode)
|
||
this.onEnded = opts.onEnded || null;
|
||
this.onTime = opts.onTime || null; // (posSec в сегменте) — для следования таймлайна
|
||
this.speeds = [0.5, 1, 2, 5];
|
||
this.spIdx = opts.spIdx != null ? opts.spIdx : 1; // скорость сохраняется между фрагментами
|
||
this.hideTimer = 0; this.dragging = false; this.dragP = 0;
|
||
wrap.innerHTML = "";
|
||
const root = document.createElement("div");
|
||
root.className = "pjs show"; root.tabIndex = 0;
|
||
root.innerHTML =
|
||
'<video class="pjs-video" autoplay playsinline></video>' +
|
||
'<div class="pjs-center"><div class="pjs-spinner"></div>' +
|
||
'<button class="pjs-bigplay" aria-label="Play">▶</button></div>' +
|
||
'<div class="pjs-bar">' +
|
||
'<button class="pjs-btn pjs-play" title="Воспроизведение/пауза (Пробел)">⏸</button>' +
|
||
'<span class="pjs-time">0:00</span>' +
|
||
'<div class="pjs-seek"><div class="pjs-buf"></div><div class="pjs-prog"></div><div class="pjs-knob"></div></div>' +
|
||
'<span class="pjs-dur">0:00</span>' +
|
||
'<button class="pjs-btn pjs-speed" title="Скорость воспроизведения">1×</button>' +
|
||
'<button class="pjs-btn pjs-fs" title="Во весь экран (F)">⛶</button>' +
|
||
'</div>';
|
||
wrap.appendChild(root);
|
||
this.root = root;
|
||
this.v = root.querySelector(".pjs-video");
|
||
this.seek = root.querySelector(".pjs-seek");
|
||
this.prog = root.querySelector(".pjs-prog");
|
||
this.buf = root.querySelector(".pjs-buf");
|
||
this.knob = root.querySelector(".pjs-knob");
|
||
this.timeEl = root.querySelector(".pjs-time");
|
||
this.durEl = root.querySelector(".pjs-dur");
|
||
this.playBtn = root.querySelector(".pjs-play");
|
||
this.speedBtn = root.querySelector(".pjs-speed");
|
||
this.speedBtn.textContent = this.speeds[this.spIdx] + "×";
|
||
this.v.src = this.mode === "transcode" ? this.srcFor(this.startOffset) : opts.src;
|
||
this._bind();
|
||
}
|
||
_fmt(s) {
|
||
if (!isFinite(s) || s < 0) s = 0;
|
||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||
return m + ":" + String(sec).padStart(2, "0");
|
||
}
|
||
_total() {
|
||
if (this.mode === "transcode") return this.total;
|
||
return isFinite(this.v.duration) && this.v.duration ? this.v.duration : (this.total || 0);
|
||
}
|
||
_curTime() {
|
||
if (this.mode === "transcode") return this.startOffset + (this.v.currentTime || 0);
|
||
return this.v.currentTime || 0;
|
||
}
|
||
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; } // вернуть выбранную скорость (в т.ч. перенесённую с прошлого фрагмента)
|
||
// перемотка к доле p∈[0..1] всего сегмента
|
||
_seekToFrac(p) {
|
||
const total = this._total(); if (!total) return;
|
||
const t = Math.min(total, Math.max(0, p * total));
|
||
if (this.mode === "transcode") {
|
||
this.startOffset = t; // перезапуск транскода с нужной секунды
|
||
this.v.src = this.srcFor(t);
|
||
this.v.play().catch(() => {});
|
||
} else {
|
||
try { this.v.currentTime = t; } catch (e) { /**/ }
|
||
}
|
||
this._sync();
|
||
}
|
||
_seekRel(d) { const total = this._total(); if (total) this._seekToFrac((this._curTime() + d) / total); }
|
||
_bind() {
|
||
const v = this.v, root = this.root;
|
||
v.addEventListener("loadedmetadata", () => {
|
||
this.durEl.textContent = this._fmt(this._total());
|
||
if (this.mode === "file" && this.startOffset > 0) { try { v.currentTime = this.startOffset; } catch (e) { /**/ } }
|
||
this._applyRate(); // вернуть скорость после загрузки нового потока (src сбрасывает playbackRate)
|
||
});
|
||
v.addEventListener("timeupdate", () => this._sync());
|
||
v.addEventListener("progress", () => this._syncBuf());
|
||
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); this._autohide(); });
|
||
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); this._show(); });
|
||
v.addEventListener("waiting", () => root.classList.add("buffering"));
|
||
v.addEventListener("playing", () => root.classList.remove("buffering"));
|
||
v.addEventListener("ended", () => { root.classList.add("paused"); if (this.onEnded) this.onEnded(); });
|
||
|
||
const toggle = () => { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } };
|
||
this.playBtn.onclick = toggle;
|
||
root.querySelector(".pjs-bigplay").onclick = toggle;
|
||
v.onclick = toggle;
|
||
v.ondblclick = () => this._fs();
|
||
root.querySelector(".pjs-fs").onclick = () => this._fs();
|
||
this.speedBtn.onclick = () => {
|
||
this.spIdx = (this.spIdx + 1) % this.speeds.length;
|
||
const r = this.speeds[this.spIdx]; v.playbackRate = r; this.speedBtn.textContent = r + "×";
|
||
};
|
||
|
||
// перемотка по шкале: во время drag — только превью, перезапуск/seek один раз на отпускании
|
||
const fracAt = (clientX) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (clientX - r.left) / r.width)); };
|
||
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = this._fmt(p * this._total()); };
|
||
this.seek.addEventListener("pointerdown", (e) => { this.dragging = true; this.dragP = fracAt(e.clientX); try { this.seek.setPointerCapture(e.pointerId); } catch (er) {} preview(this.dragP); });
|
||
this.seek.addEventListener("pointermove", (e) => { if (this.dragging) { this.dragP = fracAt(e.clientX); preview(this.dragP); } });
|
||
this.seek.addEventListener("pointerup", () => { if (this.dragging) { this.dragging = false; this._seekToFrac(this.dragP); } });
|
||
|
||
// показ/автоскрытие панели
|
||
root.addEventListener("mousemove", () => this._autohide());
|
||
root.addEventListener("mouseleave", () => { if (!v.paused) this._hide(); });
|
||
|
||
// горячие клавиши (когда плеер в фокусе)
|
||
root.addEventListener("keydown", (e) => {
|
||
if (e.code === "Space") { e.preventDefault(); toggle(); }
|
||
else if (e.code === "ArrowRight") { e.preventDefault(); this._seekRel(5); }
|
||
else if (e.code === "ArrowLeft") { e.preventDefault(); this._seekRel(-5); }
|
||
else if (e.key === "f" || e.key === "F" || e.key === "а" || e.key === "А") { e.preventDefault(); this._fs(); }
|
||
});
|
||
this._fsSync = () => root.classList.toggle("fs", (document.fullscreenElement || document.webkitFullscreenElement) === root);
|
||
document.addEventListener("fullscreenchange", this._fsSync);
|
||
document.addEventListener("webkitfullscreenchange", this._fsSync);
|
||
setTimeout(() => { try { root.focus(); } catch (e) {} }, 0);
|
||
}
|
||
_sync() {
|
||
if (this.dragging) return; // во время перетаскивания шкалой управляет preview
|
||
const cur = this._curTime(), total = this._total();
|
||
const p = total ? Math.min(1, cur / total) : 0;
|
||
this.prog.style.width = (p * 100) + "%";
|
||
this.knob.style.left = (p * 100) + "%";
|
||
this.timeEl.textContent = this._fmt(cur);
|
||
this.durEl.textContent = this._fmt(total);
|
||
if (this.onTime) this.onTime(cur);
|
||
}
|
||
_syncBuf() {
|
||
const v = this.v; if (!v.buffered.length) return;
|
||
const total = this._total(); if (!total) return;
|
||
const base = this.mode === "transcode" ? this.startOffset : 0;
|
||
const end = base + v.buffered.end(v.buffered.length - 1);
|
||
this.buf.style.width = Math.min(100, end / total * 100) + "%";
|
||
}
|
||
_fs() { toggleFullscreen(this.root); }
|
||
_show() { this.root.classList.add("show"); }
|
||
_hide() { this.root.classList.remove("show"); }
|
||
_autohide() {
|
||
this._show(); clearTimeout(this.hideTimer);
|
||
this.hideTimer = setTimeout(() => { if (!this.v.paused && !this.dragging) this._hide(); }, 2500);
|
||
}
|
||
destroy() {
|
||
this.onTime = null; this.onEnded = null; // хвостовые timeupdate/ended не должны дёргать ленту
|
||
clearTimeout(this.hideTimer);
|
||
if (this._fsSync) {
|
||
document.removeEventListener("fullscreenchange", this._fsSync);
|
||
document.removeEventListener("webkitfullscreenchange", this._fsSync);
|
||
}
|
||
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
|
||
}
|
||
}
|
||
|
||
const Archive = {
|
||
cameras: [], cam: null, date: null, segments: [], transcode: false,
|
||
centerTime: 0, dayStart: 0, dragging: false, following: false, track: null, _curSeg: null, _playTok: 0,
|
||
|
||
async init() {
|
||
const data = await api("/api/cameras");
|
||
this.cameras = data.cameras;
|
||
this.transcode = profileMode("a") === "transcode"; // профиль архива (этот браузер)
|
||
this.cam = this.cameras[0] ? this.cameras[0].id : null;
|
||
|
||
const sel = document.getElementById("cam-select");
|
||
sel.innerHTML = this.cameras.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||
sel.onchange = () => { this.cam = sel.value; this.load(); };
|
||
|
||
const dp = document.getElementById("date");
|
||
const today = new Date();
|
||
dp.value = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
|
||
this.date = dp.value;
|
||
dp.onchange = () => { this.date = dp.value; this.load(); };
|
||
|
||
// при ресайзе окна пересчитать смещение ленты (центр зависит от ширины вьюпорта)
|
||
window.addEventListener("resize", () => { if (this.track) this._applyTl(); });
|
||
|
||
this.load();
|
||
},
|
||
|
||
async load() {
|
||
if (!this.cam || !this.date) return;
|
||
const [y, m, d] = this.date.split("-").map(Number);
|
||
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||
const to = from + 86400;
|
||
const data = await api(`/api/recordings?camera=${this.cam}&from=${from}&to=${to}`);
|
||
this.segments = data.recordings;
|
||
this.renderTimeline();
|
||
},
|
||
|
||
// горизонтальный таймлайн: шкала 00:00–24:00, жёлтая полоса записей, линия «Сейчас»
|
||
PX_PER_MIN: 8, // ширина минуты в пикселях (10 мин = 80px)
|
||
|
||
renderTimeline() {
|
||
const box = document.getElementById("timeline");
|
||
box.className = "timeline tl";
|
||
box.innerHTML = "";
|
||
this.track = null; this.segEls = [];
|
||
if (!this.segments.length) {
|
||
box.innerHTML = '<div class="tl-empty">За выбранный день записей нет</div>';
|
||
return;
|
||
}
|
||
const [y, m, d] = this.date.split("-").map(Number);
|
||
this.dayStart = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||
const PX = this.PX_PER_MIN;
|
||
|
||
const track = document.createElement("div");
|
||
track.className = "tl-track";
|
||
track.style.width = 24 * 60 * PX + "px";
|
||
|
||
// деления времени каждые 10 минут (на «часах» — выделенные)
|
||
for (let min = 0; min <= 1440; min += 10) {
|
||
const t = document.createElement("div");
|
||
t.className = "tl-tick" + (min % 60 === 0 ? " hour" : "");
|
||
t.style.left = min * PX + "px";
|
||
if (min < 1440) t.innerHTML = `<span>${pad(Math.floor(min / 60))}:${pad(min % 60)}</span>`;
|
||
track.appendChild(t);
|
||
}
|
||
|
||
// сегменты записей (жёлтая полоса)
|
||
this.segments.forEach((s) => {
|
||
const seg = document.createElement("div");
|
||
seg.className = "tl-seg";
|
||
seg.style.left = this._tlX(s.started_at) + "px";
|
||
seg.style.width = Math.max(2, ((s.duration_s || 0) / 60) * PX) + "px";
|
||
seg.title = `${fmtTime(s.started_at)} · ${fmtDur(s.duration_s)} · ${fmtSize(s.size_bytes)}`;
|
||
seg._seg = s;
|
||
track.appendChild(seg);
|
||
this.segEls.push(seg);
|
||
});
|
||
|
||
// маркер «эфир» — текущее время (если выбран сегодняшний день); движется вместе с лентой
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const isToday = now >= this.dayStart && now < this.dayStart + 86400;
|
||
if (isToday) {
|
||
const nl = document.createElement("div");
|
||
nl.className = "tl-now";
|
||
nl.style.left = this._tlX(now) + "px";
|
||
nl.innerHTML = `<span>эфир</span>`;
|
||
track.appendChild(nl);
|
||
}
|
||
box.appendChild(track);
|
||
|
||
// красный указатель, зафиксированный по центру (playhead) — лента ездит под ним
|
||
const center = document.createElement("div");
|
||
center.className = "tl-center";
|
||
center.innerHTML = "<span></span>";
|
||
box.appendChild(center);
|
||
this.centerLabel = center.querySelector("span");
|
||
this.track = track;
|
||
|
||
// начальная позиция центра: «эфир» (сегодня) или начало последней записи
|
||
const last = this.segments[this.segments.length - 1];
|
||
this._setCenter(isToday ? now : last.started_at);
|
||
this._bindTimeline(box);
|
||
},
|
||
|
||
_tlX(t) { return ((t - this.dayStart) / 60) * this.PX_PER_MIN; },
|
||
_segAt(t) { return this.segments.find((s) => t >= s.started_at && t < s.started_at + (s.duration_s || 0)); },
|
||
_setCenter(t) {
|
||
this.centerTime = Math.min(this.dayStart + 86400, Math.max(this.dayStart, t));
|
||
this._applyTl();
|
||
},
|
||
_applyTl() {
|
||
if (!this.track) return;
|
||
const box = this.track.parentElement;
|
||
const off = box.clientWidth / 2 - this._tlX(this.centerTime);
|
||
this.track.style.transform = `translateX(${off}px)`;
|
||
if (this.centerLabel) this.centerLabel.textContent = fmtTime(this.centerTime);
|
||
const cur = this._segAt(this.centerTime);
|
||
if (this.segEls) this.segEls.forEach((el) => el.classList.toggle("active", el._seg === cur));
|
||
},
|
||
_seekToCenter() {
|
||
const seg = this._segAt(this.centerTime);
|
||
if (seg) { this.following = true; this.play(seg, Math.max(0, this.centerTime - seg.started_at)); }
|
||
},
|
||
_bindTimeline(box) {
|
||
const PX = this.PX_PER_MIN;
|
||
let startX = 0, startCenter = 0, moved = false;
|
||
box.onpointerdown = (e) => {
|
||
if (e.button !== 0) return;
|
||
this.dragging = true; this.following = false; moved = false;
|
||
startX = e.clientX; startCenter = this.centerTime;
|
||
box.classList.add("grabbing");
|
||
try { box.setPointerCapture(e.pointerId); } catch (er) { /**/ }
|
||
};
|
||
box.onpointermove = (e) => {
|
||
if (!this.dragging) return;
|
||
const dx = e.clientX - startX;
|
||
if (Math.abs(dx) > 3) moved = true;
|
||
this._setCenter(startCenter - (dx / PX) * 60); // тащим вправо → раньше по времени
|
||
};
|
||
const end = () => {
|
||
if (!this.dragging) return;
|
||
this.dragging = false; box.classList.remove("grabbing");
|
||
if (!moved) { // клик без перетаскивания → точку под курсором в центр
|
||
const rect = box.getBoundingClientRect();
|
||
this._setCenter(startCenter + ((startX - (rect.left + rect.width / 2)) / PX) * 60);
|
||
}
|
||
this._seekToCenter();
|
||
};
|
||
box.onpointerup = end;
|
||
box.onpointercancel = end;
|
||
box.onwheel = (e) => { // прокрутка колесом — пан по времени
|
||
e.preventDefault();
|
||
this.following = false;
|
||
this._setCenter(this.centerTime + ((e.deltaX || e.deltaY) / PX) * 60);
|
||
clearTimeout(this._wheelTimer);
|
||
this._wheelTimer = setTimeout(() => this._seekToCenter(), 350);
|
||
};
|
||
},
|
||
|
||
play(seg, offsetSec = 0, spIdx) {
|
||
const tok = ++this._playTok; // токен: центр двигает только актуальный плеер
|
||
this._curSeg = seg;
|
||
const wrap = document.getElementById("player");
|
||
if (spIdx == null) spIdx = this._player ? this._player.spIdx : 1; // скорость сохраняется между фрагментами
|
||
if (this._player) { try { this._player.destroy(); } catch (e) { /**/ } }
|
||
const onEnded = () => { if (tok === this._playTok) this._playNext(seg); }; // авто-переход к следующей записи
|
||
// лента следует за плеером: позицию в сегменте → абсолютное время → центр (если не тащим вручную)
|
||
const onTime = (pos) => { if (tok === this._playTok && !this.dragging && this.following) this._setCenter(seg.started_at + pos); };
|
||
if (this.transcode) { // транскод-поток без Range → виртуальный таймлайн + серверный seek (-ss)
|
||
this._player = new ArchivePlayer(wrap, {
|
||
mode: "transcode",
|
||
duration: seg.duration_s || 0,
|
||
offset: offsetSec, spIdx,
|
||
srcFor: (t) => `/api/recordings/${seg.id}/play.mp4?${transcodeQueryFor("a")}&start=${(t || 0).toFixed(3)}`,
|
||
onEnded, onTime,
|
||
});
|
||
} else { // сырой H.265-файл: нативная перемотка (нужен HW-декод)
|
||
this._player = new ArchivePlayer(wrap, {
|
||
mode: "file",
|
||
duration: seg.duration_s || 0,
|
||
offset: offsetSec, spIdx,
|
||
src: `/api/recordings/${seg.id}/file`,
|
||
onEnded, onTime,
|
||
});
|
||
}
|
||
},
|
||
// следующий сегмент по времени (непрерывный просмотр архива)
|
||
_playNext(seg) {
|
||
const next = this.segments.find((s) => s.started_at > seg.started_at);
|
||
if (next) this.play(next, 0);
|
||
},
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница КАМЕРЫ (cam settings)
|
||
// ════════════════════════════════════════════════════════════════
|
||
const CamSettings = {
|
||
SLOTS: 64,
|
||
editing: false, // идёт инлайн-редактирование — не перерисовываем по WS
|
||
bulk: false, // режим массового редактирования всей таблицы
|
||
cams: [],
|
||
byId: {},
|
||
|
||
async init() {
|
||
const add = document.getElementById("add-cam");
|
||
if (add) add.onclick = () => this.beginAddFirstEmpty();
|
||
const be = document.getElementById("bulk-edit");
|
||
if (be) be.onclick = () => this.bulk ? this.saveBulk() : this.enterBulk();
|
||
const bc = document.getElementById("bulk-cancel");
|
||
if (bc) bc.onclick = () => this.exitBulk();
|
||
const cc = document.getElementById("clear-cams");
|
||
if (cc) cc.onclick = () => this.clearAll();
|
||
await this.load();
|
||
connectStatusWS(() => { if (!this.editing) this.load(); });
|
||
},
|
||
|
||
setBulkButtons() {
|
||
const be = document.getElementById("bulk-edit");
|
||
const bc = document.getElementById("bulk-cancel");
|
||
const add = document.getElementById("add-cam");
|
||
const cc = document.getElementById("clear-cams");
|
||
if (be) be.textContent = this.bulk ? "Сохранить" : "Изменить";
|
||
if (be) be.classList.toggle("primary", this.bulk);
|
||
if (bc) bc.hidden = !this.bulk;
|
||
if (add) add.hidden = this.bulk; // во время массового редактирования не добавляем
|
||
if (cc) cc.hidden = this.bulk;
|
||
},
|
||
|
||
enterBulk() {
|
||
if (!this.cams.length) { alert("Нет камер для редактирования"); return; }
|
||
this.bulk = true; this.editing = true;
|
||
this.setBulkButtons();
|
||
this.load();
|
||
},
|
||
|
||
async exitBulk() {
|
||
this.bulk = false; this.editing = false;
|
||
this.setBulkButtons();
|
||
await this.load();
|
||
},
|
||
|
||
// удалить ВСЕ камеры (архив не трогается) — POST /api/cameras/clear
|
||
async clearAll() {
|
||
if (this.bulk) return;
|
||
const n = this.cams.length;
|
||
if (!n) { alert("Камер нет"); return; }
|
||
if (!confirm(`Удалить ВСЕ камеры (${n})?\nЗаписи архива НЕ затрагиваются. Действие необратимо.`)) return;
|
||
const r = await fetch("/api/cameras/clear", { method: "POST" });
|
||
if (!r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
alert("Не удалось очистить: " + (d.detail || r.status));
|
||
return;
|
||
}
|
||
const d = await r.json().catch(() => ({}));
|
||
await this.load();
|
||
alert(`Удалено камер: ${d.removed != null ? d.removed : n}`);
|
||
},
|
||
|
||
// ── строка камеры в режиме массового редактирования ──
|
||
bulkRowHtml(slotNo, cam) {
|
||
const v = (x) => this.esc(x);
|
||
return (
|
||
`<td colspan="8" class="cam-edit">` +
|
||
`<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` +
|
||
`<div class="cam-edit-grid" data-cam="${v(cam.id)}">` +
|
||
`<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` +
|
||
`<label>IP<input type="text" data-f="ip" value="${v(cam.ip)}"></label>` +
|
||
`<label>Логин<input type="text" data-f="user" value="${v(cam.user)}"></label>` +
|
||
`<label>Пароль<input type="password" data-f="password" value="${v(cam.password)}"></label>` +
|
||
`<label>Осн. поток<input type="text" data-f="main_path" value="${v(cam.main_path)}"></label>` +
|
||
`<label>Субпоток<input type="text" data-f="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` +
|
||
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
|
||
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` +
|
||
`</div>` +
|
||
`</td>`
|
||
);
|
||
},
|
||
|
||
async saveBulk() {
|
||
const grids = document.querySelectorAll(".cam-edit-grid[data-cam]");
|
||
const errors = [];
|
||
for (const g of grids) {
|
||
const id = g.dataset.cam;
|
||
const orig = this.byId[id] || {};
|
||
const get = (f) => { const el = g.querySelector(`[data-f="${f}"]`); return el ? el.value : ""; };
|
||
const payload = {
|
||
name: get("name").trim(),
|
||
ip: get("ip").trim(),
|
||
user: get("user").trim(),
|
||
password: get("password"),
|
||
main_path: get("main_path").trim(),
|
||
sub_path: get("sub_path").trim(),
|
||
enabled: g.querySelector('[data-f="enabled"]').checked,
|
||
record: g.querySelector('[data-f="record"]').checked,
|
||
};
|
||
const changed =
|
||
payload.name !== orig.name || payload.ip !== orig.ip ||
|
||
payload.user !== orig.user || payload.password !== (orig.password || "") ||
|
||
payload.main_path !== orig.main_path ||
|
||
(payload.sub_path || "") !== (orig.sub_path || "") ||
|
||
payload.enabled !== orig.enabled ||
|
||
payload.record !== orig.record;
|
||
if (!changed) continue;
|
||
const r = await fetch(`/api/cameras/${id}`, {
|
||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`${id}: ${d.detail || r.status}`); }
|
||
}
|
||
this.bulk = false; this.editing = false;
|
||
this.setBulkButtons();
|
||
await this.load();
|
||
if (errors.length) alert("Не сохранено:\n" + errors.join("\n"));
|
||
},
|
||
|
||
esc(s) { return String(s == null ? "" : s).replace(/"/g, """); },
|
||
|
||
// ── редактор внутри строки таблицы ──
|
||
editRowHtml(slotNo, cam) {
|
||
const v = (x) => this.esc(x);
|
||
const isEdit = !!cam;
|
||
return (
|
||
`<td colspan="8" class="cam-edit">` +
|
||
`<div class="cam-edit-grid">` +
|
||
`<label>ID<input type="text" id="e-id" value="${cam ? v(cam.id) : ""}" ${isEdit ? "disabled" : ""} placeholder="cam${slotNo}"></label>` +
|
||
`<label>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` +
|
||
`<label>IP<input type="text" id="e-ip" value="${cam ? v(cam.ip) : ""}" placeholder="192.168.63.x"></label>` +
|
||
`<label>Логин<input type="text" id="e-user" value="${cam ? v(cam.user) : "admin"}"></label>` +
|
||
`<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` +
|
||
`<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` +
|
||
`<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
|
||
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
|
||
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
|
||
`</div>` +
|
||
`<div class="cam-edit-foot">` +
|
||
`<span class="err" id="e-err"></span>` +
|
||
`<button data-act="save-edit">Сохранить</button>` +
|
||
`<button data-act="cancel-edit">Отмена</button>` +
|
||
`</div>` +
|
||
`</td>`
|
||
);
|
||
},
|
||
|
||
beginEdit(tr, slotNo, cam) {
|
||
this.editing = true;
|
||
this.editId = cam ? cam.id : null;
|
||
// убрать возможный лог-подстрочник занятой камеры
|
||
if (tr.nextElementSibling && tr.nextElementSibling.querySelector(".logbox")) {
|
||
tr.nextElementSibling.remove();
|
||
}
|
||
tr.className = "cam-edit-row";
|
||
tr.innerHTML = this.editRowHtml(slotNo, cam);
|
||
const focusEl = document.getElementById(cam ? "e-name" : "e-id");
|
||
if (focusEl) focusEl.focus();
|
||
},
|
||
|
||
beginAddFirstEmpty() {
|
||
const tb = document.getElementById("cam-rows");
|
||
const empty = tb.querySelector("tr.slot-empty");
|
||
if (!empty) { alert("Все 64 слота заняты"); return; }
|
||
const slotNo = +empty.firstElementChild.textContent;
|
||
this.beginEdit(empty, slotNo, null);
|
||
},
|
||
|
||
async save(tr) {
|
||
const val = (id) => (document.getElementById(id) ? document.getElementById(id).value : "");
|
||
const payload = {
|
||
id: val("e-id").trim(),
|
||
name: val("e-name").trim(),
|
||
ip: val("e-ip").trim(),
|
||
user: val("e-user").trim(),
|
||
password: val("e-pass"),
|
||
main_path: val("e-main").trim(),
|
||
sub_path: val("e-sub").trim(),
|
||
enabled: document.getElementById("e-enabled").checked,
|
||
record: document.getElementById("e-record").checked,
|
||
};
|
||
const url = this.editId ? `/api/cameras/${this.editId}` : "/api/cameras";
|
||
const method = this.editId ? "PUT" : "POST";
|
||
try {
|
||
const r = await fetch(url, {
|
||
method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
||
});
|
||
if (r.ok) { this.editing = false; await this.load(); }
|
||
else {
|
||
const d = await r.json().catch(() => ({}));
|
||
document.getElementById("e-err").textContent = d.detail || ("Ошибка " + r.status);
|
||
}
|
||
} catch (e) {
|
||
document.getElementById("e-err").textContent = "Сбой запроса";
|
||
}
|
||
},
|
||
|
||
async remove(id, name) {
|
||
if (!confirm(`Удалить камеру «${name}» (${id})? Записи в архиве сохранятся.`)) return;
|
||
await fetch(`/api/cameras/${id}`, { method: "DELETE" });
|
||
await this.load();
|
||
},
|
||
|
||
async load() {
|
||
const data = await api("/api/cameras");
|
||
const cams = data.cameras;
|
||
this.cams = cams;
|
||
this.byId = {};
|
||
cams.forEach((c) => { this.byId[c.id] = c; });
|
||
const tb = document.getElementById("cam-rows");
|
||
const openLogs = new Set(
|
||
[...document.querySelectorAll(".logbox")].filter((b) => !b.hidden).map((b) => b.id)
|
||
);
|
||
tb.innerHTML = "";
|
||
if (this.bulk) {
|
||
// режим массового редактирования: только существующие камеры, форма на каждую
|
||
cams.forEach((c, i) => {
|
||
const tr = document.createElement("tr");
|
||
tr.className = "cam-edit-row";
|
||
tr.dataset.id = c.id;
|
||
tr.innerHTML = this.bulkRowHtml(i + 1, c);
|
||
tb.appendChild(tr);
|
||
});
|
||
return;
|
||
}
|
||
for (let i = 0; i < this.SLOTS; i++) {
|
||
const c = cams[i];
|
||
const tr = document.createElement("tr");
|
||
if (c) {
|
||
const st = (c.status && c.status.state) || "offline";
|
||
const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : "");
|
||
const statusCell = c.enabled
|
||
? `<span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span>`
|
||
: `<span class="muted">выключена</span>`;
|
||
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
||
tr.innerHTML =
|
||
`<td class="muted">${i + 1}</td>` +
|
||
`<td>${c.name}</td>` +
|
||
`<td class="muted">${c.ip}</td>` +
|
||
`<td class="muted">${streams}</td>` +
|
||
`<td><input type="checkbox" class="en-toggle" data-id="${c.id}" title="Камера включена (выкл — не работает совсем)" ${c.enabled ? "checked" : ""}></td>` +
|
||
`<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></td>` +
|
||
`<td>${statusCell}</td>` +
|
||
`<td class="actions">` +
|
||
`<button data-act="log" data-id="${c.id}">Лог</button> ` +
|
||
`<button data-act="restart" data-id="${c.id}">⟳</button> ` +
|
||
`<button data-act="delete" data-id="${c.id}">✕</button>` +
|
||
`</td>`;
|
||
tb.appendChild(tr);
|
||
const logTr = document.createElement("tr");
|
||
logTr.className = "logrow";
|
||
const hid = openLogs.has("log-" + c.id) ? "" : "hidden";
|
||
logTr.innerHTML = `<td colspan="8" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
|
||
tb.appendChild(logTr);
|
||
} else {
|
||
tr.className = "slot-empty";
|
||
tr.dataset.slot = i + 1;
|
||
tr.innerHTML =
|
||
`<td class="muted">${i + 1}</td>` +
|
||
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
|
||
`<td class="muted">пусто</td>` +
|
||
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
|
||
tb.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
tb.onclick = async (e) => {
|
||
const btn = e.target.closest("button");
|
||
if (!btn) return;
|
||
const id = btn.dataset.id;
|
||
const act = btn.dataset.act;
|
||
const tr = btn.closest("tr");
|
||
const slotNo = tr && tr.dataset.slot ? +tr.dataset.slot : 0;
|
||
if (act === "add") {
|
||
this.beginEdit(tr, slotNo, null);
|
||
} else if (act === "save-edit") {
|
||
this.save(tr);
|
||
} else if (act === "cancel-edit") {
|
||
this.editing = false; await this.load();
|
||
} else if (act === "delete") {
|
||
this.remove(id, this.byId[id] ? this.byId[id].name : id);
|
||
} else if (act === "restart") {
|
||
btn.disabled = true; btn.textContent = "…";
|
||
await post(`/api/cameras/${id}/restart`);
|
||
setTimeout(() => this.load(), 1500);
|
||
} else if (act === "log") {
|
||
const box = document.getElementById("log-" + id);
|
||
if (!box.hidden) { box.hidden = true; return; }
|
||
const d = await api(`/api/cameras/${id}/log`);
|
||
box.textContent = d.log.length ? d.log.join("\n") : "(лог пуст)";
|
||
box.hidden = false;
|
||
}
|
||
};
|
||
|
||
tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись)
|
||
const en = e.target.closest(".en-toggle");
|
||
const cb = en || e.target.closest(".rec-toggle");
|
||
if (!cb) return;
|
||
const id = cb.dataset.id;
|
||
const patch = en ? { enabled: cb.checked } : { record: cb.checked };
|
||
cb.disabled = true;
|
||
try {
|
||
const r = await fetch(`/api/cameras/${id}/toggle`, {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||
if (this.byId[id]) Object.assign(this.byId[id], patch);
|
||
await this.load(); // перерисовать: REC зависит от состояния «Вкл»
|
||
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
|
||
finally { cb.disabled = false; }
|
||
};
|
||
},
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница НАСТРОЙКИ
|
||
// ════════════════════════════════════════════════════════════════
|
||
const Settings = {
|
||
async init() {
|
||
const meEl = document.getElementById("me-info");
|
||
if (meEl && CURRENT_USER)
|
||
meEl.innerHTML = `Вы вошли как <b>${CURRENT_USER.username}</b> — ` +
|
||
(ROLE_RU[CURRENT_USER.role] || CURRENT_USER.role);
|
||
await Promise.all([this.loadSystem(), this.loadPrefs(), this.loadStorage()]);
|
||
if (roleAtLeast("superadmin")) this.loadOptim();
|
||
setInterval(() => this.loadSystem(), 5000);
|
||
},
|
||
|
||
async loadOptim() {
|
||
// доп. настройки Live view — серверная политика; менять только суперадмин + пароль
|
||
const single = document.getElementById("single-main");
|
||
const m16 = document.getElementById("mode-16plus");
|
||
const saved = document.getElementById("optim-saved");
|
||
if (!single) return;
|
||
let cur = { single_main_stream: true, mode_16plus: false };
|
||
try { const opt = await api("/api/optimization"); cur = { single_main_stream: !!opt.single_main_stream, mode_16plus: !!opt.mode_16plus }; }
|
||
catch (e) { /* нет связи — дефолты */ }
|
||
single.checked = cur.single_main_stream;
|
||
if (m16) m16.checked = cur.mode_16plus;
|
||
document.getElementById("optim-save").onclick = async () => {
|
||
const desired = { single_main_stream: single.checked, mode_16plus: m16 ? m16.checked : cur.mode_16plus };
|
||
if (desired.single_main_stream === cur.single_main_stream && desired.mode_16plus === cur.mode_16plus) {
|
||
saved.textContent = "Без изменений";
|
||
return;
|
||
}
|
||
const pwd = prompt("Изменение этих настроек требует пароль:");
|
||
if (pwd === null) { // отмена — вернуть тумблеры
|
||
single.checked = cur.single_main_stream; if (m16) m16.checked = cur.mode_16plus; return;
|
||
}
|
||
const r = await fetch("/api/optimization", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...desired, password: pwd }),
|
||
});
|
||
if (!r.ok) {
|
||
single.checked = cur.single_main_stream; if (m16) m16.checked = cur.mode_16plus;
|
||
const d = await r.json().catch(() => ({}));
|
||
saved.textContent = d.detail || ("Ошибка " + r.status);
|
||
return;
|
||
}
|
||
cur = desired;
|
||
saved.textContent = "Сохранено ✓ (применится при открытии Live view)";
|
||
clearTimeout(this._to);
|
||
this._to = setTimeout(() => { saved.textContent = "Смена требует пароль"; }, 3000);
|
||
};
|
||
},
|
||
|
||
async loadStorage() {
|
||
const s = await api("/api/system");
|
||
const sel = document.getElementById("segment-size");
|
||
const saved = document.getElementById("segment-saved");
|
||
const btn = document.getElementById("segment-save");
|
||
sel.value = String(s.segment_seconds);
|
||
btn.onclick = async () => {
|
||
btn.disabled = true; saved.textContent = "Сохранение…";
|
||
try {
|
||
const r = await fetch("/api/storage", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ segment_seconds: +sel.value }),
|
||
});
|
||
if (r.ok) {
|
||
saved.textContent = "Сохранено ✓ рекордеры перезапущены";
|
||
setTimeout(() => { saved.textContent = ""; }, 3000);
|
||
} else {
|
||
const d = await r.json().catch(() => ({}));
|
||
saved.textContent = d.detail || ("Ошибка " + r.status);
|
||
}
|
||
} finally { btn.disabled = false; }
|
||
};
|
||
},
|
||
|
||
trRowHtml(pr) { // ряд параметров одного профиля
|
||
const sel = (f, opts) => `<select data-f="${f}">` + opts.map(([v, t]) =>
|
||
`<option value="${v}"${String(pr[f]) === String(v) ? " selected" : ""}>${t}</option>`).join("") + `</select>`;
|
||
// «Готовый профиль» SOFT/MEDIUM/HARD — выпадающее меню; выставляет все параметры разом
|
||
const qmatch = matchPreset(pr);
|
||
const qcol = (v) => TR_PRESET_COLORS[v] ? ` style="color:${TR_PRESET_COLORS[v]}"` : "";
|
||
const qpresetSel = `<select class="qpreset"${qcol(qmatch)}>` +
|
||
[["", "— свой —"]].concat(Object.keys(TR_PRESETS).map((k) => [k, k])).map(([v, t]) =>
|
||
`<option value="${v}"${v === qmatch ? " selected" : ""}${qcol(v)}>${t}</option>`).join("") + `</select>`;
|
||
return (
|
||
`<div class="tr-params">` +
|
||
`<label>Кодек${sel("codec", [["h264", "H.264"], ["vp9", "VP9"]])}</label>` +
|
||
`<label>Профиль H.264${sel("profile", [["baseline", "baseline"], ["main", "main"], ["high", "high"]])}</label>` +
|
||
`<label>Пресет (Preset)${sel("preset", [["ultrafast", "Ultrafast"], ["superfast", "Superfast"], ["veryfast", "Veryfast"], ["faster", "Faster"], ["fast", "Fast"], ["medium", "Medium"]])}</label>` +
|
||
`<label>Контроль битрейта${sel("rc", [["vbr", "VBR (переменный)"], ["cbr", "CBR (постоянный)"], ["qp", "QP (постоянный)"]])}</label>` +
|
||
`<label>Битрейт${sel("bitrate", [["500k", "0,5 Мбит/с"], ["1000k", "1 Мбит/с"], ["2000k", "2 Мбит/с"], ["4000k", "4 Мбит/с"], ["8000k", "8 Мбит/с"], ["10000k", "10 Мбит/с"], ["16000k", "16 Мбит/с"], ["20000k", "20 Мбит/с"], ["24000k", "24 Мбит/с"]])}</label>` +
|
||
`<label>QP (квантизатор)<input type="number" data-f="qp" min="0" max="51" value="${pr.qp}"></label>` +
|
||
`<label>Интервал ключевых кадров${sel("keyint", [[1, "1 секунда"], [2, "2 секунды"], [5, "5 секунд"]])}</label>` +
|
||
`</div>` +
|
||
`<div class="tr-quality"><label>Готовый профиль${qpresetSel}</label></div>`
|
||
);
|
||
},
|
||
|
||
loadPrefs() {
|
||
// 3 независимых профиля (1 поток / 2 поток / архив), у каждого свой режим:
|
||
// «Использовать свой плеер» (клиентский WASM-декод) или «Перекодировать» (сервер)
|
||
const saved = document.getElementById("prefs-saved");
|
||
["1", "2", "a"].forEach((which) => {
|
||
const block = document.querySelector(`.tr-block[data-tr="${which}"]`);
|
||
if (!block) return;
|
||
const pr = transcodeProfile(which);
|
||
const row = block.querySelector(".tr-row");
|
||
row.innerHTML = this.trRowHtml(pr);
|
||
// режим: проставить выбранный radio
|
||
const radios = block.querySelectorAll(`input[name="mode-${which}"]`);
|
||
radios.forEach((r) => { r.checked = (r.value === pr.mode); });
|
||
// показывать ряд параметров только в режиме «Перекодировать»
|
||
const syncMode = () => {
|
||
const sel = block.querySelector(`input[name="mode-${which}"]:checked`);
|
||
row.hidden = !(sel && sel.value === "transcode");
|
||
};
|
||
radios.forEach((r) => { r.onchange = syncMode; });
|
||
syncMode();
|
||
// профиль H.264 недоступен для VP9
|
||
const codec = row.querySelector('[data-f="codec"]');
|
||
const prof = row.querySelector('[data-f="profile"]');
|
||
const syncCodec = () => { prof.disabled = codec.value === "vp9"; };
|
||
// QP ↔ битрейт: активен только параметр выбранного режима, второй — неактивен
|
||
const rcSel = row.querySelector('[data-f="rc"]');
|
||
const brSel = row.querySelector('[data-f="bitrate"]');
|
||
const qpInp = row.querySelector('[data-f="qp"]');
|
||
const syncRc = () => { const isQp = rcSel.value === "qp"; brSel.disabled = isQp; qpInp.disabled = !isQp; };
|
||
// «Готовый профиль» (SOFT/MEDIUM/HARD): меню показывает совпадение или «— свой —»
|
||
const qpreset = row.querySelector(".qpreset");
|
||
const syncQuality = () => {
|
||
qpreset.value = matchPreset({ codec: codec.value, profile: prof.value, bitrate: brSel.value,
|
||
keyint: row.querySelector('[data-f="keyint"]').value, preset: row.querySelector('[data-f="preset"]').value });
|
||
qpreset.style.color = TR_PRESET_COLORS[qpreset.value] || "";
|
||
};
|
||
qpreset.onchange = () => {
|
||
qpreset.style.color = TR_PRESET_COLORS[qpreset.value] || "";
|
||
const p = TR_PRESETS[qpreset.value];
|
||
if (!p) return;
|
||
const put = (f, v) => { const s = row.querySelector(`[data-f="${f}"]`); if (s) s.value = String(v); };
|
||
put("codec", p.codec); put("profile", p.profile); put("bitrate", p.bitrate);
|
||
put("keyint", p.keyint); put("preset", p.preset); put("rc", "vbr");
|
||
syncCodec(); syncRc();
|
||
};
|
||
syncCodec(); syncRc();
|
||
row.querySelectorAll("select[data-f]").forEach((s) => {
|
||
s.addEventListener("change", () => { syncCodec(); syncRc(); syncQuality(); });
|
||
});
|
||
});
|
||
document.getElementById("prefs-save").onclick = () => {
|
||
["1", "2", "a"].forEach((which) => {
|
||
const block = document.querySelector(`.tr-block[data-tr="${which}"]`);
|
||
if (!block) return;
|
||
const row = block.querySelector(".tr-row");
|
||
const get = (f) => { const s = row.querySelector(`[data-f="${f}"]`); return s ? s.value : ""; };
|
||
const sel = block.querySelector(`input[name="mode-${which}"]:checked`);
|
||
setProfilePref(which, {
|
||
mode: sel ? sel.value : "player",
|
||
codec: get("codec"), profile: get("profile"), bitrate: get("bitrate"),
|
||
preset: get("preset"), keyint: +get("keyint"),
|
||
rc: get("rc"), qp: +get("qp"),
|
||
});
|
||
});
|
||
saved.textContent = "Сохранено в этом браузере ✓";
|
||
clearTimeout(this._t);
|
||
this._t = setTimeout(() => { saved.textContent = "Сохраняется в этом браузере"; }, 2000);
|
||
};
|
||
},
|
||
async loadSystem() {
|
||
const s = await api("/api/system");
|
||
document.getElementById("storage-kv").innerHTML =
|
||
this.kv("Путь записи", s.storage_path) +
|
||
this.kv("Длина сегмента", Math.round(s.segment_seconds / 60) + " мин (" + s.segment_seconds + " c)") +
|
||
this.kv("Квота хранилища", s.quota_gb + " GB") +
|
||
this.kv("Срок хранения", s.retention_days + " дней") +
|
||
this.kv("Объём архива", fmtSize(s.archive_size_bytes));
|
||
document.getElementById("system-kv").innerHTML =
|
||
this.kv("Аптайм сервиса", fmtUptime(s.uptime_s)) +
|
||
this.kv("CPU", s.cpu_percent + " %") +
|
||
this.kv("RAM", s.ram_percent + " % (" + s.ram_used_gb + " / " + s.ram_total_gb + " GB)") +
|
||
this.kv("Диск", s.disk.used_gb + " / " + s.disk.total_gb + " GB (" + s.disk.percent + " %)");
|
||
},
|
||
kv(k, v) { return `<div class="k">${k}</div><div class="v">${v}</div>`; },
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница РАСКЛАДКА (назначение камер по ячейкам)
|
||
// ════════════════════════════════════════════════════════════════
|
||
const LayoutEditor = {
|
||
cameras: [], dim: 2,
|
||
|
||
async init() {
|
||
const data = await api("/api/cameras");
|
||
this.cameras = data.cameras;
|
||
let opt = {};
|
||
try { opt = await api("/api/optimization"); } catch (e) { /* дефолты */ }
|
||
this.maxDim = opt.mode_16plus ? 8 : 4;
|
||
const sel = document.getElementById("layout-dim");
|
||
[...sel.options].forEach((o) => { o.hidden = +o.value > this.maxDim; });
|
||
this.dim = Math.min(this.maxDim, Math.max(2, parseInt(localStorage.getItem("corevision.grid"), 10) || 2));
|
||
sel.value = String(this.dim);
|
||
sel.onchange = () => { this.dim = +sel.value; this.render(); };
|
||
document.getElementById("layout-save").onclick = () => this.save();
|
||
this.render();
|
||
},
|
||
|
||
render() {
|
||
const g = document.getElementById("layout-grid");
|
||
g.style.gridTemplateColumns = `repeat(${this.dim}, 1fr)`;
|
||
const saved = getLayout(this.dim) || [];
|
||
const optionsFor = (cur) =>
|
||
`<option value="">— пусто —</option>` +
|
||
this.cameras.map((c) =>
|
||
`<option value="${c.id}"${cur === c.id ? " selected" : ""}>${c.name}</option>`).join("");
|
||
let html = "";
|
||
for (let i = 0; i < this.dim * this.dim; i++) {
|
||
html += `<div class="lcell"><div class="lcell-no">Ячейка #${i + 1}</div>` +
|
||
`<select data-idx="${i}">${optionsFor(saved[i] || "")}</select></div>`;
|
||
}
|
||
g.innerHTML = html;
|
||
},
|
||
|
||
save() {
|
||
const arr = [];
|
||
document.querySelectorAll("#layout-grid select").forEach((s) => { arr[+s.dataset.idx] = s.value || null; });
|
||
setLayout(this.dim, arr);
|
||
localStorage.setItem("corevision.grid", String(this.dim));
|
||
const el = document.getElementById("layout-saved");
|
||
el.textContent = "Сохранено ✓ — откройте Live view";
|
||
setTimeout(() => { el.textContent = ""; }, 2500);
|
||
},
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// Страница НАСТРОЙКИ — вкладка ПОЛЬЗОВАТЕЛИ (только суперадмин)
|
||
// ════════════════════════════════════════════════════════════════
|
||
const Users = {
|
||
adding: false,
|
||
|
||
async init() {
|
||
const add = document.getElementById("add-user");
|
||
if (add) add.onclick = () => this.beginAdd();
|
||
await this.load();
|
||
},
|
||
|
||
roleOptions(cur) {
|
||
return ["operator", "admin", "superadmin"].map((r) =>
|
||
`<option value="${r}"${r === cur ? " selected" : ""}>${ROLE_RU[r]}</option>`).join("");
|
||
},
|
||
|
||
msg(text) {
|
||
const m = document.getElementById("users-msg");
|
||
if (m) { m.textContent = text || ""; if (text) clearTimeout(this._t), this._t = setTimeout(() => m.textContent = "", 3000); }
|
||
},
|
||
|
||
beginAdd() {
|
||
if (this.adding) return;
|
||
this.adding = true;
|
||
const tb = document.getElementById("user-rows");
|
||
const tr = document.createElement("tr");
|
||
tr.className = "cam-edit-row";
|
||
tr.innerHTML =
|
||
`<td><input type="text" id="nu-name" placeholder="логин"></td>` +
|
||
`<td><select id="nu-role">${this.roleOptions("operator")}</select></td>` +
|
||
`<td><input type="password" id="nu-pass" placeholder="пароль"></td>` +
|
||
`<td class="actions"><button data-act="save-new">Создать</button> ` +
|
||
`<button data-act="cancel-new">✕</button></td>`;
|
||
tb.insertBefore(tr, tb.firstChild);
|
||
const f = document.getElementById("nu-name"); if (f) f.focus();
|
||
},
|
||
|
||
async load() {
|
||
let data;
|
||
try { data = await api("/api/users"); } catch (e) { return; }
|
||
const me = CURRENT_USER ? CURRENT_USER.username : "";
|
||
const tb = document.getElementById("user-rows");
|
||
tb.innerHTML = "";
|
||
data.users.forEach((u) => {
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.u = u.username;
|
||
const selfTag = u.username === me ? ' <span class="muted">(вы)</span>' : "";
|
||
tr.innerHTML =
|
||
`<td>${u.username}${selfTag}</td>` +
|
||
`<td><select data-role="${u.username}">${this.roleOptions(u.role)}</select></td>` +
|
||
`<td><input type="password" data-pass="${u.username}" placeholder="новый пароль"></td>` +
|
||
`<td class="actions">` +
|
||
`<button data-act="save-user" data-u="${u.username}">Сохранить</button> ` +
|
||
`<button data-act="del-user" data-u="${u.username}">✕</button>` +
|
||
`</td>`;
|
||
tb.appendChild(tr);
|
||
});
|
||
|
||
tb.onclick = async (e) => {
|
||
const btn = e.target.closest("button");
|
||
if (!btn) return;
|
||
const act = btn.dataset.act;
|
||
if (act === "save-new") {
|
||
const name = document.getElementById("nu-name").value.trim();
|
||
const role = document.getElementById("nu-role").value;
|
||
const pass = document.getElementById("nu-pass").value;
|
||
const r = await fetch("/api/users", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username: name, password: pass, role }),
|
||
});
|
||
if (r.ok) { this.adding = false; await this.load(); this.msg("Пользователь создан ✓"); }
|
||
else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); }
|
||
} else if (act === "cancel-new") {
|
||
this.adding = false; await this.load();
|
||
} else if (act === "save-user") {
|
||
const u = btn.dataset.u;
|
||
const role = document.querySelector(`select[data-role="${u}"]`).value;
|
||
const pass = document.querySelector(`input[data-pass="${u}"]`).value;
|
||
const r = await fetch(`/api/users/${encodeURIComponent(u)}`, {
|
||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ role, password: pass }),
|
||
});
|
||
if (r.ok) { await this.load(); this.msg("Сохранено ✓"); }
|
||
else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); }
|
||
} else if (act === "del-user") {
|
||
const u = btn.dataset.u;
|
||
if (!confirm(`Удалить пользователя «${u}»?`)) return;
|
||
const r = await fetch(`/api/users/${encodeURIComponent(u)}`, { method: "DELETE" });
|
||
if (r.ok) { await this.load(); this.msg("Пользователь удалён"); }
|
||
else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); }
|
||
}
|
||
};
|
||
},
|
||
};
|
||
|
||
// ── показ/скрытие элементов по роли (data-min-role) ─────────────
|
||
function applyRoleVisibility() {
|
||
document.querySelectorAll("[data-min-role]").forEach((el) => {
|
||
if (!roleAtLeast(el.dataset.minRole)) { el.hidden = true; el.dataset.roleHidden = "1"; }
|
||
});
|
||
const visTabs = [...document.querySelectorAll(".tab")].filter((t) => t.dataset.roleHidden !== "1");
|
||
let active = document.querySelector(".tab.active");
|
||
if (!active || active.dataset.roleHidden === "1") active = visTabs[0];
|
||
if (!active) return;
|
||
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === active));
|
||
document.querySelectorAll(".tab-panel").forEach((p) => {
|
||
p.hidden = (p.dataset.panel !== active.dataset.tab) || (p.dataset.roleHidden === "1");
|
||
});
|
||
}
|
||
|
||
// ── вкладки на странице настроек ────────────────────────────────
|
||
function initTabs() {
|
||
const tabs = document.querySelectorAll(".tab");
|
||
const panels = document.querySelectorAll(".tab-panel");
|
||
tabs.forEach((t) => {
|
||
t.onclick = () => {
|
||
tabs.forEach((x) => x.classList.toggle("active", x === t));
|
||
panels.forEach((p) => { p.hidden = p.dataset.panel !== t.dataset.tab; });
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── загрузка страницы ───────────────────────────────────────────
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
startClock();
|
||
bindLogout();
|
||
ensureVersionEl();
|
||
refreshStats(); setInterval(refreshStats, 5000);
|
||
applyGridMenuLimit();
|
||
const page = document.body.dataset.page;
|
||
if (page === "live") Live.init();
|
||
else if (page === "archive") Archive.init();
|
||
else if (page === "cams") CamSettings.init();
|
||
else if (page === "layout") LayoutEditor.init();
|
||
else if (page === "settings") {
|
||
await fetchMe();
|
||
applyRoleVisibility();
|
||
initTabs();
|
||
CamSettings.init();
|
||
Settings.init();
|
||
LayoutEditor.init();
|
||
if (roleAtLeast("superadmin")) Users.init();
|
||
}
|
||
});
|