// CoRE.Vizion+ — мобильный dashboard. Оба режима через фирменный плеер (.pjs): // Live — только пауза/плей + во весь экран; Архив — перемотка + скорость. // Видео отдаётся сервером уже перекодированным в H.264 (играет на любом телефоне). "use strict"; // фиксированный профиль H.264 для телефона (макс. совместимость) const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23"; function pad(n) { return String(n).padStart(2, "0"); } function 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"); } async function api(url) { const r = await fetch(url, { credentials: "same-origin" }); if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); } if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); } // ── фирменный плеер (.pjs) ────────────────────────────────────────────── // opts: { live:bool, duration, srcFor(startSec)->url, src } // live — без таймлайна: только пауза/плей + fullscreen. // архив — серверный транскод без Range: виртуальный таймлайн на duration, // перемотка = перезапуск транскода с -ss (srcFor(t)). class Player { constructor(wrap, opts = {}) { this.live = !!opts.live; this.total = opts.duration || 0; this.srcFor = opts.srcFor || null; this.startOffset = 0; this.speeds = [0.5, 1, 2, 5]; this.spIdx = 1; this.dragging = false; this.dragP = 0; wrap.innerHTML = ""; const root = document.createElement("div"); root.className = "pjs" + (this.live ? " live" : ""); root.tabIndex = 0; root.innerHTML = '' + '
' + '
' + '' + '0:00' + '
' + '0:00' + '' + '' + '
'; 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.v.src = this.live ? opts.src : this.srcFor(0); this._bind(); this.v.play().catch(() => {}); } _total() { return this.total || 0; } _curTime() { return this.startOffset + (this.v.currentTime || 0); } _applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; } _seekToFrac(p) { const total = this._total(); if (!total || this.live) return; const t = Math.min(total, Math.max(0, p * total)); this.startOffset = t; // перезапуск транскода с нужной секунды this.v.src = this.srcFor(t); this.v.play().catch(() => {}); this._sync(); } _bind() { const v = this.v, root = this.root; v.addEventListener("loadedmetadata", () => { this.durEl.textContent = fmt(this._total()); this._applyRate(); }); v.addEventListener("timeupdate", () => this._sync()); v.addEventListener("progress", () => this._syncBuf()); v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); }); v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); }); v.addEventListener("waiting", () => root.classList.add("buffering")); v.addEventListener("playing", () => root.classList.remove("buffering")); const toggle = () => { if (v.paused) v.play().catch(() => {}); else v.pause(); }; this.playBtn.onclick = toggle; root.querySelector(".pjs-bigplay").onclick = toggle; v.onclick = toggle; 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 + "×"; }; if (!this.live) { // перемотка по шкале (тач): превью при drag, seek на отпускании const fracAt = (x) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (x - r.left) / r.width)); }; const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = 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); } }); } } _sync() { if (this.dragging || this.live) return; 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 = fmt(cur); this.durEl.textContent = fmt(total); } _syncBuf() { const v = this.v; if (this.live || !v.buffered.length) return; const total = this._total(); if (!total) return; const end = this.startOffset + v.buffered.end(v.buffered.length - 1); this.buf.style.width = Math.min(100, end / total * 100) + "%"; } _fs() { const r = this.root; if (document.fullscreenElement || document.webkitFullscreenElement) { (document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document); } else if (r.requestFullscreen) { r.requestFullscreen().catch(() => {}); } else if (r.webkitRequestFullscreen) { r.webkitRequestFullscreen(); } else if (this.v.webkitEnterFullscreen) { this.v.webkitEnterFullscreen(); } // iOS: только видео } destroy() { try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ } if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); } } const M = { cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null, async init() { let data; try { data = await api("/api/cameras"); } catch (e) { return; } this.cams = data.cameras || []; const enabled = this.cams.filter((c) => c.enabled); const lc = document.getElementById("m-live-cam"); const ac = document.getElementById("m-arch-cam"); lc.innerHTML = enabled.map((c) => ``).join(""); ac.innerHTML = this.cams.map((c) => ``).join(""); this.liveId = enabled[0] ? enabled[0].id : null; this.archId = this.cams[0] ? this.cams[0].id : null; lc.onchange = () => { this.liveId = lc.value; this.startLive(); }; ac.onchange = () => { this.archId = ac.value; this.loadArch(); }; const dt = document.getElementById("m-arch-date"); const t = new Date(); this.date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`; dt.value = this.date; dt.onchange = () => { this.date = dt.value; this.loadArch(); }; // бутерброд-меню сверху: выбор режима (Live / Архив) const drawer = document.getElementById("m-drawer"); const scrim = document.getElementById("m-scrim"); const setMenu = (open) => { drawer.classList.toggle("open", open); scrim.hidden = !open; }; document.getElementById("m-burger").onclick = () => setMenu(!drawer.classList.contains("open")); scrim.onclick = () => setMenu(false); document.querySelectorAll(".m-nav").forEach((b) => { b.onclick = () => { this.showView(b.dataset.view); setMenu(false); }; }); this.showView("live"); }, _kill() { if (this.player) { this.player.destroy(); this.player = null; } }, showView(v) { this.view = v; document.getElementById("m-title").textContent = v === "live" ? "Live" : "Архив"; document.querySelectorAll(".m-nav").forEach((b) => b.classList.toggle("active", b.dataset.view === v)); document.getElementById("view-live").hidden = v !== "live"; document.getElementById("view-arch").hidden = v !== "arch"; this._kill(); if (v === "live") this.startLive(); else this.loadArch(); }, // ── Live ── startLive() { this._kill(); const box = document.getElementById("m-live-box"); if (!this.liveId) { box.innerHTML = '
Нет доступных камер
'; return; } this.player = new Player(box, { live: true, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` }); }, // ── Архив ── async loadArch() { this._kill(); const box = document.getElementById("m-arch-box"); const list = document.getElementById("m-seglist"); box.innerHTML = '
Выберите запись ниже
'; if (!this.archId || !this.date) { list.innerHTML = ""; 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; list.innerHTML = '
Загрузка…
'; let recs = []; try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; } catch (e) { list.innerHTML = '
Ошибка загрузки
'; return; } if (!recs.length) { list.innerHTML = '
За выбранный день записей нет
'; return; } this.segs = recs; list.innerHTML = recs.map((r) => { const tt = new Date(r.started_at * 1000); const mins = Math.round((r.duration_s || 0) / 60); return ``; }).join(""); list.onclick = (e) => { const b = e.target.closest(".m-seg"); if (!b) return; list.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b)); this.playSeg(+b.dataset.id); }; }, playSeg(id) { this._kill(); const seg = this.segs.find((r) => r.id === id); const box = document.getElementById("m-arch-box"); this.player = new Player(box, { duration: seg ? (seg.duration_s || 0) : 0, srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`, }); }, }; document.addEventListener("DOMContentLoaded", () => M.init());