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:
+5
-117
@@ -7,11 +7,7 @@
|
||||
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"); }
|
||||
@@ -19,116 +15,7 @@ async function api(url) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
|
||||
|
||||
const M = {
|
||||
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
||||
@@ -185,7 +72,7 @@ const M = {
|
||||
this._kill();
|
||||
const box = document.getElementById("m-live-box");
|
||||
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();
|
||||
const seg = this.segs.find((r) => r.id === id);
|
||||
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,
|
||||
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user