Files
CoRE.Vision/frontend/static/app.js
T
git_admin e2e03054ac ui: remove top header, move CPU/RAM/disk + clock into the footer
Header bar dropped from all pages; brand, system stats (#sys-stats) and clock
(#clock) now live in the 22px footer next to the version. .layout fills the
full height minus the footer. ensureFooter runs before startClock/refreshStats
so the moved elements exist when populated.

VERSION 0.0.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:56:59 +05:00

2073 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"); }
// Канал Hikvision (1-32) ↔ пути RTSP: канал N → осн. /Streaming/Channels/{N*100+1},
// суб /Streaming/Channels/{N*100+2} (101/102 = канал 1, 1101/1102 = канал 11, 3201/3202 = 32).
function channelFromPath(p) {
const m = /(\d+)\s*$/.exec(p || "");
const ch = m ? Math.floor(parseInt(m[1], 10) / 100) : 1;
return ch >= 1 ? ch : 1;
}
function mainPathForChannel(ch) { return `/Streaming/Channels/${ch * 100 + 1}`; }
function subPathForChannel(ch) { return `/Streaming/Channels/${ch * 100 + 2}`; }
function channelOptions(sel) {
let h = "";
for (let i = 1; i <= 32; i++) h += `<option value="${i}"${i === sel ? " selected" : ""}>${i}</option>`;
return h;
}
// ── шапка: часы + статистика + выход ────────────────────────────
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);
}
}
function ensureFooter() {
// футер 22px снизу — только на страницах с основной раскладкой (не на login)
if (document.querySelector("footer.app-footer") || !document.querySelector(".layout")) return;
const f = document.createElement("footer");
f.className = "app-footer";
f.innerHTML = `<span class="brand">CoRE<span>.Vision</span></span>` +
`<span class="spacer"></span>` +
`<span class="stats" id="sys-stats"></span>` +
`<span class="clock" id="clock"></span>` +
`<span id="footer-version"></span>`;
document.body.appendChild(f);
}
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>&nbsp;&nbsp;RAM <b>${s.ram_percent}%</b>` +
`&nbsp;&nbsp;Диск <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;
const fv = document.getElementById("footer-version");
if (fv && s.version) fv.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: {},
storages: [], // список хранилищ для выпадающего выбора в строке камеры
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();
try { this.storages = (await api("/api/storages")).storages || []; } catch (e) { this.storages = []; }
await this.load();
connectStatusWS(() => { if (!this.editing) this.load(); });
},
// выпадающий список хранилищ; "" = по умолчанию (активное)
storageOptions(sel) {
const v = (x) => this.esc(x);
let html = `<option value=""${!sel ? " selected" : ""}>По умолчанию</option>`;
let found = false;
(this.storages || []).forEach((s) => {
const isSel = s.id === sel; if (isSel) found = true;
html += `<option value="${v(s.id)}"${isSel ? " selected" : ""}>${v(s.name)}</option>`;
});
if (sel && !found) html += `<option value="${v(sel)}" selected>${v(sel)} (нет)</option>`;
return html;
},
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() {
this.bulk = true; this.editing = true; // пустые слоты тоже редактируемы → можно с 0 камер
this.setBulkButtons();
this.setBulkHeaders(true);
this.load();
},
// наименьший свободный id вида camN (id скрыт от пользователя, нужен как ключ записи/потока)
nextCamId(used) {
for (let n = 1; n <= 999; n++) {
const id = "cam" + n;
if (!used.has(id)) { used.add(id); return id; }
}
return "cam" + (used.size + 1);
},
async exitBulk() {
this.bulk = false; this.editing = false;
this.setBulkButtons();
this.setBulkHeaders(false);
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}`);
},
// ── строка камеры в режиме массового редактирования: те же колонки, но ячейки —
// редактируемые инпуты (НЕ отдельная форма). cam=null → пустой слот для новой камеры.
// Статус-колонка на время правки = логин/пароль.
bulkRowHtml(slotNo, cam) {
const v = (x) => this.esc(x);
const name = cam ? v(cam.name) : "";
const ip = cam ? v(cam.ip) : "";
const user = cam ? v(cam.user) : "admin";
const password = cam ? v(cam.password) : "";
const ch = cam ? channelFromPath(cam.main_path) : 1;
const enabled = cam ? cam.enabled : true;
const record = cam ? cam.record : true;
const storageId = cam ? cam.storage_id : "";
return (
`<td class="muted">${slotNo}</td>` +
`<td><input class="cell-in" type="text" data-f="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` +
`<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></td>` +
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` +
`<td><input type="checkbox" data-f="enabled" ${enabled ? "checked" : ""}></td>` +
`<td><input type="checkbox" data-f="record" ${record ? "checked" : ""}></td>` +
`<td><select class="stor-sel" data-f="storage_id">${this.storageOptions(storageId)}</select></td>` +
`<td class="cell-dual">` +
`<input class="cell-in" type="text" data-f="user" value="${user}" title="Логин">` +
`<input class="cell-in" type="password" data-f="password" value="${password}" title="Пароль" placeholder="пароль">` +
`</td>` +
`<td></td>`
);
},
// подмена заголовков колонок на время массового редактирования (Статус → Логин/Пароль)
setBulkHeaders(on) {
document.querySelectorAll("table.cams th[data-bulk-label]").forEach((th) => {
if (on) { if (th.dataset.orig == null) th.dataset.orig = th.textContent; th.textContent = th.dataset.bulkLabel; }
else if (th.dataset.orig != null) th.textContent = th.dataset.orig;
});
},
async saveBulk() {
const rows = document.querySelectorAll("#cam-rows tr.bulk-row");
const errors = [];
const used = new Set(this.cams.map((c) => c.id)); // занятые id (для генерации новых)
for (const tr of rows) {
const get = (f) => { const el = tr.querySelector(`[data-f="${f}"]`); return el ? el.value : ""; };
const ch = +get("channel") || 1;
const payload = {
name: get("name").trim(),
ip: get("ip").trim(),
user: get("user").trim(),
password: get("password"),
main_path: mainPathForChannel(ch),
sub_path: subPathForChannel(ch),
storage_id: get("storage_id"),
enabled: tr.querySelector('[data-f="enabled"]').checked,
record: tr.querySelector('[data-f="record"]').checked,
};
if (tr.dataset.id) { // существующая камера → PUT только при изменении
const id = tr.dataset.id;
const orig = this.byId[id] || {};
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.storage_id || "") !== (orig.storage_id || "") ||
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}`); }
} else { // пустой слот → создаём камеру, если заполнено имя/IP
if (!payload.name && !payload.ip) continue;
const id = this.nextCamId(used);
const r = await fetch("/api/cameras", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...payload }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`слот ${tr.dataset.slot}: ${d.detail || r.status}`); }
}
}
this.bulk = false; this.editing = false;
this.setBulkButtons();
this.setBulkHeaders(false);
await this.load();
if (errors.length) alert("Не сохранено:\n" + errors.join("\n"));
},
esc(s) { return String(s == null ? "" : s).replace(/"/g, "&quot;"); },
// ── редактор внутри строки таблицы ──
editRowHtml(slotNo, cam) {
const v = (x) => this.esc(x);
const isEdit = !!cam;
return (
`<td colspan="9" 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>Канал (поток)<select id="e-channel">${channelOptions(cam ? channelFromPath(cam.main_path) : 1)}</select></label>` +
`<label>Хранилище<select id="e-storage">${this.storageOptions(cam ? cam.storage_id : "")}</select></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: mainPathForChannel(+val("e-channel") || 1),
sub_path: subPathForChannel(+val("e-channel") || 1),
storage_id: val("e-storage"),
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) {
// массовое редактирование: ВСЕ 64 слота сразу редактируемые (пустые — для добавления)
for (let i = 0; i < this.SLOTS; i++) {
const c = cams[i] || null;
const tr = document.createElement("tr");
tr.className = "bulk-row";
if (c) tr.dataset.id = c.id; else tr.dataset.slot = i + 1;
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 = "канал " + channelFromPath(c.main_path);
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><select class="stor-sel" data-id="${c.id}" title="В какое хранилище писать">${this.storageOptions(c.storage_id)}</select></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="9" 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="muted">пусто</td>` +
`<td></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» (тумблеры) и «Хранилище» (select)
if (this.bulk) return; // в массовом режиме изменения копятся до «Сохранить»
const en = e.target.closest(".en-toggle");
const rec = e.target.closest(".rec-toggle");
const stor = e.target.closest(".stor-sel");
const ctrl = en || rec || stor;
if (!ctrl) return;
const id = ctrl.dataset.id;
let patch, revert;
if (stor) {
patch = { storage_id: stor.value };
const prev = (this.byId[id] && this.byId[id].storage_id) || "";
revert = () => { stor.value = prev; };
} else {
patch = en ? { enabled: ctrl.checked } : { record: ctrl.checked };
revert = () => { ctrl.checked = !ctrl.checked; };
}
ctrl.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) { revert(); 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) { revert(); alert("Сбой запроса"); }
finally { ctrl.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()]);
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);
};
},
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 Storages = {
data: { storages: [], active: "", retention_days: 7, segment_seconds: 600 },
async init() {
const addBtn = document.getElementById("stor-add");
if (!addBtn) return; // нет прав / не на странице настроек
addBtn.onclick = () => this.toggleForm(true);
const cancel = document.getElementById("sf-cancel");
if (cancel) cancel.onclick = () => this.toggleForm(false);
const typeSel = document.getElementById("sf-type");
if (typeSel) typeSel.onchange = () => this.syncFormType();
const save = document.getElementById("sf-save");
if (save) save.onclick = () => this.create();
const pol = document.getElementById("pol-save");
if (pol) pol.onclick = () => this.savePolicy();
await this.load();
},
esc(s) { return String(s == null ? "" : s).replace(/"/g, "&quot;"); },
toggleForm(show) {
const card = document.getElementById("stor-form-card");
if (!card) return;
card.hidden = !show;
if (show) {
["sf-id", "sf-name", "sf-path", "sf-server", "sf-share", "sf-user", "sf-pass", "sf-subpath"]
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ""; });
document.getElementById("sf-quota").value = "0";
document.getElementById("sf-type").value = "local";
document.getElementById("sf-err").textContent = "";
document.getElementById("sf-mount").textContent = "";
this.syncFormType();
document.getElementById("sf-id").focus();
}
},
syncFormType() {
const nas = document.getElementById("sf-type").value === "nas";
document.querySelectorAll(".sf-nas").forEach((el) => { el.hidden = !nas; });
document.querySelectorAll(".sf-local").forEach((el) => { el.hidden = nas; });
},
async load() {
let d;
try { d = await api("/api/storages"); } catch (e) { return; }
this.data = d;
this.render();
const seg = document.getElementById("pol-segment");
if (seg) seg.value = String(d.segment_seconds);
const ret = document.getElementById("pol-retention");
if (ret) ret.value = String(d.retention_days);
},
render() {
const tb = document.getElementById("stor-rows");
if (!tb) return;
const v = (x) => this.esc(x);
tb.innerHTML = "";
(this.data.storages || []).forEach((s) => {
const tr = document.createElement("tr");
tr.dataset.id = s.id;
const typeRu = s.type === "nas" ? "NAS" : "локал";
const used = fmtSize(s.used_bytes || 0);
const disk = s.disk ? `${s.disk.used_gb} / ${s.disk.total_gb} ГБ` : "—";
const statusCell = s.type === "nas"
? `<span class="badge"><span class="${dotClass(s.mounted ? "online" : "offline")}"></span>${s.mounted ? "смонтировано" : "не смонтировано"}</span>`
: `<span class="muted">—</span>`;
const activeCell = s.active
? `<span class="badge badge-active">активно</span>`
: `<button data-act="activate" data-id="${v(s.id)}">Выбрать</button>`;
tr.innerHTML =
`<td>${activeCell}</td>` +
`<td class="muted">${v(s.id)}</td>` +
`<td>${v(s.name)}</td>` +
`<td class="muted">${typeRu}</td>` +
`<td class="muted" title="${v(s.path)}">${v(s.path)}</td>` +
`<td><span class="q-cell"><input type="number" class="q-input" data-id="${v(s.id)}" min="0" value="${s.quota_gb}">` +
`<button data-act="save-quota" data-id="${v(s.id)}" title="Сохранить квоту">✓</button></span></td>` +
`<td class="muted">${used} / ${disk}</td>` +
`<td>${statusCell}</td>` +
`<td class="actions">${s.active ? "" : `<button data-act="delete" data-id="${v(s.id)}" title="Убрать из списка (файлы не удаляются)">✕</button>`}</td>`;
tb.appendChild(tr);
});
tb.onclick = (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const id = btn.dataset.id, act = btn.dataset.act;
if (act === "activate") this.activate(id);
else if (act === "delete") this.remove(id);
else if (act === "save-quota") this.saveQuota(id);
};
},
async activate(id) {
const s = (this.data.storages || []).find((x) => x.id === id);
if (s && s.type === "nas" && !s.mounted &&
!confirm("NAS не смонтирован. Запись начнётся только после монтирования на хосте. Всё равно сделать активным?")) return;
const r = await fetch(`/api/storages/${id}/activate`, { method: "POST" });
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
await this.load();
this.msg("Активное хранилище изменено — рекордеры перезапущены");
},
async remove(id) {
if (!confirm(`Убрать хранилище «${id}» из списка?\nФайлы записей НЕ удаляются — архив сохранится.`)) return;
const r = await fetch(`/api/storages/${id}`, { method: "DELETE" });
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
await this.load();
},
async saveQuota(id) {
const inp = document.querySelector(`.q-input[data-id="${CSS.escape(id)}"]`);
if (!inp) return;
const q = Math.max(0, parseInt(inp.value, 10) || 0);
const r = await fetch(`/api/storages/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quota_gb: q }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
this.msg(`Квота «${id}»: ${q ? q + " ГБ" : "без лимита"}`);
await this.load();
},
async create() {
const val = (id) => { const el = document.getElementById(id); return el ? el.value : ""; };
const type = val("sf-type");
const payload = {
id: val("sf-id").trim(), name: val("sf-name").trim(), type,
quota_gb: Math.max(0, parseInt(val("sf-quota"), 10) || 0),
};
if (type === "local") {
const p = val("sf-path").trim(); if (p) payload.path = p;
} else {
payload.server = val("sf-server").trim();
payload.share = val("sf-share").trim();
payload.username = val("sf-user").trim();
payload.password = val("sf-pass");
payload.subpath = val("sf-subpath").trim();
}
const err = document.getElementById("sf-err"); err.textContent = "";
const r = await fetch("/api/storages", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { err.textContent = d.detail || ("Ошибка " + r.status); return; }
if (d.mount_help) {
document.getElementById("sf-mount").textContent =
"Хранилище создано. Смонтируйте SMB на ХОСТЕ (.79):\n\n" +
d.mount_help.mount_cmd + "\n\nАвтомонтаж — строка в /etc/fstab:\n" +
d.mount_help.fstab + "\n\n" + (d.mount_help.note || "");
this.msg("NAS-хранилище создано — смонтируйте том на хосте");
} else {
this.toggleForm(false);
}
await this.load();
},
async savePolicy() {
const seg = +document.getElementById("pol-segment").value;
const ret = Math.max(0, parseInt(document.getElementById("pol-retention").value, 10) || 0);
const saved = document.getElementById("pol-saved"); saved.textContent = "Сохранение…";
const r = await fetch("/api/storages/policy", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ segment_seconds: seg, retention_days: ret }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { saved.textContent = d.detail || ("Ошибка " + r.status); return; }
saved.textContent = "Сохранено ✓ (смена длины сегмента перезапускает рекордеры)";
clearTimeout(this._t); this._t = setTimeout(() => { saved.textContent = ""; }, 4000);
await this.load();
},
msg(t) {
const m = document.getElementById("stor-msg"); if (!m) return;
m.textContent = t; clearTimeout(this._m); this._m = setTimeout(() => { m.textContent = ""; }, 4000);
},
};
// ════════════════════════════════════════════════════════════════
// Страница РАСКЛАДКА (назначение камер по ячейкам)
// ════════════════════════════════════════════════════════════════
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 () => {
ensureVersionEl();
ensureFooter(); // создаёт футер с #sys-stats/#clock до их заполнения
startClock();
bindLogout();
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();
if (roleAtLeast("admin")) Storages.init();
LayoutEditor.init();
if (roleAtLeast("superadmin")) Users.init();
}
});