f263d84a8d
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>
186 lines
11 KiB
JavaScript
186 lines
11 KiB
JavaScript
// 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) { /**/ }
|
||
}
|
||
}
|