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:
+2
-156
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user