f263d84a8d
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>
121 lines
5.5 KiB
JavaScript
121 lines
5.5 KiB
JavaScript
// 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"); }
|
|
|
|
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();
|
|
}
|
|
|
|
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
|
|
|
|
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) => `<option value="${c.id}">${c.name}</option>`).join("");
|
|
ac.innerHTML = this.cams.map((c) => `<option value="${c.id}">${c.name}</option>`).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 = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
|
this.player = new ArchivePlayer(box, { live: true, autohide: false, 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 = '<div class="m-empty">Выберите запись ниже</div>';
|
|
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 = '<div class="m-empty">Загрузка…</div>';
|
|
let recs = [];
|
|
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
|
catch (e) { list.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
|
if (!recs.length) { list.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; 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 `<button class="m-seg" data-id="${r.id}">` +
|
|
`${pad(tt.getHours())}:${pad(tt.getMinutes())}:${pad(tt.getSeconds())}` +
|
|
`<span class="m-dur">${mins} мин</span></button>`;
|
|
}).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 ArchivePlayer(box, {
|
|
mode: "transcode", autohide: false,
|
|
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());
|