player: extract branded .pjs player into shared static/player.{js,css}

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>
This commit is contained in:
2026-06-02 00:28:04 +05:00
parent 6f9142a795
commit f263d84a8d
9 changed files with 252 additions and 352 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.154 0.0.155
+2
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vizion+ — Архив</title> <title>CoRE.Vizion+ — Архив</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/player.css">
</head> </head>
<body data-page="archive"> <body data-page="archive">
<div class="layout"> <div class="layout">
@@ -39,6 +40,7 @@
</main> </main>
</div> </div>
<script src="/static/player.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+2
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#161a22"> <meta name="theme-color" content="#161a22">
<title>CoRE.Vizion+ — Mobile</title> <title>CoRE.Vizion+ — Mobile</title>
<link rel="stylesheet" href="/static/player.css">
<link rel="stylesheet" href="/static/mobile.css"> <link rel="stylesheet" href="/static/mobile.css">
</head> </head>
<body> <body>
@@ -40,6 +41,7 @@
<div class="m-seglist" id="m-seglist"></div> <div class="m-seglist" id="m-seglist"></div>
</section> </section>
</div> </div>
<script src="/static/player.js"></script>
<script src="/static/mobile.js"></script> <script src="/static/mobile.js"></script>
</body> </body>
</html> </html>
+2 -156
View File
@@ -894,162 +894,8 @@ const Live = {
// ── плеер архива в стиле Playerjs: своя панель управления над <video> ── // ── плеер архива в стиле Playerjs: своя панель управления над <video> ──
// (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen, // (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen,
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши) // большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
class ArchivePlayer { // Фирменный плеер ArchivePlayer (.pjs) вынесен в общий модуль static/player.js
// opts: { mode:"transcode"|"file", duration, srcFor(startSec), src, offset, onEnded } // (подключается archive.html и mobile.html ПЕРЕД app.js / mobile.js).
// transcode — поток без длительности/Range: ведём виртуальный таймлайн на всю длину
// сегмента (duration из БД), перемотка = перезапуск транскода с -ss (srcFor).
// file — нативный seekable H.265 (HW-клиенты): обычный v.currentTime / v.duration.
constructor(wrap, opts = {}) {
this.mode = opts.mode === "transcode" ? "transcode" : "file";
this.total = opts.duration || 0; // полная длина сегмента, сек
this.srcFor = opts.srcFor || null; // (startSec) => url (для transcode)
this.startOffset = opts.offset || 0; // смещение начала текущего потока (transcode)
this.onEnded = opts.onEnded || null;
this.onTime = opts.onTime || null; // (posSec в сегменте) — для следования таймлайна
this.speeds = [0.5, 1, 2, 5];
this.spIdx = opts.spIdx != null ? opts.spIdx : 1; // скорость сохраняется между фрагментами
this.hideTimer = 0; this.dragging = false; this.dragP = 0;
wrap.innerHTML = "";
const root = document.createElement("div");
root.className = "pjs show"; root.tabIndex = 0;
root.innerHTML =
'<video class="pjs-video" autoplay playsinline></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="Во весь экран (F)">⛶</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.speedBtn.textContent = this.speeds[this.spIdx] + "×";
this.v.src = this.mode === "transcode" ? this.srcFor(this.startOffset) : opts.src;
this._bind();
}
_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");
}
_total() {
if (this.mode === "transcode") return this.total;
return isFinite(this.v.duration) && this.v.duration ? this.v.duration : (this.total || 0);
}
_curTime() {
if (this.mode === "transcode") return this.startOffset + (this.v.currentTime || 0);
return this.v.currentTime || 0;
}
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; } // вернуть выбранную скорость (в т.ч. перенесённую с прошлого фрагмента)
// перемотка к доле p∈[0..1] всего сегмента
_seekToFrac(p) {
const total = this._total(); if (!total) return;
const t = Math.min(total, Math.max(0, p * total));
if (this.mode === "transcode") {
this.startOffset = t; // перезапуск транскода с нужной секунды
this.v.src = this.srcFor(t);
this.v.play().catch(() => {});
} else {
try { this.v.currentTime = t; } catch (e) { /**/ }
}
this._sync();
}
_seekRel(d) { const total = this._total(); if (total) this._seekToFrac((this._curTime() + d) / total); }
_bind() {
const v = this.v, root = this.root;
v.addEventListener("loadedmetadata", () => {
this.durEl.textContent = this._fmt(this._total());
if (this.mode === "file" && this.startOffset > 0) { try { v.currentTime = this.startOffset; } catch (e) { /**/ } }
this._applyRate(); // вернуть скорость после загрузки нового потока (src сбрасывает playbackRate)
});
v.addEventListener("timeupdate", () => this._sync());
v.addEventListener("progress", () => this._syncBuf());
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); this._autohide(); });
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); this._show(); });
v.addEventListener("waiting", () => root.classList.add("buffering"));
v.addEventListener("playing", () => root.classList.remove("buffering"));
v.addEventListener("ended", () => { root.classList.add("paused"); if (this.onEnded) this.onEnded(); });
const toggle = () => { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } };
this.playBtn.onclick = toggle;
root.querySelector(".pjs-bigplay").onclick = toggle;
v.onclick = toggle;
v.ondblclick = () => this._fs();
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 + "×";
};
// перемотка по шкале: во время drag — только превью, перезапуск/seek один раз на отпускании
const fracAt = (clientX) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (clientX - r.left) / r.width)); };
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = this._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); } });
// показ/автоскрытие панели
root.addEventListener("mousemove", () => this._autohide());
root.addEventListener("mouseleave", () => { if (!v.paused) this._hide(); });
// горячие клавиши (когда плеер в фокусе)
root.addEventListener("keydown", (e) => {
if (e.code === "Space") { e.preventDefault(); toggle(); }
else if (e.code === "ArrowRight") { e.preventDefault(); this._seekRel(5); }
else if (e.code === "ArrowLeft") { e.preventDefault(); this._seekRel(-5); }
else if (e.key === "f" || e.key === "F" || e.key === "а" || e.key === "А") { e.preventDefault(); this._fs(); }
});
this._fsSync = () => root.classList.toggle("fs", (document.fullscreenElement || document.webkitFullscreenElement) === root);
document.addEventListener("fullscreenchange", this._fsSync);
document.addEventListener("webkitfullscreenchange", this._fsSync);
setTimeout(() => { try { root.focus(); } catch (e) {} }, 0);
}
_sync() {
if (this.dragging) return; // во время перетаскивания шкалой управляет preview
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 = this._fmt(cur);
this.durEl.textContent = this._fmt(total);
if (this.onTime) this.onTime(cur);
}
_syncBuf() {
const v = this.v; if (!v.buffered.length) return;
const total = this._total(); if (!total) return;
const base = this.mode === "transcode" ? this.startOffset : 0;
const end = base + v.buffered.end(v.buffered.length - 1);
this.buf.style.width = Math.min(100, end / total * 100) + "%";
}
_fs() { toggleFullscreen(this.root); }
_show() { this.root.classList.add("show"); }
_hide() { this.root.classList.remove("show"); }
_autohide() {
this._show(); clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(() => { if (!this.v.paused && !this.dragging) this._hide(); }, 2500);
}
destroy() {
this.onTime = null; this.onEnded = null; // хвостовые timeupdate/ended не должны дёргать ленту
clearTimeout(this.hideTimer);
if (this._fsSync) {
document.removeEventListener("fullscreenchange", this._fsSync);
document.removeEventListener("webkitfullscreenchange", this._fsSync);
}
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
}
}
const Archive = { const Archive = {
cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0, cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0,
+6 -34
View File
@@ -69,38 +69,10 @@ 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; }
/* ── фирменный плеер (.pjs) — единый для live и архива ── */ /* фирменный плеер (.pjs) — общий static/player.css; ниже только тач-подгонка размеров */
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; } .pjs-btn { font-size: 20px; padding: 6px 8px; }
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; } .pjs-seek { height: 6px; }
.pjs-center { position: absolute; inset: 0; pointer-events: none; } .pjs-seek:hover { height: 6px; }
.pjs-bigplay { .pjs-knob { width: 16px; height: 16px; margin-left: -8px; }
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%; .pjs-bar { gap: 12px; padding-bottom: calc(11px + env(safe-area-inset-bottom, 0)); }
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); }
.pjs-spinner {
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
}
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
@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; } .pjs.live .pjs-bar { justify-content: center; gap: 30px; }
+5 -117
View File
@@ -7,11 +7,7 @@
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"); }
@@ -19,116 +15,7 @@ async function api(url) {
return r.json(); return r.json();
} }
// ── фирменный плеер (.pjs) ────────────────────────────────────────────── // ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
// 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: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null, cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
@@ -185,7 +72,7 @@ const M = {
this._kill(); this._kill();
const box = document.getElementById("m-live-box"); const box = document.getElementById("m-live-box");
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; } if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
this.player = new Player(box, { live: true, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` }); this.player = new ArchivePlayer(box, { live: true, autohide: false, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
}, },
// ── Архив ── // ── Архив ──
@@ -222,7 +109,8 @@ const M = {
this._kill(); this._kill();
const seg = this.segs.find((r) => r.id === id); const seg = this.segs.find((r) => r.id === id);
const box = document.getElementById("m-arch-box"); const box = document.getElementById("m-arch-box");
this.player = new Player(box, { this.player = new ArchivePlayer(box, {
mode: "transcode", autohide: false,
duration: seg ? (seg.duration_s || 0) : 0, duration: seg ? (seg.duration_s || 0) : 0,
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`, srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
}); });
+48
View File
@@ -0,0 +1,48 @@
/* CoRE.Vizion+ — фирменный плеер (.pjs). Общие стили: десктоп (архив) и мобила. */
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
.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); }
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
.pjs-spinner {
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
}
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
@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: 10px; padding: 26px 14px 11px;
background: linear-gradient(transparent, rgba(0,0,0,.8));
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
}
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
.pjs-btn {
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
}
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
/* шкала перемотки */
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
.pjs-seek:hover { height: 7px; }
.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: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
}
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
.pjs.fs:not(.show) { cursor: none; }
/* 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; }
+185
View File
@@ -0,0 +1,185 @@
// CoRE.Vizion+ — фирменный плеер (.pjs). Общий модуль: десктоп (архив) и мобила.
// Класс называется ArchivePlayer (имя сохранено: app.js делает new ArchivePlayer(...)).
// Режимы:
// transcode — серверный H.264-поток без Range: виртуальный таймлайн на duration,
// перемотка = перезапуск транскода с -ss (srcFor(startSec)).
// file — нативно перематываемый файл (HW-декод H.265): v.currentTime/duration.
// live — без таймлайна: только пауза/плей + fullscreen (src без длительности).
// opts: { mode, live, duration, srcFor(startSec)->url, src, offset, onEnded, onTime, spIdx, autohide }
// autohide — авто-скрытие панели (десктоп: true по умолчанию; тач/мобила: передаём false).
"use strict";
class ArchivePlayer {
constructor(wrap, opts = {}) {
this.live = opts.live === true || opts.mode === "live";
this.mode = this.live ? "live" : (opts.mode === "transcode" ? "transcode" : "file");
this.total = opts.duration || 0; // полная длина сегмента, сек
this.srcFor = opts.srcFor || null; // (startSec) => url (для transcode)
this.startOffset = opts.offset || 0; // смещение начала текущего потока (transcode)
this.onEnded = opts.onEnded || null;
this.onTime = opts.onTime || null; // (posSec) — следование таймлайна
this.speeds = [0.5, 1, 2, 5];
this.spIdx = opts.spIdx != null ? opts.spIdx : 1; // скорость сохраняется между фрагментами
this.autohide = opts.autohide !== false; // false → панель всегда видна (тач)
this.hideTimer = 0; this.dragging = false; this.dragP = 0;
wrap.innerHTML = "";
const root = document.createElement("div");
root.className = "pjs show" + (this.live ? " live" : "");
root.tabIndex = 0;
root.innerHTML =
'<video class="pjs-video" ' + (this.live ? "muted " : "") + 'autoplay playsinline></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="Во весь экран (F)">⛶</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.speedBtn.textContent = this.speeds[this.spIdx] + "×";
this.v.src = this.live ? opts.src : (this.mode === "transcode" ? this.srcFor(this.startOffset) : opts.src);
this._bind();
if (this.live) this.v.play().catch(() => {});
}
_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");
}
_total() {
if (this.live) return 0;
if (this.mode === "transcode") return this.total;
return isFinite(this.v.duration) && this.v.duration ? this.v.duration : (this.total || 0);
}
_curTime() {
if (this.live) return 0;
if (this.mode === "transcode") return this.startOffset + (this.v.currentTime || 0);
return this.v.currentTime || 0;
}
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; }
// перемотка к доле p∈[0..1] всего сегмента
_seekToFrac(p) {
if (this.live) return;
const total = this._total(); if (!total) return;
const t = Math.min(total, Math.max(0, p * total));
if (this.mode === "transcode") {
this.startOffset = t; // перезапуск транскода с нужной секунды
this.v.src = this.srcFor(t);
this.v.play().catch(() => {});
} else {
try { this.v.currentTime = t; } catch (e) { /**/ }
}
this._sync();
}
_seekRel(d) { const total = this._total(); if (total) this._seekToFrac((this._curTime() + d) / total); }
_bind() {
const v = this.v, root = this.root;
v.addEventListener("loadedmetadata", () => {
this.durEl.textContent = this._fmt(this._total());
if (this.mode === "file" && this.startOffset > 0) { try { v.currentTime = this.startOffset; } catch (e) { /**/ } }
this._applyRate(); // вернуть скорость после загрузки нового потока (src сбрасывает playbackRate)
});
v.addEventListener("timeupdate", () => this._sync());
v.addEventListener("progress", () => this._syncBuf());
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); this._autohide(); });
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); this._show(); });
v.addEventListener("waiting", () => root.classList.add("buffering"));
v.addEventListener("playing", () => root.classList.remove("buffering"));
v.addEventListener("ended", () => { root.classList.add("paused"); if (this.onEnded) this.onEnded(); });
const toggle = () => { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } };
this.playBtn.onclick = toggle;
root.querySelector(".pjs-bigplay").onclick = toggle;
v.onclick = toggle;
v.ondblclick = () => this._fs();
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 = (clientX) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (clientX - r.left) / r.width)); };
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = this._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); } });
}
// показ/автоскрытие панели
root.addEventListener("mousemove", () => this._autohide());
root.addEventListener("mouseleave", () => { if (!v.paused && this.autohide) this._hide(); });
// горячие клавиши (когда плеер в фокусе)
root.addEventListener("keydown", (e) => {
if (e.code === "Space") { e.preventDefault(); toggle(); }
else if (e.code === "ArrowRight") { e.preventDefault(); this._seekRel(5); }
else if (e.code === "ArrowLeft") { e.preventDefault(); this._seekRel(-5); }
else if (e.key === "f" || e.key === "F" || e.key === "а" || e.key === "А") { e.preventDefault(); this._fs(); }
});
this._fsSync = () => root.classList.toggle("fs", (document.fullscreenElement || document.webkitFullscreenElement) === root);
document.addEventListener("fullscreenchange", this._fsSync);
document.addEventListener("webkitfullscreenchange", this._fsSync);
setTimeout(() => { try { root.focus(); } catch (e) {} }, 0);
}
_sync() {
if (this.dragging || this.live) return; // во время перетаскивания шкалой управляет preview
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 = this._fmt(cur);
this.durEl.textContent = this._fmt(total);
if (this.onTime) this.onTime(cur);
}
_syncBuf() {
const v = this.v; if (this.live || !v.buffered.length) return;
const total = this._total(); if (!total) return;
const base = this.mode === "transcode" ? this.startOffset : 0;
const end = base + v.buffered.end(v.buffered.length - 1);
this.buf.style.width = Math.min(100, end / total * 100) + "%";
}
// self-contained fullscreen (без глобального toggleFullscreen): работает и на мобиле
_fs() {
const el = this.root;
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
if (fsEl) {
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
} else if (el.requestFullscreen || el.webkitRequestFullscreen) {
(el.requestFullscreen || el.webkitRequestFullscreen).call(el);
} else if (this.v.webkitEnterFullscreen) {
this.v.webkitEnterFullscreen(); // iOS: на весь экран умеет только видеоэлемент
}
}
_show() { this.root.classList.add("show"); }
_hide() { this.root.classList.remove("show"); }
_autohide() {
this._show();
if (!this.autohide) return; // тач-режим: панель всегда видна
clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(() => { if (!this.v.paused && !this.dragging) this._hide(); }, 2500);
}
destroy() {
this.onTime = null; this.onEnded = null; // хвостовые timeupdate/ended не должны дёргать ленту
clearTimeout(this.hideTimer);
if (this._fsSync) {
document.removeEventListener("fullscreenchange", this._fsSync);
document.removeEventListener("webkitfullscreenchange", this._fsSync);
}
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
}
}
+1 -44
View File
@@ -300,50 +300,7 @@ footer.grid-bar {
.player-wrap video { max-width: 100%; max-height: 100%; } .player-wrap video { max-width: 100%; max-height: 100%; }
.player-wrap .hint { color: var(--text-dim); } .player-wrap .hint { color: var(--text-dim); }
/* ── плеер архива в стиле Playerjs ── */ /* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
.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); }
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
.pjs-spinner {
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
}
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
@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: 10px; padding: 26px 14px 11px;
background: linear-gradient(transparent, rgba(0,0,0,.8));
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
}
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
.pjs-btn {
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
}
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
/* шкала перемотки */
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
.pjs-seek:hover { height: 7px; }
.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: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
}
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
.pjs.fs:not(.show) { cursor: none; }
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */ /* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
.timeline { .timeline {