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
+2 -156
View File
@@ -894,162 +894,8 @@ const Live = {
// ── плеер архива в стиле Playerjs: своя панель управления над <video> ──
// (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen,
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
class ArchivePlayer {
// opts: { mode:"transcode"|"file", duration, srcFor(startSec), src, offset, onEnded }
// 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) { /**/ }
}
}
// Фирменный плеер ArchivePlayer (.pjs) вынесен в общий модуль static/player.js
// (подключается archive.html и mobile.html ПЕРЕД app.js / mobile.js).
const Archive = {
cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0,