Files
CoRE.Vision/frontend/static/player.js
T
git_admin f263d84a8d 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>
2026-06-02 00:28:04 +05:00

186 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) { /**/ }
}
}