Compare commits
3 Commits
126b588dc7
...
6f9142a795
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f9142a795 | |||
| cfb125784d | |||
| c408fcbe92 |
+16
-16
@@ -9,36 +9,36 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="m-app">
|
<div class="m-app">
|
||||||
<header class="m-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></header>
|
<header class="m-head">
|
||||||
|
<button class="m-burger" id="m-burger" aria-label="Меню">☰</button>
|
||||||
|
<span class="m-title" id="m-title">Live</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- LIVE: одна камера + выбор камеры -->
|
<!-- бутерброд-меню: выбор режима (Live / Архив) -->
|
||||||
|
<div class="m-scrim" id="m-scrim" hidden></div>
|
||||||
|
<aside class="m-drawer" id="m-drawer">
|
||||||
|
<div class="m-drawer-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></div>
|
||||||
|
<button class="m-nav active" data-view="live"><span class="i">▦</span>Live</button>
|
||||||
|
<button class="m-nav" data-view="arch"><span class="i">🗄</span>Архив</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- LIVE: одна камера + выбор камеры (плеер только пауза/плей) -->
|
||||||
<section class="m-view" id="view-live">
|
<section class="m-view" id="view-live">
|
||||||
<div class="m-bar">
|
<div class="m-bar">
|
||||||
<select id="m-live-cam" class="m-sel" title="Камера"></select>
|
<select id="m-live-cam" class="m-sel" title="Камера"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-player" id="m-live-player">
|
<div class="m-stage"><div class="player-wrap" id="m-live-box"></div></div>
|
||||||
<video id="m-live-video" playsinline muted autoplay></video>
|
|
||||||
<div class="m-hint" id="m-live-hint">Нет камер</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- АРХИВ: камера + дата + список записей -->
|
<!-- АРХИВ: камера + дата + список записей (плеер с перемоткой) -->
|
||||||
<section class="m-view" id="view-arch" hidden>
|
<section class="m-view" id="view-arch" hidden>
|
||||||
<div class="m-bar">
|
<div class="m-bar">
|
||||||
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
|
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
|
||||||
<input type="date" id="m-arch-date" class="m-date" title="Дата">
|
<input type="date" id="m-arch-date" class="m-date" title="Дата">
|
||||||
</div>
|
</div>
|
||||||
<div class="m-player">
|
<div class="m-stage"><div class="player-wrap" id="m-arch-box"></div></div>
|
||||||
<video id="m-arch-video" playsinline controls></video>
|
|
||||||
<div class="m-hint" id="m-arch-hint">Выберите запись ниже</div>
|
|
||||||
</div>
|
|
||||||
<div class="m-seglist" id="m-seglist"></div>
|
<div class="m-seglist" id="m-seglist"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav class="m-tabs">
|
|
||||||
<!-- одна кнопка: переключает режим Live ⇄ Архив (или/или) -->
|
|
||||||
<button class="m-mode" id="m-mode"><span class="i">⇆</span><span class="m-mode-name" id="m-mode-name">Live</span></button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/mobile.js"></script>
|
<script src="/static/mobile.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+72
-27
@@ -1,4 +1,4 @@
|
|||||||
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, нижние вкладки. */
|
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, бутерброд-меню, фирменный плеер. */
|
||||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
|
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
|
||||||
@@ -7,32 +7,56 @@ html, body {
|
|||||||
}
|
}
|
||||||
.m-app { display: flex; flex-direction: column; height: 100%; }
|
.m-app { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
|
||||||
|
/* ── шапка с бутербродом ── */
|
||||||
.m-head {
|
.m-head {
|
||||||
height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
|
height: 48px; flex: none; display: flex; align-items: center; gap: 10px;
|
||||||
background: #161a22; border-bottom: 1px solid #272d3a;
|
padding: 0 8px; background: #161a22; border-bottom: 1px solid #272d3a;
|
||||||
|
position: relative; z-index: 30;
|
||||||
}
|
}
|
||||||
.m-brand { font-weight: 700; font-size: 16px; letter-spacing: .3px; }
|
.m-burger { background: none; border: 0; color: #e6e9ef; font-size: 22px; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
|
||||||
|
.m-burger:active { background: #1d2230; }
|
||||||
|
.m-title { font-weight: 600; font-size: 16px; letter-spacing: .3px; }
|
||||||
|
.m-brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; }
|
||||||
.m-brand span { color: #c0392b; }
|
.m-brand span { color: #c0392b; }
|
||||||
|
|
||||||
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
/* ── бутерброд-меню: боковая шторка + затемнение ── */
|
||||||
|
.m-scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; }
|
||||||
.m-bar {
|
.m-scrim[hidden] { display: none; }
|
||||||
flex: none; display: flex; gap: 8px; padding: 8px;
|
.m-drawer {
|
||||||
background: #161a22; border-bottom: 1px solid #272d3a;
|
position: fixed; top: 0; left: 0; bottom: 0; width: 240px; max-width: 80vw; z-index: 50;
|
||||||
|
background: #161a22; border-right: 1px solid #272d3a;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(-100%); transition: transform .22s ease;
|
||||||
}
|
}
|
||||||
|
.m-drawer.open { transform: translateX(0); }
|
||||||
|
.m-drawer-head { height: 56px; flex: none; display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #272d3a; }
|
||||||
|
.m-nav {
|
||||||
|
display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
|
||||||
|
background: none; border: 0; border-left: 3px solid transparent; color: #cdd3e0;
|
||||||
|
padding: 16px 18px; font: 600 16px/1 inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-nav .i { font-size: 20px; width: 24px; text-align: center; }
|
||||||
|
.m-nav:active { background: #1d2230; }
|
||||||
|
.m-nav.active { color: #fff; background: #1d2230; border-left-color: #c0392b; }
|
||||||
|
|
||||||
|
/* ── режимы ── */
|
||||||
|
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.m-view[hidden] { display: none; } /* hidden должен реально скрывать (иначе display:flex перебивает) */
|
||||||
|
|
||||||
|
.m-bar { flex: none; display: flex; gap: 8px; padding: 8px; background: #161a22; border-bottom: 1px solid #272d3a; }
|
||||||
.m-sel, .m-date {
|
.m-sel, .m-date {
|
||||||
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
||||||
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
|
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
|
||||||
}
|
}
|
||||||
.m-date { flex: 0 0 auto; }
|
.m-date { flex: 0 0 auto; }
|
||||||
|
|
||||||
.m-player { position: relative; background: #000; flex: none; }
|
/* сцена плеера: live — на всё доступное место, архив — 16:9 (ниже список записей) */
|
||||||
.m-player video { width: 100%; aspect-ratio: 16 / 9; display: block; background: #000; object-fit: contain; }
|
.m-stage { background: #000; position: relative; min-height: 0; }
|
||||||
.m-hint {
|
#view-live .m-stage { flex: 1; }
|
||||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
#view-arch .m-stage { flex: none; aspect-ratio: 16 / 9; }
|
||||||
color: #8b93a7; font-size: 14px; pointer-events: none; text-align: center; padding: 0 16px;
|
.player-wrap { width: 100%; height: 100%; }
|
||||||
}
|
|
||||||
|
|
||||||
|
/* список записей архива */
|
||||||
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
|
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
|
||||||
.m-seg {
|
.m-seg {
|
||||||
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
|
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
|
||||||
@@ -45,17 +69,38 @@ html, body {
|
|||||||
.m-seg.active .m-dur { color: #fff; }
|
.m-seg.active .m-dur { color: #fff; }
|
||||||
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
||||||
|
|
||||||
.m-tabs {
|
/* ── фирменный плеер (.pjs) — единый для live и архива ── */
|
||||||
flex: none; display: flex; justify-content: center; background: #161a22;
|
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
||||||
border-top: 1px solid #272d3a; padding: 8px 12px;
|
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
||||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
|
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.pjs-bigplay {
|
||||||
|
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
||||||
|
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
|
||||||
|
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
|
||||||
|
transform: translate(-50%, -50%) scale(.9);
|
||||||
}
|
}
|
||||||
/* одна кнопка-переключатель режима (или/или): показывает текущий режим, тап — меняет */
|
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
.m-mode {
|
.pjs-spinner {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||||
min-width: 190px; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
|
||||||
border-radius: 10px; padding: 12px 28px; cursor: pointer;
|
|
||||||
font: 600 15px/1 inherit; letter-spacing: .3px;
|
|
||||||
}
|
}
|
||||||
.m-mode:active { background: #232a3a; }
|
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
|
||||||
.m-mode .i { font-size: 18px; color: #c0392b; }
|
@keyframes pjs-spin { to { transform: rotate(360deg); } }
|
||||||
|
/* нижняя панель — на тач всегда видима */
|
||||||
|
.pjs-bar {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 26px 12px calc(11px + env(safe-area-inset-bottom, 0));
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,.85));
|
||||||
|
}
|
||||||
|
.pjs-btn { background: none; border: none; color: #fff; cursor: pointer; font-size: 20px; line-height: 1; padding: 6px 8px; border-radius: 6px; }
|
||||||
|
.pjs-btn:active { background: rgba(255,255,255,.18); }
|
||||||
|
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; }
|
||||||
|
.pjs-seek { position: relative; flex: 1; height: 6px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
||||||
|
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||||||
|
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
|
||||||
|
.pjs-knob { position: absolute; top: 50%; width: 16px; height: 16px; margin-left: -8px; border-radius: 50%; background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%); }
|
||||||
|
|
||||||
|
/* live: только пауза/плей + во весь экран (без шкалы/времени/скорости) */
|
||||||
|
.pjs.live .pjs-time, .pjs.live .pjs-dur, .pjs.live .pjs-seek, .pjs.live .pjs-speed { display: none; }
|
||||||
|
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||||
|
|||||||
+160
-45
@@ -1,11 +1,17 @@
|
|||||||
// CoRE.Vizion+ — мобильный dashboard. 2 режима: Live (1 камера + выбор) и Архив.
|
// CoRE.Vizion+ — мобильный dashboard. Оба режима через фирменный плеер (.pjs):
|
||||||
// Видео отдаётся сервером уже перекодированным в H.264 (обычный <video>, играет на любом телефоне).
|
// Live — только пауза/плей + во весь экран; Архив — перемотка + скорость.
|
||||||
|
// Видео отдаётся сервером уже перекодированным в H.264 (играет на любом телефоне).
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// фиксированный профиль H.264 для телефона (макс. совместимость)
|
// фиксированный профиль H.264 для телефона (макс. совместимость)
|
||||||
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
|
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 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) {
|
async function api(url) {
|
||||||
const r = await fetch(url, { credentials: "same-origin" });
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||||||
@@ -13,8 +19,119 @@ async function api(url) {
|
|||||||
return r.json();
|
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 =
|
||||||
|
'<video class="pjs-video" playsinline' + (this.live ? " muted autoplay" : " autoplay") + '></video>' +
|
||||||
|
'<div class="pjs-center"><div class="pjs-spinner"></div><button class="pjs-bigplay" aria-label="Play">▶</button></div>' +
|
||||||
|
'<div class="pjs-bar">' +
|
||||||
|
'<button class="pjs-btn pjs-play" title="Пауза / воспроизведение">⏸</button>' +
|
||||||
|
'<span class="pjs-time">0:00</span>' +
|
||||||
|
'<div class="pjs-seek"><div class="pjs-buf"></div><div class="pjs-prog"></div><div class="pjs-knob"></div></div>' +
|
||||||
|
'<span class="pjs-dur">0:00</span>' +
|
||||||
|
'<button class="pjs-btn pjs-speed" title="Скорость воспроизведения">1×</button>' +
|
||||||
|
'<button class="pjs-btn pjs-fs" title="Во весь экран">⛶</button>' +
|
||||||
|
'</div>';
|
||||||
|
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 = {
|
const M = {
|
||||||
cams: [], liveId: null, archId: null, date: null, view: "live",
|
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
let data;
|
let data;
|
||||||
@@ -37,80 +154,78 @@ const M = {
|
|||||||
dt.value = this.date;
|
dt.value = this.date;
|
||||||
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
|
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
|
||||||
|
|
||||||
// тап по живому видео — на весь экран
|
// бутерброд-меню сверху: выбор режима (Live / Архив)
|
||||||
const lv = document.getElementById("m-live-video");
|
const drawer = document.getElementById("m-drawer");
|
||||||
lv.addEventListener("click", () => {
|
const scrim = document.getElementById("m-scrim");
|
||||||
if (lv.requestFullscreen) lv.requestFullscreen().catch(() => {});
|
const setMenu = (open) => { drawer.classList.toggle("open", open); scrim.hidden = !open; };
|
||||||
else if (lv.webkitEnterFullscreen) lv.webkitEnterFullscreen(); // iOS
|
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); };
|
||||||
});
|
});
|
||||||
|
|
||||||
// одна кнопка снизу — переключатель режима (или/или): Live ⇄ Архив
|
|
||||||
document.getElementById("m-mode").onclick = () =>
|
|
||||||
this.showView(this.view === "live" ? "arch" : "live");
|
|
||||||
|
|
||||||
this.showView("live");
|
this.showView("live");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_kill() { if (this.player) { this.player.destroy(); this.player = null; } },
|
||||||
|
|
||||||
showView(v) {
|
showView(v) {
|
||||||
this.view = v;
|
this.view = v;
|
||||||
document.getElementById("m-mode-name").textContent = v === "live" ? "Live" : "Архив";
|
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-live").hidden = v !== "live";
|
||||||
document.getElementById("view-arch").hidden = v !== "arch";
|
document.getElementById("view-arch").hidden = v !== "arch";
|
||||||
if (v === "live") { this.stopArch(); this.startLive(); }
|
this._kill();
|
||||||
else { this.stopLive(); this.loadArch(); }
|
if (v === "live") this.startLive();
|
||||||
|
else this.loadArch();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Live ──
|
// ── Live ──
|
||||||
startLive() {
|
startLive() {
|
||||||
const v = document.getElementById("m-live-video");
|
this._kill();
|
||||||
const hint = document.getElementById("m-live-hint");
|
const box = document.getElementById("m-live-box");
|
||||||
if (!this.liveId) { this.stopLive(); hint.textContent = "Нет доступных камер"; hint.hidden = false; return; }
|
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
||||||
hint.hidden = true;
|
this.player = new Player(box, { live: true, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
|
||||||
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() {
|
async loadArch() {
|
||||||
const box = document.getElementById("m-seglist");
|
this._kill();
|
||||||
this.stopArch();
|
const box = document.getElementById("m-arch-box");
|
||||||
document.getElementById("m-arch-hint").hidden = false;
|
const list = document.getElementById("m-seglist");
|
||||||
if (!this.archId || !this.date) { box.innerHTML = ""; return; }
|
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 [y, m, d] = this.date.split("-").map(Number);
|
||||||
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||||||
const to = from + 86400;
|
const to = from + 86400;
|
||||||
box.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
list.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
||||||
let recs = [];
|
let recs = [];
|
||||||
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
||||||
catch (e) { box.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
catch (e) { list.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
||||||
if (!recs.length) { box.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
if (!recs.length) { list.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
||||||
box.innerHTML = recs.map((r) => {
|
this.segs = recs;
|
||||||
const t = new Date(r.started_at * 1000);
|
list.innerHTML = recs.map((r) => {
|
||||||
|
const tt = new Date(r.started_at * 1000);
|
||||||
const mins = Math.round((r.duration_s || 0) / 60);
|
const mins = Math.round((r.duration_s || 0) / 60);
|
||||||
return `<button class="m-seg" data-id="${r.id}">` +
|
return `<button class="m-seg" data-id="${r.id}">` +
|
||||||
`${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}` +
|
`${pad(tt.getHours())}:${pad(tt.getMinutes())}:${pad(tt.getSeconds())}` +
|
||||||
`<span class="m-dur">${mins} мин</span></button>`;
|
`<span class="m-dur">${mins} мин</span></button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
box.onclick = (e) => {
|
list.onclick = (e) => {
|
||||||
const b = e.target.closest(".m-seg");
|
const b = e.target.closest(".m-seg");
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
box.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
list.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
||||||
this.playSeg(+b.dataset.id);
|
this.playSeg(+b.dataset.id);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
playSeg(id) {
|
playSeg(id) {
|
||||||
const v = document.getElementById("m-arch-video");
|
this._kill();
|
||||||
document.getElementById("m-arch-hint").hidden = true;
|
const seg = this.segs.find((r) => r.id === id);
|
||||||
v.src = `/api/recordings/${id}/play.mp4?${TR}`;
|
const box = document.getElementById("m-arch-box");
|
||||||
v.play().catch(() => {});
|
this.player = new Player(box, {
|
||||||
},
|
duration: seg ? (seg.duration_s || 0) : 0,
|
||||||
stopArch() {
|
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
||||||
const v = document.getElementById("m-arch-video");
|
});
|
||||||
v.pause(); v.removeAttribute("src"); v.load();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user