// 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());