"use strict"; // ── общие хелперы ─────────────────────────────────────────────── async function api(path) { const r = await fetch(path); if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); } if (!r.ok) throw new Error(path + " -> " + r.status); return r.json(); } function post(path) { return fetch(path, { method: "POST" }); } // ── клиентские настройки (этот браузер), localStorage ─────────── const PREFS_KEY = "corevision.prefs"; // mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2, rc: "vbr", qp: 23 }; const PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив // общие пресеты перекодирования — выставляют все параметры профиля разом const TR_PRESETS = { SOFT: { codec: "vp9", profile: "baseline", bitrate: "500k", keyint: 2, preset: "ultrafast" }, MEDIUM: { codec: "h264", profile: "baseline", bitrate: "8000k", keyint: 2, preset: "ultrafast" }, HARD: { codec: "h264", profile: "main", bitrate: "16000k", keyint: 1, preset: "superfast" }, }; const TR_PRESET_COLORS = { SOFT: "#e03131", MEDIUM: "#e9c46a", HARD: "#ffffff" }; // цветомаркировка профилей function matchPreset(pr) { // имя пресета, совпадающего с параметрами return Object.keys(TR_PRESETS).find((k) => { const p = TR_PRESETS[k]; return p.codec === pr.codec && p.bitrate === pr.bitrate && +p.keyint === +pr.keyint && p.preset === pr.preset && (pr.codec === "vp9" || p.profile === pr.profile); }) || ""; } const PREFS_DEFAULT = { prof1: { ...PROFILE_DEFAULT, mode: "player" }, // 1 поток (основной) prof2: { ...PROFILE_DEFAULT, mode: "player" }, // 2 поток (суб) profA: { ...PROFILE_DEFAULT, mode: "transcode" }, // архив }; // ── текущий пользователь / роли ───────────────────────────────── const ROLE_RANK = { operator: 1, admin: 2, superadmin: 3 }; const ROLE_RU = { operator: "оператор", admin: "админ", superadmin: "суперадмин" }; let CURRENT_USER = null; async function fetchMe() { try { CURRENT_USER = await api("/api/me"); } catch (e) { CURRENT_USER = null; } return CURRENT_USER; } function roleAtLeast(min) { const r = CURRENT_USER && CURRENT_USER.role; return !!r && (ROLE_RANK[r] || 0) >= (ROLE_RANK[min] || 99); } function clientPrefs() { try { return { ...PREFS_DEFAULT, ...(JSON.parse(localStorage.getItem(PREFS_KEY)) || {}) }; } catch (e) { return { ...PREFS_DEFAULT }; } } function setClientPref(key, val) { const p = clientPrefs(); p[key] = val; localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } function transcodeProfile(which) { // which: "1" | "2" | "a" const p = clientPrefs(); return { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}) }; } function setProfilePref(which, patch) { const p = clientPrefs(); p[PROF_KEY[which]] = { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}), ...patch }; localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } function transcodeQueryFor(which) { // строка запроса под профиль const e = encodeURIComponent, pr = transcodeProfile(which); return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` + `&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`; } function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode" const m = transcodeProfile(which).mode; return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player"; } // раскладки: маппинг камер по ячейкам, на размерность (этот браузер) function getLayout(dim) { try { return (JSON.parse(localStorage.getItem("corevision.layout")) || {})[dim] || null; } catch (e) { return null; } } function setLayout(dim, arr) { let m; try { m = JSON.parse(localStorage.getItem("corevision.layout")) || {}; } catch (e) { m = {}; } m[dim] = arr; localStorage.setItem("corevision.layout", JSON.stringify(m)); } const pad = (n) => String(n).padStart(2, "0"); function fmtTime(ts) { const d = new Date(ts * 1000); return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); } function fmtDur(s) { if (!s) return "—"; const m = Math.floor(s / 60), sec = Math.round(s % 60); return m + ":" + pad(sec); } function fmtSize(b) { if (!b) return "—"; const mb = b / 1048576; return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(0) + " MB"; } function fmtUptime(s) { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d) return `${d}д ${h}ч ${m}м`; if (h) return `${h}ч ${m}м`; return `${m}м`; } const STATE_RU = { online: "запись", retrying: "переподключение", offline: "выключена", starting: "запуск" }; function dotClass(state) { return "dot " + (state || "offline"); } // ── шапка: часы + статистика + выход ──────────────────────────── function startClock() { const el = document.getElementById("clock"); if (!el) return; const tick = () => { const d = new Date(); el.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); }; tick(); setInterval(tick, 1000); } function ensureVersionEl() { const aside = document.querySelector("aside"); if (aside && !document.getElementById("app-version")) { const d = document.createElement("div"); d.id = "app-version"; d.className = "app-version"; aside.appendChild(d); } } async function refreshStats() { const el = document.getElementById("sys-stats"); try { const s = await api("/api/system"); if (el) el.innerHTML = `CPU ${s.cpu_percent}% RAM ${s.ram_percent}%` + ` Диск ${s.disk.used_gb}/${s.disk.total_gb} GB`; const v = document.getElementById("app-version"); if (v && s.version) v.textContent = "v" + s.version; } catch (e) { /* нет связи / 401 — молчим */ } } // ограничение подменю раскладок (режим 16+) — на любой странице с .subnav async function applyGridMenuLimit() { const items = document.querySelectorAll(".subnav .subitem[data-grid]"); if (!items.length) return; let maxDim = 4; try { const opt = await api("/api/optimization"); maxDim = opt.mode_16plus ? 8 : 4; } catch (e) { /* нет связи — лимит 16 */ } items.forEach((a) => { a.hidden = +a.dataset.grid > maxDim; }); } function bindLogout() { const btn = document.getElementById("logout"); if (!btn) return; btn.onclick = async () => { await post("/api/logout"); location.href = "/login"; }; } // ── статусный WebSocket (общий) ───────────────────────────────── function connectStatusWS(onCameras) { const proto = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${proto}://${location.host}/api/ws`); ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "status") onCameras(msg.cameras); else if (msg.type === "camera") onCameras([msg.camera]); }; ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000); } // ── go2rtc плеер ──────────────────────────────────────────────── let _rtcLoaded = null; function loadRtcComponent(go2rtcUrl) { if (_rtcLoaded) return _rtcLoaded; _rtcLoaded = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = go2rtcUrl.replace(/\/$/, "") + "/video-rtc.js"; s.onload = resolve; s.onerror = () => reject(new Error("go2rtc video-rtc.js не загрузился")); document.head.appendChild(s); }); return _rtcLoaded; } // ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ── // Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен, // сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU). const HEVC_WORKER_URL = "/static/vendor/hevcjs/hevc-player-worker.js"; const HEVC_WASM_JS = "/static/vendor/hevcjs/hevc-decode.esm.js"; const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm"; class HevcWasmPlayer { constructor(box, wsUrl, useMain, onFail) { this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; this.closed = false; this.worker = null; this.ws = null; const cv = document.createElement("canvas"); cv.className = "wc-canvas"; // размер буфера рендера: main крупнее (для разворота на весь экран), sub мелкий cv.width = useMain ? 1600 : 640; cv.height = useMain ? 900 : 360; box.appendChild(cv); this.canvas = cv; let off; try { off = cv.transferControlToOffscreen(); } catch (e) { this._fail("offscreen"); return; } try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; } this.worker.onmessage = (e) => { const m = e.data; if (m.type === "ready") this._openWS(); else if (m.type === "err") console.warn("HEVC worker:", m.msg); }; this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); }; this.worker.postMessage( { type: "init", canvas: off, wasmUrl: HEVC_WASM_JS, wasmBinaryUrl: HEVC_WASM_BIN }, [off]); } _openWS() { if (this.closed) return; try { this.ws = new WebSocket(this.wsUrl); this.ws.binaryType = "arraybuffer"; this.ws.onmessage = (ev) => { if (!this.closed && this.worker) this.worker.postMessage({ type: "data", buf: ev.data }, [ev.data]); }; this.ws.onerror = () => { /**/ }; this.ws.onclose = () => { /**/ }; } catch (e) { /**/ } } _fail(reason) { if (this.closed) return; const f = this.onFail; this.onFail = null; this.destroy(); if (f) f(reason); } resize() { /* canvas масштабируется CSS (object-fit), буфер рендера фиксирован */ } destroy() { this.closed = true; try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ } this.ws = null; if (this.worker) { try { this.worker.postMessage({ type: "stop" }); } catch (e) { /**/ } try { this.worker.terminate(); } catch (e) { /**/ } this.worker = null; } } } function toggleFullscreen(el) { const fsEl = document.fullscreenElement || document.webkitFullscreenElement; if (fsEl) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } else if (el.requestFullscreen || el.webkitRequestFullscreen) { (el.requestFullscreen || el.webkitRequestFullscreen).call(el); } } // умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра let _hevc = null; function hevcSupported() { if (_hevc !== null) return _hevc; let ok = false; try { const v = document.createElement("video"); const t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); if (t === "probably" || t === "maybe") ok = true; if (!ok && window.MediaSource && MediaSource.isTypeSupported) { ok = MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') || MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"'); } } catch (e) { /* ignore */ } _hevc = ok; return ok; } // нативный плеер go2rtc (open-source video-rtc.js): MSE/WebRTC, поддерживает H.265 function makePlayer(go2rtcUrl, srcName, transcode) { const el = document.createElement("video-stream"); // mse раньше webrtc — в Chrome HEVC доступен только в MSE; webrtc — фолбэк (Safari) el.setAttribute("mode", transcode ? "webrtc,mse" : "mse,webrtc"); el.setAttribute("background", "false"); const base = go2rtcUrl.replace(/\/$/, ""); el.src = new URL(base + "/api/ws?src=" + encodeURIComponent(srcName)); return el; } // ── свой WebCodecs-плеер H.265 (аппаратный декод на GPU клиента, 0% CPU сервера) ── // есть ли в браузере аппаратный HEVC-декодер через WebCodecs (кэш Promise) let _hevcHw = null; function hevcHwSupported() { if (_hevcHw) return _hevcHw; _hevcHw = (async () => { if (!("VideoDecoder" in window) || !window.isSecureContext) return false; const cands = ["hvc1.1.6.L153.B0", "hev1.1.6.L153.B0", "hvc1.2.4.L153.B0"]; for (const codec of cands) { try { const r = await VideoDecoder.isConfigSupported( { codec, codedWidth: 3200, codedHeight: 1800, hardwareAcceleration: "prefer-hardware" }); if (r && r.supported) return true; } catch (e) { /* пробуем следующий */ } } return false; })(); return _hevcHw; } // строка кодека из HEVCDecoderConfigurationRecord (hvcC) function hevcCodecFromHvcC(hvcC) { try { const profileIdc = hvcC[1] & 0x1f; const tier = ((hvcC[1] >> 5) & 1) ? "H" : "L"; const level = hvcC[12] || 153; return `hvc1.${profileIdc || 1}.6.${tier}${level}.B0`; } catch (e) { return "hvc1.1.6.L153.B0"; } } // демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры class FlvHevcDemuxer { constructor({ onConfig, onSample }) { this.onConfig = onConfig; this.onSample = onSample; this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false; } push(ab) { const inc = new Uint8Array(ab); if (this.buf.length === 0) { this.buf = inc; } else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; } this._parse(); } _u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; } _parse() { const b = this.buf, len = b.length; if (!this.headerDone) { if (len < 9) return; const dataOffset = this._u32(5); // обычно 9 this.buf = b.slice(dataOffset); this.headerDone = true; return this._parse(); } let o = 0; while (true) { if (o + 4 + 11 > len) break; // 4 байта PrevTagSize + 11 байт TagHeader const ts = o + 4; const tagType = b[ts] & 0x1f; const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3]; const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize; if (bodyEnd > len) break; // тег ещё не дочитан if (tagType === 9) { const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6]; this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs); } o = bodyEnd; } if (o > 0) this.buf = b.slice(o); } _videoTag(body, tsMs) { if (body.length < 5) return; const b0 = body[0], isEx = (b0 & 0x80) !== 0; if (isEx) { const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f; const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]); if (fourcc !== "hvc1" && fourcc !== "hev1") return; let off = 5; if (packetType === 1) off += 3; // CompositionTime const payload = body.subarray(off); if (packetType === 0) this._config(payload); else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs); } else { const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f; if (codecId !== 12 && codecId !== 7) return; // 12=HEVC legacy, 7=AVC const pkt = body[1], payload = body.subarray(5); if (pkt === 0) this._config(payload); else if (pkt === 1) this._frames(payload, frameType === 1, tsMs); } } _config(hvcC) { if (this.gotConfig || hvcC.length < 13) return; this.gotConfig = true; this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC)); } _frames(payload, isKey, tsMs) { if (!this.gotConfig || payload.length === 0) return; this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 }); } } // плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → 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 → 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 = 'плеер не запустился'; }); }; 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 = `${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 = 'NO LINK'; } 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: своя панель управления над ── // (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 = '' + '' + '▶' + '' + '⏸' + '0:00' + '' + '0:00' + '1×' + '⛶' + ''; 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) => `${c.name}`).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 = 'За выбранный день записей нет'; 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 = `${pad(Math.floor(min / 60))}:${pad(min % 60)}`; 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 = `эфир`; track.appendChild(nl); } box.appendChild(track); // красный указатель, зафиксированный по центру (playhead) — лента ездит под ним const center = document.createElement("div"); center.className = "tl-center"; center.innerHTML = ""; box.appendChild(center); this.centerLabel = center.querySelector("span"); this.track = track; // начальная позиция центра: «эфир» (сегодня) или начало последней записи const last = this.segments[this.segments.length - 1]; this._setCenter(isToday ? now : last.started_at); this._bindTimeline(box); }, _tlX(t) { return ((t - this.dayStart) / 60) * this.PX_PER_MIN; }, _segAt(t) { return this.segments.find((s) => t >= s.started_at && t < s.started_at + (s.duration_s || 0)); }, _setCenter(t) { this.centerTime = Math.min(this.dayStart + 86400, Math.max(this.dayStart, t)); this._applyTl(); }, _applyTl() { if (!this.track) return; const box = this.track.parentElement; const off = box.clientWidth / 2 - this._tlX(this.centerTime); this.track.style.transform = `translateX(${off}px)`; if (this.centerLabel) this.centerLabel.textContent = fmtTime(this.centerTime); const cur = this._segAt(this.centerTime); if (this.segEls) this.segEls.forEach((el) => el.classList.toggle("active", el._seg === cur)); }, _seekToCenter() { const seg = this._segAt(this.centerTime); if (seg) { this.following = true; this.play(seg, Math.max(0, this.centerTime - seg.started_at)); } }, _bindTimeline(box) { const PX = this.PX_PER_MIN; let startX = 0, startCenter = 0, moved = false; box.onpointerdown = (e) => { if (e.button !== 0) return; this.dragging = true; this.following = false; moved = false; startX = e.clientX; startCenter = this.centerTime; box.classList.add("grabbing"); try { box.setPointerCapture(e.pointerId); } catch (er) { /**/ } }; box.onpointermove = (e) => { if (!this.dragging) return; const dx = e.clientX - startX; if (Math.abs(dx) > 3) moved = true; this._setCenter(startCenter - (dx / PX) * 60); // тащим вправо → раньше по времени }; const end = () => { if (!this.dragging) return; this.dragging = false; box.classList.remove("grabbing"); if (!moved) { // клик без перетаскивания → точку под курсором в центр const rect = box.getBoundingClientRect(); this._setCenter(startCenter + ((startX - (rect.left + rect.width / 2)) / PX) * 60); } this._seekToCenter(); }; box.onpointerup = end; box.onpointercancel = end; box.onwheel = (e) => { // прокрутка колесом — пан по времени e.preventDefault(); this.following = false; this._setCenter(this.centerTime + ((e.deltaX || e.deltaY) / PX) * 60); clearTimeout(this._wheelTimer); this._wheelTimer = setTimeout(() => this._seekToCenter(), 350); }; }, play(seg, offsetSec = 0, spIdx) { const tok = ++this._playTok; // токен: центр двигает только актуальный плеер this._curSeg = seg; const wrap = document.getElementById("player"); if (spIdx == null) spIdx = this._player ? this._player.spIdx : 1; // скорость сохраняется между фрагментами if (this._player) { try { this._player.destroy(); } catch (e) { /**/ } } const onEnded = () => { if (tok === this._playTok) this._playNext(seg); }; // авто-переход к следующей записи // лента следует за плеером: позицию в сегменте → абсолютное время → центр (если не тащим вручную) const onTime = (pos) => { if (tok === this._playTok && !this.dragging && this.following) this._setCenter(seg.started_at + pos); }; if (this.transcode) { // транскод-поток без Range → виртуальный таймлайн + серверный seek (-ss) this._player = new ArchivePlayer(wrap, { mode: "transcode", duration: seg.duration_s || 0, offset: offsetSec, spIdx, srcFor: (t) => `/api/recordings/${seg.id}/play.mp4?${transcodeQueryFor("a")}&start=${(t || 0).toFixed(3)}`, onEnded, onTime, }); } else { // сырой H.265-файл: нативная перемотка (нужен HW-декод) this._player = new ArchivePlayer(wrap, { mode: "file", duration: seg.duration_s || 0, offset: offsetSec, spIdx, src: `/api/recordings/${seg.id}/file`, onEnded, onTime, }); } }, // следующий сегмент по времени (непрерывный просмотр архива) _playNext(seg) { const next = this.segments.find((s) => s.started_at > seg.started_at); if (next) this.play(next, 0); }, }; // ════════════════════════════════════════════════════════════════ // Страница КАМЕРЫ (cam settings) // ════════════════════════════════════════════════════════════════ const CamSettings = { SLOTS: 64, editing: false, // идёт инлайн-редактирование — не перерисовываем по WS bulk: false, // режим массового редактирования всей таблицы cams: [], byId: {}, async init() { const add = document.getElementById("add-cam"); if (add) add.onclick = () => this.beginAddFirstEmpty(); const be = document.getElementById("bulk-edit"); if (be) be.onclick = () => this.bulk ? this.saveBulk() : this.enterBulk(); const bc = document.getElementById("bulk-cancel"); if (bc) bc.onclick = () => this.exitBulk(); const cc = document.getElementById("clear-cams"); if (cc) cc.onclick = () => this.clearAll(); await this.load(); connectStatusWS(() => { if (!this.editing) this.load(); }); }, setBulkButtons() { const be = document.getElementById("bulk-edit"); const bc = document.getElementById("bulk-cancel"); const add = document.getElementById("add-cam"); const cc = document.getElementById("clear-cams"); if (be) be.textContent = this.bulk ? "Сохранить" : "Изменить"; if (be) be.classList.toggle("primary", this.bulk); if (bc) bc.hidden = !this.bulk; if (add) add.hidden = this.bulk; // во время массового редактирования не добавляем if (cc) cc.hidden = this.bulk; }, enterBulk() { if (!this.cams.length) { alert("Нет камер для редактирования"); return; } this.bulk = true; this.editing = true; this.setBulkButtons(); this.load(); }, async exitBulk() { this.bulk = false; this.editing = false; this.setBulkButtons(); await this.load(); }, // удалить ВСЕ камеры (архив не трогается) — POST /api/cameras/clear async clearAll() { if (this.bulk) return; const n = this.cams.length; if (!n) { alert("Камер нет"); return; } if (!confirm(`Удалить ВСЕ камеры (${n})?\nЗаписи архива НЕ затрагиваются. Действие необратимо.`)) return; const r = await fetch("/api/cameras/clear", { method: "POST" }); if (!r.ok) { const d = await r.json().catch(() => ({})); alert("Не удалось очистить: " + (d.detail || r.status)); return; } const d = await r.json().catch(() => ({})); await this.load(); alert(`Удалено камер: ${d.removed != null ? d.removed : n}`); }, // ── строка камеры в режиме массового редактирования ── bulkRowHtml(slotNo, cam) { const v = (x) => this.esc(x); return ( `` + `#${slotNo} · ID ${v(cam.id)}` + `` + `Имя` + `IP` + `Логин` + `Пароль` + `Осн. поток` + `Субпоток` + `Вкл` + `REC` + `` + `` ); }, async saveBulk() { const grids = document.querySelectorAll(".cam-edit-grid[data-cam]"); const errors = []; for (const g of grids) { const id = g.dataset.cam; const orig = this.byId[id] || {}; const get = (f) => { const el = g.querySelector(`[data-f="${f}"]`); return el ? el.value : ""; }; const payload = { name: get("name").trim(), ip: get("ip").trim(), user: get("user").trim(), password: get("password"), main_path: get("main_path").trim(), sub_path: get("sub_path").trim(), enabled: g.querySelector('[data-f="enabled"]').checked, record: g.querySelector('[data-f="record"]').checked, }; const changed = payload.name !== orig.name || payload.ip !== orig.ip || payload.user !== orig.user || payload.password !== (orig.password || "") || payload.main_path !== orig.main_path || (payload.sub_path || "") !== (orig.sub_path || "") || payload.enabled !== orig.enabled || payload.record !== orig.record; if (!changed) continue; const r = await fetch(`/api/cameras/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!r.ok) { const d = await r.json().catch(() => ({})); errors.push(`${id}: ${d.detail || r.status}`); } } this.bulk = false; this.editing = false; this.setBulkButtons(); await this.load(); if (errors.length) alert("Не сохранено:\n" + errors.join("\n")); }, esc(s) { return String(s == null ? "" : s).replace(/"/g, """); }, // ── редактор внутри строки таблицы ── editRowHtml(slotNo, cam) { const v = (x) => this.esc(x); const isEdit = !!cam; return ( `` + `` + `ID` + `Имя` + `IP` + `Логин` + `Пароль` + `Осн. поток` + `Субпоток` + `Вкл` + `REC` + `` + `` + `` + `Сохранить` + `Отмена` + `` + `` ); }, beginEdit(tr, slotNo, cam) { this.editing = true; this.editId = cam ? cam.id : null; // убрать возможный лог-подстрочник занятой камеры if (tr.nextElementSibling && tr.nextElementSibling.querySelector(".logbox")) { tr.nextElementSibling.remove(); } tr.className = "cam-edit-row"; tr.innerHTML = this.editRowHtml(slotNo, cam); const focusEl = document.getElementById(cam ? "e-name" : "e-id"); if (focusEl) focusEl.focus(); }, beginAddFirstEmpty() { const tb = document.getElementById("cam-rows"); const empty = tb.querySelector("tr.slot-empty"); if (!empty) { alert("Все 64 слота заняты"); return; } const slotNo = +empty.firstElementChild.textContent; this.beginEdit(empty, slotNo, null); }, async save(tr) { const val = (id) => (document.getElementById(id) ? document.getElementById(id).value : ""); const payload = { id: val("e-id").trim(), name: val("e-name").trim(), ip: val("e-ip").trim(), user: val("e-user").trim(), password: val("e-pass"), main_path: val("e-main").trim(), sub_path: val("e-sub").trim(), enabled: document.getElementById("e-enabled").checked, record: document.getElementById("e-record").checked, }; const url = this.editId ? `/api/cameras/${this.editId}` : "/api/cameras"; const method = this.editId ? "PUT" : "POST"; try { const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (r.ok) { this.editing = false; await this.load(); } else { const d = await r.json().catch(() => ({})); document.getElementById("e-err").textContent = d.detail || ("Ошибка " + r.status); } } catch (e) { document.getElementById("e-err").textContent = "Сбой запроса"; } }, async remove(id, name) { if (!confirm(`Удалить камеру «${name}» (${id})? Записи в архиве сохранятся.`)) return; await fetch(`/api/cameras/${id}`, { method: "DELETE" }); await this.load(); }, async load() { const data = await api("/api/cameras"); const cams = data.cameras; this.cams = cams; this.byId = {}; cams.forEach((c) => { this.byId[c.id] = c; }); const tb = document.getElementById("cam-rows"); const openLogs = new Set( [...document.querySelectorAll(".logbox")].filter((b) => !b.hidden).map((b) => b.id) ); tb.innerHTML = ""; if (this.bulk) { // режим массового редактирования: только существующие камеры, форма на каждую cams.forEach((c, i) => { const tr = document.createElement("tr"); tr.className = "cam-edit-row"; tr.dataset.id = c.id; tr.innerHTML = this.bulkRowHtml(i + 1, c); tb.appendChild(tr); }); return; } for (let i = 0; i < this.SLOTS; i++) { const c = cams[i]; const tr = document.createElement("tr"); if (c) { const st = (c.status && c.status.state) || "offline"; const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : ""); const statusCell = c.enabled ? `${STATE_RU[st] || st}` : `выключена`; tr.dataset.id = c.id; tr.dataset.slot = i + 1; tr.innerHTML = `${i + 1}` + `${c.name}` + `${c.ip}` + `${streams}` + `` + `` + `${statusCell}` + `` + `Лог ` + `⟳ ` + `✕` + ``; tb.appendChild(tr); const logTr = document.createElement("tr"); logTr.className = "logrow"; const hid = openLogs.has("log-" + c.id) ? "" : "hidden"; logTr.innerHTML = ``; tb.appendChild(logTr); } else { tr.className = "slot-empty"; tr.dataset.slot = i + 1; tr.innerHTML = `${i + 1}` + `—————` + `пусто` + `+ Добавить`; tb.appendChild(tr); } } tb.onclick = async (e) => { const btn = e.target.closest("button"); if (!btn) return; const id = btn.dataset.id; const act = btn.dataset.act; const tr = btn.closest("tr"); const slotNo = tr && tr.dataset.slot ? +tr.dataset.slot : 0; if (act === "add") { this.beginEdit(tr, slotNo, null); } else if (act === "save-edit") { this.save(tr); } else if (act === "cancel-edit") { this.editing = false; await this.load(); } else if (act === "delete") { this.remove(id, this.byId[id] ? this.byId[id].name : id); } else if (act === "restart") { btn.disabled = true; btn.textContent = "…"; await post(`/api/cameras/${id}/restart`); setTimeout(() => this.load(), 1500); } else if (act === "log") { const box = document.getElementById("log-" + id); if (!box.hidden) { box.hidden = true; return; } const d = await api(`/api/cameras/${id}/log`); box.textContent = d.log.length ? d.log.join("\n") : "(лог пуст)"; box.hidden = false; } }; tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись) const en = e.target.closest(".en-toggle"); const cb = en || e.target.closest(".rec-toggle"); if (!cb) return; const id = cb.dataset.id; const patch = en ? { enabled: cb.checked } : { record: cb.checked }; cb.disabled = true; try { const r = await fetch(`/api/cameras/${id}/toggle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; } if (this.byId[id]) Object.assign(this.byId[id], patch); await this.load(); // перерисовать: REC зависит от состояния «Вкл» } catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); } finally { cb.disabled = false; } }; }, }; // ════════════════════════════════════════════════════════════════ // Страница НАСТРОЙКИ // ════════════════════════════════════════════════════════════════ const Settings = { async init() { const meEl = document.getElementById("me-info"); if (meEl && CURRENT_USER) meEl.innerHTML = `Вы вошли как ${CURRENT_USER.username} — ` + (ROLE_RU[CURRENT_USER.role] || CURRENT_USER.role); await Promise.all([this.loadSystem(), this.loadPrefs(), this.loadStorage()]); if (roleAtLeast("superadmin")) this.loadOptim(); setInterval(() => this.loadSystem(), 5000); }, async loadOptim() { // доп. настройки Live view — серверная политика; менять только суперадмин + пароль const single = document.getElementById("single-main"); const m16 = document.getElementById("mode-16plus"); const saved = document.getElementById("optim-saved"); if (!single) return; let cur = { single_main_stream: true, mode_16plus: false }; try { const opt = await api("/api/optimization"); cur = { single_main_stream: !!opt.single_main_stream, mode_16plus: !!opt.mode_16plus }; } catch (e) { /* нет связи — дефолты */ } single.checked = cur.single_main_stream; if (m16) m16.checked = cur.mode_16plus; document.getElementById("optim-save").onclick = async () => { const desired = { single_main_stream: single.checked, mode_16plus: m16 ? m16.checked : cur.mode_16plus }; if (desired.single_main_stream === cur.single_main_stream && desired.mode_16plus === cur.mode_16plus) { saved.textContent = "Без изменений"; return; } const pwd = prompt("Изменение этих настроек требует пароль:"); if (pwd === null) { // отмена — вернуть тумблеры single.checked = cur.single_main_stream; if (m16) m16.checked = cur.mode_16plus; return; } const r = await fetch("/api/optimization", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...desired, password: pwd }), }); if (!r.ok) { single.checked = cur.single_main_stream; if (m16) m16.checked = cur.mode_16plus; const d = await r.json().catch(() => ({})); saved.textContent = d.detail || ("Ошибка " + r.status); return; } cur = desired; saved.textContent = "Сохранено ✓ (применится при открытии Live view)"; clearTimeout(this._to); this._to = setTimeout(() => { saved.textContent = "Смена требует пароль"; }, 3000); }; }, async loadStorage() { const s = await api("/api/system"); const sel = document.getElementById("segment-size"); const saved = document.getElementById("segment-saved"); const btn = document.getElementById("segment-save"); sel.value = String(s.segment_seconds); btn.onclick = async () => { btn.disabled = true; saved.textContent = "Сохранение…"; try { const r = await fetch("/api/storage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ segment_seconds: +sel.value }), }); if (r.ok) { saved.textContent = "Сохранено ✓ рекордеры перезапущены"; setTimeout(() => { saved.textContent = ""; }, 3000); } else { const d = await r.json().catch(() => ({})); saved.textContent = d.detail || ("Ошибка " + r.status); } } finally { btn.disabled = false; } }; }, trRowHtml(pr) { // ряд параметров одного профиля const sel = (f, opts) => `` + opts.map(([v, t]) => `${t}`).join("") + ``; // «Готовый профиль» SOFT/MEDIUM/HARD — выпадающее меню; выставляет все параметры разом const qmatch = matchPreset(pr); const qcol = (v) => TR_PRESET_COLORS[v] ? ` style="color:${TR_PRESET_COLORS[v]}"` : ""; const qpresetSel = `` + [["", "— свой —"]].concat(Object.keys(TR_PRESETS).map((k) => [k, k])).map(([v, t]) => `${t}`).join("") + ``; return ( `` + `Кодек${sel("codec", [["h264", "H.264"], ["vp9", "VP9"]])}` + `Профиль H.264${sel("profile", [["baseline", "baseline"], ["main", "main"], ["high", "high"]])}` + `Пресет (Preset)${sel("preset", [["ultrafast", "Ultrafast"], ["superfast", "Superfast"], ["veryfast", "Veryfast"], ["faster", "Faster"], ["fast", "Fast"], ["medium", "Medium"]])}` + `Контроль битрейта${sel("rc", [["vbr", "VBR (переменный)"], ["cbr", "CBR (постоянный)"], ["qp", "QP (постоянный)"]])}` + `Битрейт${sel("bitrate", [["500k", "0,5 Мбит/с"], ["1000k", "1 Мбит/с"], ["2000k", "2 Мбит/с"], ["4000k", "4 Мбит/с"], ["8000k", "8 Мбит/с"], ["10000k", "10 Мбит/с"], ["16000k", "16 Мбит/с"], ["20000k", "20 Мбит/с"], ["24000k", "24 Мбит/с"]])}` + `QP (квантизатор)` + `Интервал ключевых кадров${sel("keyint", [[1, "1 секунда"], [2, "2 секунды"], [5, "5 секунд"]])}` + `` + `Готовый профиль${qpresetSel}` ); }, 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 `${k}${v}`; }, }; // ════════════════════════════════════════════════════════════════ // Страница РАСКЛАДКА (назначение камер по ячейкам) // ════════════════════════════════════════════════════════════════ 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) => `— пусто —` + this.cameras.map((c) => `${c.name}`).join(""); let html = ""; for (let i = 0; i < this.dim * this.dim; i++) { html += `Ячейка #${i + 1}` + `${optionsFor(saved[i] || "")}`; } 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) => `${ROLE_RU[r]}`).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 = `` + `${this.roleOptions("operator")}` + `` + `Создать ` + `✕`; 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 ? ' (вы)' : ""; tr.innerHTML = `${u.username}${selfTag}` + `${this.roleOptions(u.role)}` + `` + `` + `Сохранить ` + `✕` + ``; tb.appendChild(tr); }); tb.onclick = async (e) => { const btn = e.target.closest("button"); if (!btn) return; const act = btn.dataset.act; if (act === "save-new") { const name = document.getElementById("nu-name").value.trim(); const role = document.getElementById("nu-role").value; const pass = document.getElementById("nu-pass").value; const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: name, password: pass, role }), }); if (r.ok) { this.adding = false; await this.load(); this.msg("Пользователь создан ✓"); } else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); } } else if (act === "cancel-new") { this.adding = false; await this.load(); } else if (act === "save-user") { const u = btn.dataset.u; const role = document.querySelector(`select[data-role="${u}"]`).value; const pass = document.querySelector(`input[data-pass="${u}"]`).value; const r = await fetch(`/api/users/${encodeURIComponent(u)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role, password: pass }), }); if (r.ok) { await this.load(); this.msg("Сохранено ✓"); } else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); } } else if (act === "del-user") { const u = btn.dataset.u; if (!confirm(`Удалить пользователя «${u}»?`)) return; const r = await fetch(`/api/users/${encodeURIComponent(u)}`, { method: "DELETE" }); if (r.ok) { await this.load(); this.msg("Пользователь удалён"); } else { const d = await r.json().catch(() => ({})); this.msg(d.detail || ("Ошибка " + r.status)); } } }; }, }; // ── показ/скрытие элементов по роли (data-min-role) ───────────── function applyRoleVisibility() { document.querySelectorAll("[data-min-role]").forEach((el) => { if (!roleAtLeast(el.dataset.minRole)) { el.hidden = true; el.dataset.roleHidden = "1"; } }); const visTabs = [...document.querySelectorAll(".tab")].filter((t) => t.dataset.roleHidden !== "1"); let active = document.querySelector(".tab.active"); if (!active || active.dataset.roleHidden === "1") active = visTabs[0]; if (!active) return; document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === active)); document.querySelectorAll(".tab-panel").forEach((p) => { p.hidden = (p.dataset.panel !== active.dataset.tab) || (p.dataset.roleHidden === "1"); }); } // ── вкладки на странице настроек ──────────────────────────────── function initTabs() { const tabs = document.querySelectorAll(".tab"); const panels = document.querySelectorAll(".tab-panel"); tabs.forEach((t) => { t.onclick = () => { tabs.forEach((x) => x.classList.toggle("active", x === t)); panels.forEach((p) => { p.hidden = p.dataset.panel !== t.dataset.tab; }); }; }); } // ── загрузка страницы ─────────────────────────────────────────── document.addEventListener("DOMContentLoaded", async () => { startClock(); bindLogout(); ensureVersionEl(); refreshStats(); setInterval(refreshStats, 5000); applyGridMenuLimit(); const page = document.body.dataset.page; if (page === "live") Live.init(); else if (page === "archive") Archive.init(); else if (page === "cams") CamSettings.init(); else if (page === "layout") LayoutEditor.init(); else if (page === "settings") { await fetchMe(); applyRoleVisibility(); initTabs(); CamSettings.init(); Settings.init(); LayoutEditor.init(); if (roleAtLeast("superadmin")) Users.init(); } });