mobile: add a phone dashboard on a second port (8443) — Live + Archive
A new mobile dashboard is served on a second host port (8443) mapped to the same backend, so the recorder/supervisor is not duplicated. main.py detects the mobile port via the Host header and serves mobile.html instead of the desktop index. The mobile SPA has two modes: Live (one camera with a selector) and Archive (camera + date + segment list). Video is delivered as server-side H.264 (live.mp4 / play.mp4 transcode) so a plain <video> plays on any phone. Bottom tab bar, dark theme, safe-area aware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// CoRE.Vizion+ — мобильный dashboard. 2 режима: Live (1 камера + выбор) и Архив.
|
||||
// Видео отдаётся сервером уже перекодированным в H.264 (обычный <video>, играет на любом телефоне).
|
||||
"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();
|
||||
}
|
||||
|
||||
const M = {
|
||||
cams: [], liveId: null, archId: null, date: 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(); };
|
||||
|
||||
// тап по живому видео — на весь экран
|
||||
const lv = document.getElementById("m-live-video");
|
||||
lv.addEventListener("click", () => {
|
||||
if (lv.requestFullscreen) lv.requestFullscreen().catch(() => {});
|
||||
else if (lv.webkitEnterFullscreen) lv.webkitEnterFullscreen(); // iOS
|
||||
});
|
||||
|
||||
document.querySelectorAll(".m-tab").forEach((tb) => {
|
||||
tb.onclick = () => this.showView(tb.dataset.view);
|
||||
});
|
||||
|
||||
this.showView("live");
|
||||
},
|
||||
|
||||
showView(v) {
|
||||
document.querySelectorAll(".m-tab").forEach((t) => t.classList.toggle("active", t.dataset.view === v));
|
||||
document.getElementById("view-live").hidden = v !== "live";
|
||||
document.getElementById("view-arch").hidden = v !== "arch";
|
||||
if (v === "live") { this.stopArch(); this.startLive(); }
|
||||
else { this.stopLive(); this.loadArch(); }
|
||||
},
|
||||
|
||||
// ── Live ──
|
||||
startLive() {
|
||||
const v = document.getElementById("m-live-video");
|
||||
const hint = document.getElementById("m-live-hint");
|
||||
if (!this.liveId) { this.stopLive(); hint.textContent = "Нет доступных камер"; hint.hidden = false; return; }
|
||||
hint.hidden = true;
|
||||
v.src = `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}`;
|
||||
v.play().catch(() => {});
|
||||
},
|
||||
stopLive() {
|
||||
const v = document.getElementById("m-live-video");
|
||||
v.pause(); v.removeAttribute("src"); v.load(); // обрыв запроса → сервер гасит ffmpeg
|
||||
},
|
||||
|
||||
// ── Архив ──
|
||||
async loadArch() {
|
||||
const box = document.getElementById("m-seglist");
|
||||
this.stopArch();
|
||||
document.getElementById("m-arch-hint").hidden = false;
|
||||
if (!this.archId || !this.date) { box.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;
|
||||
box.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
||||
let recs = [];
|
||||
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
||||
catch (e) { box.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
||||
if (!recs.length) { box.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
||||
box.innerHTML = recs.map((r) => {
|
||||
const t = 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(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}` +
|
||||
`<span class="m-dur">${mins} мин</span></button>`;
|
||||
}).join("");
|
||||
box.onclick = (e) => {
|
||||
const b = e.target.closest(".m-seg");
|
||||
if (!b) return;
|
||||
box.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
||||
this.playSeg(+b.dataset.id);
|
||||
};
|
||||
},
|
||||
playSeg(id) {
|
||||
const v = document.getElementById("m-arch-video");
|
||||
document.getElementById("m-arch-hint").hidden = true;
|
||||
v.src = `/api/recordings/${id}/play.mp4?${TR}`;
|
||||
v.play().catch(() => {});
|
||||
},
|
||||
stopArch() {
|
||||
const v = document.getElementById("m-arch-video");
|
||||
v.pause(); v.removeAttribute("src"); v.load();
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => M.init());
|
||||
Reference in New Issue
Block a user