// 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 = '' + '
' + '
' + '
' + '' + '0:00' + '
' + '0:00' + '' + '' + '
'; 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) { /**/ } } }