Files
CoRE.Vision/frontend/static/app.js
T
git_admin f263d84a8d player: extract branded .pjs player into shared static/player.{js,css}
One source of truth for the branded player on desktop (archive) and
mobile. The ArchivePlayer class moves out of app.js into player.js
(name kept; app.js still does `new ArchivePlayer`); the .pjs styles
move out of style.css into player.css. Both archive.html and mobile.html
load the shared module before their own script.

The shared player gains two additive, backward-compatible options:
- live: timeline hidden, only pause/play + fullscreen (mobile Live).
- autohide:false: control bar always visible (touch); desktop keeps
  autohide (default true), so desktop archive behaves exactly as before.
Fullscreen is now self-contained (no global toggleFullscreen dep) so it
works on mobile too, with an iOS <video> fallback.

mobile.js drops its duplicated Player class and uses ArchivePlayer.

Verified on .79: /archive 200 loads player.{js,css}; app.js has no
class def; mobile (Host :8443) loads the shared module.

VERSION 0.0.154 -> 0.0.155

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:28:04 +05:00

2116 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" }, // архив
liveAutostart: false, // live view: сразу показывать (true) или ждать Play (по умолч.)
};
// ── текущий пользователь / роли ─────────────────────────────────
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>.Vizion+</span></span>` +
`<span class="spacer"></span>` +
`<span class="stats" id="sys-stats"></span>` +
`<span class="clock" id="clock"></span>`;
document.body.appendChild(f);
}
function ensureHeader() {
// шапка 50px сверху с брендом по центру — на страницах с раскладкой (не на login)
if (document.querySelector("header.app-header") || !document.querySelector(".layout")) return;
const h = document.createElement("header");
h.className = "app-header";
h.innerHTML = `<span class="brand">CoRE<span>.Vizion+</span></span>`;
document.body.insertBefore(h, document.body.firstChild);
}
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;
} 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);
}
}
// Клавиша F — переключить полноэкранный режим фрейма (live-main / arch-content).
// Esc выходит нативно. e.code (физическая клавиша) — работает и в русской раскладке.
function bindFullscreenKey() {
document.addEventListener("keydown", (e) => {
if (e.code !== "KeyF" || e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const frame = document.getElementById("live-main") || document.getElementById("arch-content");
if (!frame) return;
e.preventDefault();
toggleFullscreen(frame);
});
}
// умеет ли браузер декодировать 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, page: 0, _fsBySolo: false,
async init() {
const data = await api("/api/cameras");
this.cameras = data.cameras;
this.playing = clientPrefs().liveAutostart === true; // по умолч. ждём Play; вкл — показываем сразу
// режим каждого потока (свой плеер / транскод) — настройка этого браузера
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], .gb-split-menu button[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));
this.page = 0; // смена размера сетки → первая страница
this.fillSelected(); // selected = камеры текущей страницы
document.querySelectorAll("#live-subnav .subitem, .subnav .subitem, .gb-split-menu button").forEach((a) =>
a.classList.toggle("active", +a.dataset.grid === this.dim));
if (rerender) this.renderGrid();
this.updatePager();
},
// число страниц: все камеры, разбитые по layout ячеек
pageCount() {
return Math.max(1, Math.ceil(this.cameras.length / this.layout));
},
// заполнить selected камерами текущей страницы; страница 0 уважает сохранённую раскладку
fillSelected() {
const pages = this.pageCount();
this.page = Math.min(Math.max(0, this.page), pages - 1);
const saved = getLayout(this.dim);
if (this.page === 0 && Array.isArray(saved) && saved.length === this.layout) {
this.selected = saved.slice();
return;
}
const start = this.page * this.layout;
this.selected = this.cameras.slice(start, start + this.layout).map((c) => c.id);
while (this.selected.length < this.layout) this.selected.push(undefined);
},
setPage(p) {
const pages = this.pageCount();
const np = Math.min(pages - 1, Math.max(0, p));
if (np === this.page) { this.updatePager(); return; }
this.page = np;
this.fillSelected();
this.renderGrid();
this.updatePager();
},
// состояние стрелок пагинации в футере (выкл на краях)
updatePager() {
const pages = this.pageCount();
const prev = document.getElementById("gb-prev");
const next = document.getElementById("gb-next");
if (prev) prev.disabled = this.page <= 0;
if (next) next.disabled = this.page >= pages - 1;
const bar = document.getElementById("grid-bar");
if (bar) bar.title = `Страница ${this.page + 1} из ${pages}`;
},
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("live-main") || document.getElementById("grid")); };
const pt = document.getElementById("play-toggle");
if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; }
// ── панель управления под сеткой (футер фрейма): ⏮ play/stop ⏭ + фулскрин ──
const fsMain = () => toggleFullscreen(document.getElementById("live-main") || document.getElementById("grid"));
const gbPrev = document.getElementById("gb-prev");
if (gbPrev) gbPrev.onclick = (e) => { e.preventDefault(); this.setPage(this.page - 1); };
const gbNext = document.getElementById("gb-next");
if (gbNext) gbNext.onclick = (e) => { e.preventDefault(); this.setPage(this.page + 1); };
const gbPlay = document.getElementById("gb-play");
if (gbPlay) gbPlay.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); };
const gbFs = document.getElementById("gb-fs");
if (gbFs) gbFs.onclick = (e) => { e.preventDefault(); fsMain(); };
// меню «Раскладка» (window split): 4/9/16/25/36/49/64 → setDim
const gbSplit = document.getElementById("gb-split");
const splitMenu = document.getElementById("gb-split-menu");
if (gbSplit && splitMenu) {
gbSplit.onclick = (e) => { e.preventDefault(); splitMenu.classList.toggle("open"); };
splitMenu.onclick = (e) => {
const b = e.target.closest("button[data-grid]");
if (!b) return;
e.preventDefault();
history.replaceState(null, "", "/?grid=" + b.dataset.grid);
this.setDim(+b.dataset.grid);
splitMenu.classList.remove("open");
};
document.addEventListener("click", (e) => { // клик вне меню — закрыть
if (!e.target.closest(".gb-split")) splitMenu.classList.remove("open");
});
}
this.updatePlayBtn();
this.updatePager();
},
bindFullscreenResize() {
if (this._fsBound) return;
this._fsBound = true;
const onFs = () => {
// выход из фуллскрина (в т.ч. по ESC/F) — сбросить флаг и «соло», вернуться к сетке
if (!(document.fullscreenElement || document.webkitFullscreenElement)) {
this._fsBySolo = false;
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);
}
const gb = document.getElementById("gb-play");
if (gb) {
gb.textContent = this.playing ? "⏸" : "▶";
gb.title = 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"));
},
// Двойной клик по камере.
// Режим 1 (из сетки): вход в фуллскрин этой камеры; повторный двойной клик — выход в live view.
// Режим 2 (уже в фуллскрине по F): соло ↔ сетка без выхода из фуллскрина (выход — Esc/F).
toggleSolo(tile) {
const grid = document.getElementById("grid");
const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
const wasSolo = grid.classList.contains("solo") && tile.classList.contains("solo");
this.clearSolo();
if (wasSolo) {
if (this._fsBySolo) { // режим 1: фуллскрин включён самим кликом → назад в live view
this._fsBySolo = false;
if (isFs) toggleFullscreen(document.getElementById("live-main") || grid);
} // режим 2 (вошли по F): остаёмся в фуллскрине — просто сетка
} else {
grid.classList.add("solo");
tile.classList.add("solo");
if (!isFs) { // режим 1: входим в фуллскрин этой камеры
this._fsBySolo = true;
toggleFullscreen(document.getElementById("live-main") || 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,
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
// Фирменный плеер ArchivePlayer (.pjs) вынесен в общий модуль static/player.js
// (подключается archive.html и mobile.html ПЕРЕД app.js / mobile.js).
const Archive = {
cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0,
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;
this.buildCamList();
const today = new Date();
this.date = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
this.calYear = today.getFullYear();
this.calMonth = today.getMonth();
const dp = document.getElementById("date");
if (dp) {
dp.value = this.date;
dp.onchange = () => { this.date = dp.value; this.syncCalToDate(); this.renderCalendar(); this.load(); };
}
this.bindCalendar();
this.renderCalendar();
const fsBtn = document.getElementById("arch-fs");
if (fsBtn) fsBtn.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("arch-content")); };
// при ресайзе окна пересчитать смещение ленты (центр зависит от ширины вьюпорта)
window.addEventListener("resize", () => { if (this.track) this._applyTl(); });
this.load();
},
// верхняя половина сайдбара: список камер (клик — выбор)
buildCamList() {
const box = document.getElementById("arch-cams");
if (!box) return;
box.innerHTML = this.cameras.map((c) =>
`<div class="arch-cam${c.id === this.cam ? " active" : ""}" data-cam="${c.id}">${c.name}</div>`
).join("");
box.onclick = (e) => {
const it = e.target.closest(".arch-cam[data-cam]");
if (!it) return;
this.cam = it.dataset.cam;
box.querySelectorAll(".arch-cam").forEach((x) => x.classList.toggle("active", x.dataset.cam === this.cam));
this.load();
};
},
syncCalToDate() {
const [y, m] = (this.date || "").split("-").map(Number);
if (y && m) { this.calYear = y; this.calMonth = m - 1; }
},
bindCalendar() {
const box = document.getElementById("calendar");
if (!box) return;
box.onclick = (e) => {
const nav = e.target.closest("[data-cal]");
if (nav) {
if (nav.dataset.cal === "prev") { if (--this.calMonth < 0) { this.calMonth = 11; this.calYear--; } }
else { if (++this.calMonth > 11) { this.calMonth = 0; this.calYear++; } }
this.renderCalendar();
return;
}
const day = e.target.closest(".cal-day[data-day]");
if (!day) return;
this.date = `${this.calYear}-${pad(this.calMonth + 1)}-${pad(+day.dataset.day)}`;
const dp = document.getElementById("date");
if (dp) dp.value = this.date;
this.renderCalendar();
this.load();
};
},
// нижняя половина сайдбара: месячный календарь
renderCalendar() {
const box = document.getElementById("calendar");
if (!box) return;
const MN = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"];
const WD = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const y = this.calYear, m = this.calMonth;
const [sy, sm, sd] = (this.date || "").split("-").map(Number);
const startDow = (new Date(y, m, 1).getDay() + 6) % 7; // 0 = понедельник
const days = new Date(y, m + 1, 0).getDate();
const t = new Date();
let html = `<div class="cal-head">` +
`<button class="cal-nav" data-cal="prev" title="Предыдущий месяц"></button>` +
`<span class="cal-title">${MN[m]} ${y}</span>` +
`<button class="cal-nav" data-cal="next" title="Следующий месяц"></button></div>`;
html += `<div class="cal-grid">` + WD.map((w) => `<div class="cal-wd">${w}</div>`).join("");
for (let i = 0; i < startDow; i++) html += `<div class="cal-day empty"></div>`;
for (let dd = 1; dd <= days; dd++) {
const sel = (y === sy && m === sm - 1 && dd === sd) ? " sel" : "";
const today = (y === t.getFullYear() && m === t.getMonth() && dd === t.getDate()) ? " today" : "";
html += `<div class="cal-day${sel}${today}" data-day="${dd}">${dd}</div>`;
}
box.innerHTML = html + `</div>`;
},
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 class="actions">` +
(cam
? `<button data-act="restart" data-id="${cam.id}" title="Перезапустить камеру">⟳</button> ` +
`<button data-act="delete" data-id="${cam.id}" title="Удалить камеру (записи архива сохранятся)">✕</button>`
: "") +
`</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");
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"></td>`;
tb.appendChild(tr);
} 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") {
const prev = btn.textContent; btn.disabled = true; btn.textContent = "…";
await post(`/api/cameras/${id}/restart`);
if (this.bulk) { // в массовом режиме строки не перерисовываем (правки не теряем)
btn.textContent = "✓";
setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 1500);
} else {
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-group">` +
`<div class="tr-gtitle">Кодек</div>` +
`<div class="tr-gbody">` +
`<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("keyint", [[1, "1 секунда"], [2, "2 секунды"], [5, "5 секунд"]])}</label>` +
`</div>` +
`</div>` +
`<div class="tr-group">` +
`<div class="tr-gtitle">Битрейт</div>` +
`<div class="tr-gbody">` +
`<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>` +
`</div>` +
`</div>` +
`<div class="tr-group tr-group-preset">` +
`<div class="tr-gtitle">Готовый профиль</div>` +
`<div class="tr-gbody"><label>${qpresetSel}</label></div>` +
`</div>`
);
},
loadPrefs() {
// 3 независимых профиля (1 поток / 2 поток / архив), у каждого свой режим:
// «Использовать свой плеер» (клиентский WASM-декод) или «Перекодировать» (сервер)
const saved = document.getElementById("prefs-saved");
// Live view: показывать сразу при входе или после нажатия Play (этот браузер)
const auto = document.getElementById("pref-autostart");
if (auto) {
auto.checked = clientPrefs().liveAutostart === true;
auto.onchange = () => {
setClientPref("liveAutostart", auto.checked);
if (saved) {
saved.textContent = "Сохранено в этом браузере ✓";
clearTimeout(this._t);
this._t = setTimeout(() => { saved.textContent = "Сохраняется в этом браузере"; }, 2000);
}
};
}
["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();
ensureHeader(); // шапка 50px с брендом по центру
ensureFooter(); // создаёт футер с #sys-stats/#clock до их заполнения
startClock();
bindLogout();
bindFullscreenKey();
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();
}
});