Compare commits
8 Commits
157893b516
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa290a51b7 | |||
| 847c87fd7e | |||
| f263d84a8d | |||
| 6f9142a795 | |||
| cfb125784d | |||
| c408fcbe92 | |||
| 126b588dc7 | |||
| c91f82bf28 |
@@ -285,12 +285,19 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
||||
@router.delete("/{cam_id}")
|
||||
async def delete_camera(cam_id: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
if not runtime.config.camera(cam_id):
|
||||
cams = runtime.config.cameras
|
||||
idx = next((i for i, c in enumerate(cams) if c.id == cam_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "camera not found")
|
||||
runtime.config.cameras = [c for c in runtime.config.cameras if c.id != cam_id]
|
||||
# удаление из середины сдвигает слоты последующих камер → их папки записи
|
||||
# (номер слота) меняются. Перезапускаем запись, чтобы ffmpeg писал в новую папку.
|
||||
shifts = idx < len(cams) - 1
|
||||
runtime.config.cameras = [c for c in cams if c.id != cam_id]
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.remove_camera(cam_id)
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
if shifts:
|
||||
await runtime.supervisor.restart_all()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,29 @@ class Config:
|
||||
def enabled_cameras(self) -> list[Camera]:
|
||||
return [c for c in self.cameras if c.enabled]
|
||||
|
||||
# ── слоты записи ────────────────────────────────────────────
|
||||
# Папка камеры в хранилище = номер её слота (позиция в списке, 1-based) с
|
||||
# ведущим нулём: слот 5 → «05», слот 12 → «12». Так совпадает со столбцом ID в UI.
|
||||
def slot_of(self, cam_id: str) -> int | None:
|
||||
for i, c in enumerate(self.cameras, start=1):
|
||||
if c.id == cam_id:
|
||||
return i
|
||||
return None
|
||||
|
||||
def archive_dir(self, cam_id: str) -> str:
|
||||
"""Имя подпапки записи камеры в хранилище. Откат на id, если камеры нет в списке."""
|
||||
slot = self.slot_of(cam_id)
|
||||
return f"{slot:02d}" if slot is not None else cam_id
|
||||
|
||||
def camera_by_archive_dir(self, name: str) -> Camera | None:
|
||||
"""Обратное: имя папки-слота («05») → камера в этом слоте. None для нечисловых
|
||||
(легаси-папки по старому id индексируются под своим именем как раньше)."""
|
||||
if name.isdigit():
|
||||
idx = int(name)
|
||||
if 1 <= idx <= len(self.cameras):
|
||||
return self.cameras[idx - 1]
|
||||
return None
|
||||
|
||||
# ── хранилища ───────────────────────────────────────────────
|
||||
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||
return next((s for s in self.storage.items if s.id == sid), None)
|
||||
|
||||
+11
-2
@@ -28,6 +28,13 @@ logging.basicConfig(
|
||||
log = logging.getLogger("nvr")
|
||||
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
# Мобильный dashboard: тот же бэкенд слушает один порт, но в docker проброшен второй
|
||||
# хост-порт (8443→8090). Запрос на него приходит с Host ".. :8443" → отдаём mobile.html.
|
||||
MOBILE_PORT = os.environ.get("NVR_MOBILE_PORT", "8443")
|
||||
|
||||
|
||||
def _is_mobile(request: Request) -> bool:
|
||||
return request.headers.get("host", "").endswith(":" + MOBILE_PORT)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -109,8 +116,10 @@ app.include_router(ws.router)
|
||||
|
||||
# ── фронтенд ────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||
async def index(request: Request) -> FileResponse:
|
||||
# на мобильном порту — мобильный dashboard (Live одной камеры + Архив)
|
||||
page = "mobile.html" if _is_mobile(request) else "index.html"
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, page))
|
||||
|
||||
|
||||
@app.get("/archive")
|
||||
|
||||
@@ -9,17 +9,18 @@ from ..config import Camera
|
||||
|
||||
|
||||
def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
||||
input_url: str | None = None) -> list[str]:
|
||||
input_url: str | None = None, sub_dir: str | None = None) -> list[str]:
|
||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||
|
||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
|
||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||
"""
|
||||
# Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||
# Подпапка даты: <root>/<sub_dir>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
||||
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
out_template = os.path.join(out_root, sub_dir or cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
|
||||
@@ -32,16 +32,17 @@ def _parse_started_at(path: str) -> int:
|
||||
|
||||
|
||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
|
||||
"""Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
|
||||
легаси-id камеры — сопоставление в камеру делает вызывающий).
|
||||
|
||||
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||
Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||
<folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
||||
result: dict[str, list[str]] = {}
|
||||
if not os.path.isdir(root):
|
||||
return result
|
||||
for cam_id in os.listdir(root):
|
||||
cam_dir = os.path.join(root, cam_id)
|
||||
for folder in os.listdir(root):
|
||||
cam_dir = os.path.join(root, folder)
|
||||
if not os.path.isdir(cam_dir):
|
||||
continue
|
||||
files: list[str] = []
|
||||
@@ -58,7 +59,7 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
|
||||
files.sort(key=os.path.basename)
|
||||
if files:
|
||||
result[cam_id] = files
|
||||
result[folder] = files
|
||||
return result
|
||||
|
||||
|
||||
@@ -102,9 +103,10 @@ class Indexer:
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
if store.type == "nas" and not os.path.ismount(store.path):
|
||||
continue
|
||||
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
|
||||
for d in days:
|
||||
try:
|
||||
os.makedirs(os.path.join(store.path, cam.id, d), exist_ok=True)
|
||||
os.makedirs(os.path.join(store.path, sub, d), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -115,12 +117,23 @@ class Indexer:
|
||||
|
||||
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
||||
for root in self.config.storage_paths():
|
||||
by_cam = _scan_segments(root)
|
||||
by_dir = _scan_segments(root)
|
||||
is_active = (root == active_path)
|
||||
for cam_id, files in by_cam.items():
|
||||
# в активном хранилище последний файл ещё пишется — пропускаем;
|
||||
# в неактивных все сегменты завершены, индексируем целиком.
|
||||
complete = files[:-1] if is_active else files
|
||||
for folder, files in by_dir.items():
|
||||
# Сопоставление папки с камерой по приоритету:
|
||||
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
|
||||
# иначе папка камеры с id «5» спуталась бы со слотом 5;
|
||||
# 2) папка-слот «NN» → камера в этом слоте (новая схема);
|
||||
# 3) иначе оставляем имя папки (осиротевший архив индексируется как раньше).
|
||||
cam = self.config.camera(folder) or self.config.camera_by_archive_dir(folder)
|
||||
cam_id = cam.id if cam else folder
|
||||
# последний файл ещё пишется только в активной папке-слоте ТЕКУЩЕЙ камеры;
|
||||
# легаси-папки и неактивное хранилище дописаны — индексируем целиком.
|
||||
being_written = (
|
||||
is_active and cam is not None
|
||||
and self.config.archive_dir(cam.id) == folder
|
||||
)
|
||||
complete = files[:-1] if being_written else files
|
||||
for path in complete:
|
||||
if path in known:
|
||||
continue
|
||||
|
||||
@@ -23,10 +23,11 @@ class RecorderTask:
|
||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||
|
||||
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
|
||||
on_status=None, input_url: str | None = None):
|
||||
on_status=None, input_url: str | None = None, sub_dir: str | None = None):
|
||||
self.cam = cam
|
||||
self.store = store # активное хранилище (куда пишем)
|
||||
self.segment_seconds = segment_seconds
|
||||
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
|
||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||
self.status = CameraStatus(
|
||||
@@ -84,9 +85,9 @@ class RecorderTask:
|
||||
continue
|
||||
|
||||
try:
|
||||
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||
# подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
||||
base = os.path.join(self.store.path, self.cam.id)
|
||||
base = os.path.join(self.store.path, self.sub_dir)
|
||||
now = datetime.now()
|
||||
for d in (now, now + timedelta(days=1)):
|
||||
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
|
||||
@@ -97,7 +98,8 @@ class RecorderTask:
|
||||
continue
|
||||
|
||||
self._set_state(CameraState.STARTING)
|
||||
args = record_args(self.cam, self.store.path, self.segment_seconds, self.input_url)
|
||||
args = record_args(self.cam, self.store.path, self.segment_seconds,
|
||||
self.input_url, sub_dir=self.sub_dir)
|
||||
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
|
||||
@@ -26,7 +26,8 @@ class Supervisor:
|
||||
# id (удалённое хранилище) → тоже откат на активное.
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
return RecorderTask(cam, store, self.config.storage.segment_seconds,
|
||||
on_status=self.on_status, input_url=self._input_url(cam))
|
||||
on_status=self.on_status, input_url=self._input_url(cam),
|
||||
sub_dir=self.config.archive_dir(cam.id))
|
||||
|
||||
def start_all(self) -> None:
|
||||
for cam in self.config.cameras:
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ services:
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
||||
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
||||
ports:
|
||||
- "443:8090" # дашборд на https://<сервер>
|
||||
- "443:8090" # десктоп-дашборд на https://<сервер>
|
||||
- "8443:8090" # мобильный dashboard https://<сервер>:8443 (тот же бэкенд, mobile.html по Host)
|
||||
volumes:
|
||||
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
||||
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vizion+ — Архив</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/player.css">
|
||||
</head>
|
||||
<body data-page="archive">
|
||||
<div class="layout">
|
||||
@@ -39,6 +40,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/player.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<div class="gb-split">
|
||||
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки">▦</button>
|
||||
<div class="gb-split-menu" id="gb-split-menu">
|
||||
<button data-grid="1">1</button>
|
||||
<button data-grid="2">4</button>
|
||||
<button data-grid="3">9</button>
|
||||
<button data-grid="4">16</button>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#161a22">
|
||||
<title>CoRE.Vizion+ — Mobile</title>
|
||||
<link rel="stylesheet" href="/static/player.css">
|
||||
<link rel="stylesheet" href="/static/mobile.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="m-app">
|
||||
<header class="m-head">
|
||||
<button class="m-burger" id="m-burger" aria-label="Меню">☰</button>
|
||||
<span class="m-title" id="m-title">Live</span>
|
||||
</header>
|
||||
|
||||
<!-- бутерброд-меню: выбор режима (Live / Архив) -->
|
||||
<div class="m-scrim" id="m-scrim" hidden></div>
|
||||
<aside class="m-drawer" id="m-drawer">
|
||||
<div class="m-drawer-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></div>
|
||||
<button class="m-nav active" data-view="live"><span class="i">▦</span>Live</button>
|
||||
<button class="m-nav" data-view="arch"><span class="i">🗄</span>Архив</button>
|
||||
</aside>
|
||||
|
||||
<!-- LIVE: одна камера + выбор камеры (плеер только пауза/плей) -->
|
||||
<section class="m-view" id="view-live">
|
||||
<div class="m-bar">
|
||||
<select id="m-live-cam" class="m-sel" title="Камера"></select>
|
||||
</div>
|
||||
<div class="m-stage"><div class="player-wrap" id="m-live-box"></div></div>
|
||||
</section>
|
||||
|
||||
<!-- АРХИВ: камера + дата + список записей (плеер с перемоткой) -->
|
||||
<section class="m-view" id="view-arch" hidden>
|
||||
<div class="m-bar">
|
||||
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
|
||||
<input type="date" id="m-arch-date" class="m-date" title="Дата">
|
||||
</div>
|
||||
<div class="m-stage"><div class="player-wrap" id="m-arch-box"></div></div>
|
||||
<div class="m-seglist" id="m-seglist"></div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/static/player.js"></script>
|
||||
<script src="/static/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-14
@@ -153,23 +153,18 @@
|
||||
<div class="card">
|
||||
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
||||
Для каждого потока выберите способ воспроизведения: <b>как есть</b> —
|
||||
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
|
||||
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
|
||||
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
|
||||
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
|
||||
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
|
||||
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
|
||||
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
|
||||
VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
||||
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b> —
|
||||
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
|
||||
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
|
||||
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
|
||||
(декод нагружает CPU клиента); <b>перекодировать</b> — сервер транскодирует
|
||||
в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
||||
</div>
|
||||
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
||||
<div class="tr-block" data-tr="1">
|
||||
<h3>1 поток (основной)</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-1" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-1" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-1" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
||||
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
<div class="tr-row"></div>
|
||||
@@ -178,9 +173,7 @@
|
||||
<div class="tr-block" data-tr="2">
|
||||
<h3>2 поток (суб)</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-2" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-2" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-2" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
||||
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
<div class="tr-row"></div>
|
||||
@@ -189,7 +182,6 @@
|
||||
<div class="tr-block" data-tr="a">
|
||||
<h3>Архив</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-a" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-a" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
|
||||
+134
-290
@@ -69,9 +69,9 @@ function transcodeQueryFor(which) { // строка запрос
|
||||
return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` +
|
||||
`&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`;
|
||||
}
|
||||
function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode"
|
||||
const m = transcodeProfile(which).mode;
|
||||
return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player";
|
||||
function profileMode(which) { // "player" | "transcode"
|
||||
// режимы "native" (как есть) и "webcodecs" (железо) упразднены → встроенный плеер
|
||||
return transcodeProfile(which).mode === "transcode" ? "transcode" : "player";
|
||||
}
|
||||
// раскладки: маппинг камер по ячейкам, на размерность (этот браузер)
|
||||
function getLayout(dim) {
|
||||
@@ -198,19 +198,6 @@ function connectStatusWS(onCameras) {
|
||||
ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000);
|
||||
}
|
||||
|
||||
// ── go2rtc плеер ────────────────────────────────────────────────
|
||||
let _rtcLoaded = null;
|
||||
function loadRtcComponent(go2rtcUrl) {
|
||||
if (_rtcLoaded) return _rtcLoaded;
|
||||
_rtcLoaded = new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = go2rtcUrl.replace(/\/$/, "") + "/video-rtc.js";
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error("go2rtc video-rtc.js не загрузился"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _rtcLoaded;
|
||||
}
|
||||
// ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ──
|
||||
// Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен,
|
||||
// сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU).
|
||||
@@ -219,8 +206,10 @@ const HEVC_WASM_JS = "/static/vendor/hevcjs/hevc-decode.esm.js";
|
||||
const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm";
|
||||
|
||||
class HevcWasmPlayer {
|
||||
constructor(box, wsUrl, useMain, onFail) {
|
||||
// external:true — не открывать свой WS; FLV-байты подаются методом feed() (SmartFlvPlayer)
|
||||
constructor(box, wsUrl, useMain, onFail, opts) {
|
||||
this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null;
|
||||
this.external = !!(opts && opts.external); this.ready = false; this.queue = [];
|
||||
this.closed = false; this.worker = null; this.ws = null;
|
||||
const cv = document.createElement("canvas");
|
||||
cv.className = "wc-canvas";
|
||||
@@ -234,8 +223,11 @@ class HevcWasmPlayer {
|
||||
try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; }
|
||||
this.worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "ready") this._openWS();
|
||||
else if (m.type === "err") console.warn("HEVC worker:", m.msg);
|
||||
if (m.type === "ready") {
|
||||
this.ready = true;
|
||||
if (this.external) { for (const b of this.queue) this._send(b); this.queue = []; }
|
||||
else this._openWS();
|
||||
} else if (m.type === "err") console.warn("HEVC worker:", m.msg);
|
||||
};
|
||||
this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); };
|
||||
this.worker.postMessage(
|
||||
@@ -251,6 +243,9 @@ class HevcWasmPlayer {
|
||||
this.ws.onclose = () => { /**/ };
|
||||
} catch (e) { /**/ }
|
||||
}
|
||||
_send(buf) { if (!this.closed && this.worker) try { this.worker.postMessage({ type: "data", buf }, [buf]); } catch (e) { /**/ } }
|
||||
// внешняя подача FLV-байт (external-режим): до готовности воркера — в очередь
|
||||
feed(buf) { if (this.closed) return; if (this.ready) this._send(buf); else this.queue.push(buf); }
|
||||
_fail(reason) {
|
||||
if (this.closed) return;
|
||||
const f = this.onFail; this.onFail = null;
|
||||
@@ -293,55 +288,7 @@ function bindFullscreenKey() {
|
||||
});
|
||||
}
|
||||
|
||||
// умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра
|
||||
let _hevc = null;
|
||||
function hevcSupported() {
|
||||
if (_hevc !== null) return _hevc;
|
||||
let ok = false;
|
||||
try {
|
||||
const v = document.createElement("video");
|
||||
const t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"');
|
||||
if (t === "probably" || t === "maybe") ok = true;
|
||||
if (!ok && window.MediaSource && MediaSource.isTypeSupported) {
|
||||
ok = MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') ||
|
||||
MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
_hevc = ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// нативный плеер go2rtc (open-source video-rtc.js): MSE/WebRTC, поддерживает H.265
|
||||
function makePlayer(go2rtcUrl, srcName, transcode) {
|
||||
const el = document.createElement("video-stream");
|
||||
// mse раньше webrtc — в Chrome HEVC доступен только в MSE; webrtc — фолбэк (Safari)
|
||||
el.setAttribute("mode", transcode ? "webrtc,mse" : "mse,webrtc");
|
||||
el.setAttribute("background", "false");
|
||||
const base = go2rtcUrl.replace(/\/$/, "");
|
||||
el.src = new URL(base + "/api/ws?src=" + encodeURIComponent(srcName));
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── свой WebCodecs-плеер H.265 (аппаратный декод на GPU клиента, 0% CPU сервера) ──
|
||||
// есть ли в браузере аппаратный HEVC-декодер через WebCodecs (кэш Promise)
|
||||
let _hevcHw = null;
|
||||
function hevcHwSupported() {
|
||||
if (_hevcHw) return _hevcHw;
|
||||
_hevcHw = (async () => {
|
||||
if (!("VideoDecoder" in window) || !window.isSecureContext) return false;
|
||||
const cands = ["hvc1.1.6.L153.B0", "hev1.1.6.L153.B0", "hvc1.2.4.L153.B0"];
|
||||
for (const codec of cands) {
|
||||
try {
|
||||
const r = await VideoDecoder.isConfigSupported(
|
||||
{ codec, codedWidth: 3200, codedHeight: 1800, hardwareAcceleration: "prefer-hardware" });
|
||||
if (r && r.supported) return true;
|
||||
} catch (e) { /* пробуем следующий */ }
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
return _hevcHw;
|
||||
}
|
||||
|
||||
// ── WebCodecs-плеер: декод на клиенте (для H.264 — единственный путь, H.265 идёт в WASM) ──
|
||||
// строка кодека из HEVCDecoderConfigurationRecord (hvcC)
|
||||
function hevcCodecFromHvcC(hvcC) {
|
||||
try {
|
||||
@@ -352,11 +299,21 @@ function hevcCodecFromHvcC(hvcC) {
|
||||
} catch (e) { return "hvc1.1.6.L153.B0"; }
|
||||
}
|
||||
|
||||
// демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры
|
||||
// строка кодека из AVCDecoderConfigurationRecord (avcC):
|
||||
// [1]=profile_idc, [2]=constraint_flags, [3]=level_idc → "avc1.PPCCLL" (hex)
|
||||
function avcCodecFromAvcC(avcC) {
|
||||
try {
|
||||
const h2 = (n) => n.toString(16).padStart(2, "0");
|
||||
return `avc1.${h2(avcC[1])}${h2(avcC[2])}${h2(avcC[3])}`;
|
||||
} catch (e) { return "avc1.4d0033"; }
|
||||
}
|
||||
|
||||
// демуксер FLV (enhanced-RTMP) → hvcC/avcC + length-prefixed кадры (HEVC или AVC)
|
||||
class FlvHevcDemuxer {
|
||||
constructor({ onConfig, onSample }) {
|
||||
this.onConfig = onConfig; this.onSample = onSample;
|
||||
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
||||
this.codecType = "hevc"; // 'hevc' | 'avc' — определяется из видеотега
|
||||
}
|
||||
push(ab) {
|
||||
const inc = new Uint8Array(ab);
|
||||
@@ -395,7 +352,9 @@ class FlvHevcDemuxer {
|
||||
if (isEx) {
|
||||
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
||||
const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]);
|
||||
if (fourcc !== "hvc1" && fourcc !== "hev1") return;
|
||||
if (fourcc === "hvc1" || fourcc === "hev1") this.codecType = "hevc";
|
||||
else if (fourcc === "avc1") this.codecType = "avc";
|
||||
else return;
|
||||
let off = 5;
|
||||
if (packetType === 1) off += 3; // CompositionTime
|
||||
const payload = body.subarray(off);
|
||||
@@ -403,16 +362,25 @@ class FlvHevcDemuxer {
|
||||
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
||||
} else {
|
||||
const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f;
|
||||
if (codecId !== 12 && codecId !== 7) return; // 12=HEVC legacy, 7=AVC
|
||||
if (codecId === 12) this.codecType = "hevc"; // 12=HEVC legacy
|
||||
else if (codecId === 7) this.codecType = "avc"; // 7=AVC (H.264)
|
||||
else return;
|
||||
const pkt = body[1], payload = body.subarray(5);
|
||||
if (pkt === 0) this._config(payload);
|
||||
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
||||
}
|
||||
}
|
||||
_config(hvcC) {
|
||||
if (this.gotConfig || hvcC.length < 13) return;
|
||||
this.gotConfig = true;
|
||||
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
|
||||
_config(rec) {
|
||||
if (this.gotConfig) return;
|
||||
if (this.codecType === "avc") {
|
||||
if (rec.length < 7) return;
|
||||
this.gotConfig = true;
|
||||
this.onConfig(rec.slice(), avcCodecFromAvcC(rec), "avc");
|
||||
} else {
|
||||
if (rec.length < 13) return;
|
||||
this.gotConfig = true;
|
||||
this.onConfig(rec.slice(), hevcCodecFromHvcC(rec), "hevc");
|
||||
}
|
||||
}
|
||||
_frames(payload, isKey, tsMs) {
|
||||
if (!this.gotConfig || payload.length === 0) return;
|
||||
@@ -421,9 +389,12 @@ class FlvHevcDemuxer {
|
||||
}
|
||||
|
||||
// плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → <canvas>
|
||||
// Декодирует и HEVC (H.265), и AVC (H.264) — кодек берётся из config-пакета FLV.
|
||||
// external:true — не открывать свой WS, поток подаётся через feed() (для SmartFlvPlayer).
|
||||
class WebCodecsHevcPlayer {
|
||||
constructor(box, wsUrl, onFail) {
|
||||
constructor(box, wsUrl, onFail, opts) {
|
||||
this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null;
|
||||
this.external = !!(opts && opts.external);
|
||||
this.closed = false; this.configured = false; this.pending = null; this.rafId = 0;
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.className = "wc-canvas";
|
||||
@@ -431,6 +402,7 @@ class WebCodecsHevcPlayer {
|
||||
this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true });
|
||||
this._start();
|
||||
}
|
||||
feed(ab) { if (!this.closed) { try { this.demux.push(ab); } catch (e) { /**/ } } }
|
||||
_fail(reason) {
|
||||
if (this.closed) return;
|
||||
const f = this.onFail; this.onFail = null;
|
||||
@@ -452,10 +424,10 @@ class WebCodecsHevcPlayer {
|
||||
} catch (e) { this._fail("ctor"); return; }
|
||||
|
||||
this.demux = new FlvHevcDemuxer({
|
||||
onConfig: (hvcC, codec) => {
|
||||
console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length);
|
||||
onConfig: (rec, codec, codecType) => {
|
||||
console.log("WC: config received codec=", codec, "type=", codecType, "len=", rec.length);
|
||||
try {
|
||||
this.decoder.configure({ codec, description: hvcC, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" });
|
||||
this.decoder.configure({ codec, description: rec, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" });
|
||||
this.configured = true;
|
||||
console.log("WC: configure() ok, state=", this.decoder.state);
|
||||
} catch (e) { console.warn("WC: configure threw:", e && (e.message || e)); this._fail("configure:" + (e && e.name || "")); }
|
||||
@@ -472,6 +444,7 @@ class WebCodecsHevcPlayer {
|
||||
},
|
||||
});
|
||||
|
||||
if (this.external) return; // поток подаётся через feed() извне
|
||||
try {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
@@ -499,6 +472,68 @@ class WebCodecsHevcPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
// ── единый «Встроенный плеер»: автодетект кодека из FLV-потока ──
|
||||
// Одно WS-соединение, кодек берётся из первого config-пакета. «+»-варианты
|
||||
// (H.264+/H.265+, Hikvision SmartCodec) — тот же битстрим, поэтому различаем
|
||||
// только AVC (H.264/H.264+) и HEVC (H.265/H.265+):
|
||||
// • HEVC → WASM hevc.js (софт-декод в Web Worker) — как и было;
|
||||
// • AVC → WebCodecs (avc1) — WASM-декодера H.264 в проекте нет, поэтому
|
||||
// H.264 декодирует сам браузер (внутренняя деталь, не отдельный режим).
|
||||
// До определения кодека сырые FLV-чанки буферизуются и затем переигрываются
|
||||
// выбранному декодеру → реконнекта нет.
|
||||
class SmartFlvPlayer {
|
||||
constructor(box, wsUrl, useMain, onFail) {
|
||||
this.box = box; this.wsUrl = wsUrl; this.useMain = useMain;
|
||||
this.onFail = onFail || null;
|
||||
this.closed = false; this.child = null; this.deciding = false; this.rawBuf = [];
|
||||
this.sniff = new FlvHevcDemuxer({
|
||||
onConfig: (rec, codec, codecType) => this._decide(codecType),
|
||||
onSample: () => { /* до выбора декодера кадры не нужны */ },
|
||||
});
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
this.ws.onmessage = (ev) => this._onChunk(ev.data);
|
||||
this.ws.onerror = () => { /**/ };
|
||||
this.ws.onclose = () => { if (!this.closed && !this.child) this._fail("ws-closed"); };
|
||||
} catch (e) { this._fail("ws"); }
|
||||
}
|
||||
_onChunk(ab) {
|
||||
if (this.closed) return;
|
||||
if (this.child) { this.child.feed(ab); return; }
|
||||
this.rawBuf.push(ab.slice(0)); // копия для переигровки выбранному декодеру
|
||||
try { this.sniff.push(ab); } catch (e) { /**/ }
|
||||
}
|
||||
_decide(codecType) {
|
||||
if (this.closed || this.child || this.deciding) return;
|
||||
this.deciding = true;
|
||||
if (codecType === "avc") { // H.264/H.264+ → WebCodecs (браузер)
|
||||
this._attach(new WebCodecsHevcPlayer(this.box, null, (r) => this._fail(r), { external: true }));
|
||||
} else { // H.265/H.265+ → WASM hevc.js
|
||||
this._attach(new HevcWasmPlayer(this.box, null, this.useMain, (r) => this._fail(r), { external: true }));
|
||||
}
|
||||
}
|
||||
_attach(child) {
|
||||
if (this.closed) { try { child.destroy(); } catch (e) { /**/ } return; }
|
||||
this.child = child;
|
||||
for (const b of this.rawBuf) child.feed(b);
|
||||
this.rawBuf = [];
|
||||
}
|
||||
_fail(reason) {
|
||||
if (this.closed) return;
|
||||
const f = this.onFail; this.onFail = null;
|
||||
this.destroy();
|
||||
if (f) f(reason);
|
||||
}
|
||||
resize() { if (this.child && this.child.resize) try { this.child.resize(); } catch (e) { /**/ } }
|
||||
destroy() {
|
||||
this.closed = true;
|
||||
try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ }
|
||||
this.ws = null;
|
||||
if (this.child) { try { this.child.destroy(); } catch (e) { /**/ } this.child = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// Страница LIVE VIEW
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
@@ -518,11 +553,7 @@ const Live = {
|
||||
this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл
|
||||
this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64
|
||||
this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || "";
|
||||
// встроенный плеер (hevc.js WASM) грузит свой worker по требованию — предзагрузка не нужна
|
||||
if (this.go2rtcUrl && (this.mode1 === "native" || this.mode2 === "native")) { // «как есть»
|
||||
try { await loadRtcComponent(this.go2rtcUrl); } catch (e) { console.warn(e); }
|
||||
}
|
||||
|
||||
// встроенный плеер (WASM/WebCodecs) грузит декодер по требованию — предзагрузка не нужна
|
||||
this.applyMaxDim();
|
||||
this.setDim(this.initialDim(), false);
|
||||
this.bindSubnav();
|
||||
@@ -547,13 +578,13 @@ const Live = {
|
||||
initialDim() {
|
||||
const max = this.maxDim || 8;
|
||||
const q = parseInt(new URLSearchParams(location.search).get("grid"), 10);
|
||||
if (q >= 2 && q <= 8) return Math.min(q, max);
|
||||
if (q >= 1 && q <= 8) return Math.min(q, max);
|
||||
const saved = parseInt(localStorage.getItem("corevision.grid"), 10);
|
||||
return saved >= 2 ? Math.min(saved, max) : 2;
|
||||
return saved >= 1 ? Math.min(saved, max) : 2;
|
||||
},
|
||||
|
||||
setDim(dim, rerender = true) {
|
||||
this.dim = Math.min(this.maxDim || 8, Math.max(2, dim));
|
||||
this.dim = Math.min(this.maxDim || 8, Math.max(1, dim));
|
||||
this.layout = this.dim * this.dim;
|
||||
localStorage.setItem("corevision.grid", String(this.dim));
|
||||
this.page = 0; // смена размера сетки → первая страница
|
||||
@@ -695,52 +726,22 @@ const Live = {
|
||||
const which = useMain ? "1" : "2"; // профиль потока
|
||||
const mode = profileMode(which);
|
||||
if (mode === "transcode") return this.transcodeVideo(cam, useMain);
|
||||
if (mode === "native") { // «как есть»: нативный go2rtc-плеер
|
||||
if (!this.go2rtcUrl) return null;
|
||||
const src = useMain ? (cam.id + "_main") : cam.live.src;
|
||||
const el = makePlayer(this.go2rtcUrl, src, false); // браузер сам декодирует HEVC
|
||||
el.dataset.kind = "native";
|
||||
return el;
|
||||
}
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${location.host}/api/cameras/${cam.id}/flv?stream=${useMain ? "main" : "sub"}`;
|
||||
const box = document.createElement("div");
|
||||
box.className = "hevc-player";
|
||||
const startWasm = () => { // встроенный плеер: hevc.js WASM в Web Worker
|
||||
box.dataset.kind = "wasm";
|
||||
box._hevc = new HevcWasmPlayer(box, url, useMain, () => {
|
||||
if (box.isConnected) box.innerHTML = '<div class="empty">плеер не запустился</div>';
|
||||
});
|
||||
};
|
||||
if (mode === "webcodecs") { // свой плеер: WebCodecs (HW), 0% CPU сервера
|
||||
box.dataset.kind = "webcodecs";
|
||||
const fallback = (msg) => {
|
||||
if (!box.isConnected) return;
|
||||
const h = document.createElement("div");
|
||||
h.className = "hevc-hint";
|
||||
h.textContent = msg;
|
||||
box.appendChild(h);
|
||||
startWasm(); // запасной — WASM-декод в воркере
|
||||
};
|
||||
hevcHwSupported().then((hw) => {
|
||||
if (!box.isConnected) return;
|
||||
if (!hw) { // нет аппаратного HEVC в этом браузере
|
||||
fallback("Нет аппаратного H.265 (WebCodecs) в этом браузере — запасной WASM-декодер");
|
||||
return;
|
||||
}
|
||||
box._wc = new WebCodecsHevcPlayer(box, url, (reason) =>
|
||||
fallback("WebCodecs ошибка (" + reason + ") — запасной WASM-декодер"));
|
||||
});
|
||||
return box;
|
||||
}
|
||||
// встроенный плеер: hevc.js (клиентский WASM-декод H.265 + тайлы, в Web Worker)
|
||||
startWasm();
|
||||
box.dataset.kind = "player";
|
||||
// встроенный плеер: автодетект кодека (H.265/H.265+ → WASM, H.264/H.264+ → WebCodecs браузера)
|
||||
box._smart = new SmartFlvPlayer(box, url, useMain, () => {
|
||||
if (box.isConnected) box.innerHTML = '<div class="empty">плеер не запустился</div>';
|
||||
});
|
||||
return box;
|
||||
},
|
||||
|
||||
// уничтожить плеер-инстансы внутри ячейки (WASM-воркер/WebCodecs держат worker/decoder)
|
||||
destroyTilePlayers(tile) {
|
||||
tile.querySelectorAll(".hevc-player").forEach((b) => {
|
||||
if (b._smart) { try { b._smart.destroy(); } catch (e) { /* ignore */ } b._smart = null; }
|
||||
if (b._wc) { try { b._wc.destroy(); } catch (e) { /* ignore */ } b._wc = null; }
|
||||
if (b._hevc) { try { b._hevc.destroy(); } catch (e) { /* ignore */ } b._hevc = null; }
|
||||
});
|
||||
@@ -767,13 +768,6 @@ const Live = {
|
||||
tile.insertBefore(d, tile.firstChild); return;
|
||||
}
|
||||
tile.insertBefore(p, tile.firstChild);
|
||||
// подсказка только для нативного go2rtc-плеера, если браузер не умеет HEVC
|
||||
if (p.dataset.kind === "native" && !hevcSupported()) {
|
||||
const h = document.createElement("div");
|
||||
h.className = "hevc-hint";
|
||||
h.textContent = "Браузер не декодирует H.265 — выберите «встроенный плеер» в настройках";
|
||||
tile.appendChild(h);
|
||||
}
|
||||
},
|
||||
|
||||
renderGrid() {
|
||||
@@ -841,6 +835,7 @@ const Live = {
|
||||
resizePlayers() { // пересчитать canvas плееров после смены размера ячейки
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll(".hevc-player").forEach((b) => {
|
||||
if (b._smart && typeof b._smart.resize === "function") { try { b._smart.resize(); } catch (e) { /**/ } }
|
||||
if (b._wc && typeof b._wc.resize === "function") { try { b._wc.resize(); } catch (e) { /**/ } }
|
||||
if (b._hevc && typeof b._hevc.resize === "function") { try { b._hevc.resize(); } catch (e) { /**/ } }
|
||||
});
|
||||
@@ -894,162 +889,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,
|
||||
@@ -1321,6 +1162,8 @@ const Archive = {
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const CamSettings = {
|
||||
SLOTS: 64,
|
||||
// номер слота с ведущим нулём: 1‑9 → 01‑09, 10+ без изменений
|
||||
slotId(n) { return String(n).padStart(2, "0"); },
|
||||
editing: false, // идёт инлайн-редактирование — не перерисовываем по WS
|
||||
bulk: false, // режим массового редактирования всей таблицы
|
||||
cams: [],
|
||||
@@ -1420,7 +1263,7 @@ const CamSettings = {
|
||||
const record = cam ? cam.record : true;
|
||||
const storageId = cam ? cam.storage_id : "";
|
||||
return (
|
||||
`<td class="muted">${slotNo}</td>` +
|
||||
`<td class="muted">${this.slotId(slotNo)}</td>` +
|
||||
`<td><input class="cell-in" type="text" data-f="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` +
|
||||
`<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></td>` +
|
||||
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` +
|
||||
@@ -1616,7 +1459,7 @@ const CamSettings = {
|
||||
: `<span class="muted">выключена</span>`;
|
||||
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
||||
tr.innerHTML =
|
||||
`<td class="muted">${i + 1}</td>` +
|
||||
`<td class="muted">${this.slotId(i + 1)}</td>` +
|
||||
`<td>${c.name}</td>` +
|
||||
`<td class="muted">${c.ip}</td>` +
|
||||
`<td class="muted">${streams}</td>` +
|
||||
@@ -1630,7 +1473,7 @@ const CamSettings = {
|
||||
tr.className = "slot-empty";
|
||||
tr.dataset.slot = i + 1;
|
||||
tr.innerHTML =
|
||||
`<td class="muted">${i + 1}</td>` +
|
||||
`<td class="muted">${this.slotId(i + 1)}</td>` +
|
||||
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
|
||||
`<td class="muted">пусто</td>` +
|
||||
`<td></td>`;
|
||||
@@ -1814,7 +1657,8 @@ const Settings = {
|
||||
row.innerHTML = this.trRowHtml(pr);
|
||||
// режим: проставить выбранный radio
|
||||
const radios = block.querySelectorAll(`input[name="mode-${which}"]`);
|
||||
radios.forEach((r) => { r.checked = (r.value === pr.mode); });
|
||||
const dispMode = pr.mode === "transcode" ? "transcode" : "player"; // native/webcodecs → встроенный
|
||||
radios.forEach((r) => { r.checked = (r.value === dispMode); });
|
||||
// показывать ряд параметров только в режиме «Перекодировать»
|
||||
const syncMode = () => {
|
||||
const sel = block.querySelector(`input[name="mode-${which}"]:checked`);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, бутерброд-меню, фирменный плеер. */
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
|
||||
font: 15px/1.4 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-app { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
/* ── шапка с бутербродом ── */
|
||||
.m-head {
|
||||
height: 48px; flex: none; display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 8px; background: #161a22; border-bottom: 1px solid #272d3a;
|
||||
position: relative; z-index: 30;
|
||||
}
|
||||
.m-burger { background: none; border: 0; color: #e6e9ef; font-size: 22px; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.m-burger:active { background: #1d2230; }
|
||||
.m-title { font-weight: 600; font-size: 16px; letter-spacing: .3px; }
|
||||
.m-brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; }
|
||||
.m-brand span { color: #c0392b; }
|
||||
|
||||
/* ── бутерброд-меню: боковая шторка + затемнение ── */
|
||||
.m-scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; }
|
||||
.m-scrim[hidden] { display: none; }
|
||||
.m-drawer {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; width: 240px; max-width: 80vw; z-index: 50;
|
||||
background: #161a22; border-right: 1px solid #272d3a;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(-100%); transition: transform .22s ease;
|
||||
}
|
||||
.m-drawer.open { transform: translateX(0); }
|
||||
.m-drawer-head { height: 56px; flex: none; display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #272d3a; }
|
||||
.m-nav {
|
||||
display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
|
||||
background: none; border: 0; border-left: 3px solid transparent; color: #cdd3e0;
|
||||
padding: 16px 18px; font: 600 16px/1 inherit; cursor: pointer;
|
||||
}
|
||||
.m-nav .i { font-size: 20px; width: 24px; text-align: center; }
|
||||
.m-nav:active { background: #1d2230; }
|
||||
.m-nav.active { color: #fff; background: #1d2230; border-left-color: #c0392b; }
|
||||
|
||||
/* ── режимы ── */
|
||||
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.m-view[hidden] { display: none; } /* hidden должен реально скрывать (иначе display:flex перебивает) */
|
||||
|
||||
.m-bar { flex: none; display: flex; gap: 8px; padding: 8px; background: #161a22; border-bottom: 1px solid #272d3a; }
|
||||
.m-sel, .m-date {
|
||||
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
||||
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
.m-date { flex: 0 0 auto; }
|
||||
|
||||
/* сцена плеера: live — на всё доступное место, архив — 16:9 (ниже список записей) */
|
||||
.m-stage { background: #000; position: relative; min-height: 0; }
|
||||
#view-live .m-stage { flex: 1; }
|
||||
#view-arch .m-stage { flex: none; aspect-ratio: 16 / 9; }
|
||||
.player-wrap { width: 100%; height: 100%; }
|
||||
|
||||
/* список записей архива */
|
||||
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
|
||||
.m-seg {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
|
||||
background: #161a22; color: #e6e9ef; border: 1px solid #272d3a; border-radius: 8px;
|
||||
padding: 13px 14px; margin-bottom: 6px; font-size: 15px; font-variant-numeric: tabular-nums; cursor: pointer;
|
||||
}
|
||||
.m-seg:active { background: #1d2230; }
|
||||
.m-seg.active { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||
.m-dur { color: #8b93a7; font-size: 13px; }
|
||||
.m-seg.active .m-dur { color: #fff; }
|
||||
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
||||
|
||||
/* фирменный плеер (.pjs) — общий static/player.css; ниже только тач-подгонка размеров */
|
||||
.pjs-btn { font-size: 20px; padding: 6px 8px; }
|
||||
.pjs-seek { height: 6px; }
|
||||
.pjs-seek:hover { height: 6px; }
|
||||
.pjs-knob { width: 16px; height: 16px; margin-left: -8px; }
|
||||
.pjs-bar { gap: 12px; padding-bottom: calc(11px + env(safe-area-inset-bottom, 0)); }
|
||||
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||
@@ -0,0 +1,120 @@
|
||||
// CoRE.Vizion+ — мобильный dashboard. Оба режима через фирменный плеер (.pjs):
|
||||
// Live — только пауза/плей + во весь экран; Архив — перемотка + скорость.
|
||||
// Видео отдаётся сервером уже перекодированным в H.264 (играет на любом телефоне).
|
||||
"use strict";
|
||||
|
||||
// фиксированный профиль H.264 для телефона (макс. совместимость)
|
||||
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
|
||||
|
||||
function pad(n) { return String(n).padStart(2, "0"); }
|
||||
|
||||
async function api(url) {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
|
||||
|
||||
const M = {
|
||||
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
||||
|
||||
async init() {
|
||||
let data;
|
||||
try { data = await api("/api/cameras"); } catch (e) { return; }
|
||||
this.cams = data.cameras || [];
|
||||
const enabled = this.cams.filter((c) => c.enabled);
|
||||
|
||||
const lc = document.getElementById("m-live-cam");
|
||||
const ac = document.getElementById("m-arch-cam");
|
||||
lc.innerHTML = enabled.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||
ac.innerHTML = this.cams.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||
this.liveId = enabled[0] ? enabled[0].id : null;
|
||||
this.archId = this.cams[0] ? this.cams[0].id : null;
|
||||
lc.onchange = () => { this.liveId = lc.value; this.startLive(); };
|
||||
ac.onchange = () => { this.archId = ac.value; this.loadArch(); };
|
||||
|
||||
const dt = document.getElementById("m-arch-date");
|
||||
const t = new Date();
|
||||
this.date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
|
||||
dt.value = this.date;
|
||||
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
|
||||
|
||||
// бутерброд-меню сверху: выбор режима (Live / Архив)
|
||||
const drawer = document.getElementById("m-drawer");
|
||||
const scrim = document.getElementById("m-scrim");
|
||||
const setMenu = (open) => { drawer.classList.toggle("open", open); scrim.hidden = !open; };
|
||||
document.getElementById("m-burger").onclick = () => setMenu(!drawer.classList.contains("open"));
|
||||
scrim.onclick = () => setMenu(false);
|
||||
document.querySelectorAll(".m-nav").forEach((b) => {
|
||||
b.onclick = () => { this.showView(b.dataset.view); setMenu(false); };
|
||||
});
|
||||
|
||||
this.showView("live");
|
||||
},
|
||||
|
||||
_kill() { if (this.player) { this.player.destroy(); this.player = null; } },
|
||||
|
||||
showView(v) {
|
||||
this.view = v;
|
||||
document.getElementById("m-title").textContent = v === "live" ? "Live" : "Архив";
|
||||
document.querySelectorAll(".m-nav").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
|
||||
document.getElementById("view-live").hidden = v !== "live";
|
||||
document.getElementById("view-arch").hidden = v !== "arch";
|
||||
this._kill();
|
||||
if (v === "live") this.startLive();
|
||||
else this.loadArch();
|
||||
},
|
||||
|
||||
// ── Live ──
|
||||
startLive() {
|
||||
this._kill();
|
||||
const box = document.getElementById("m-live-box");
|
||||
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
||||
this.player = new ArchivePlayer(box, { live: true, autohide: false, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
|
||||
},
|
||||
|
||||
// ── Архив ──
|
||||
async loadArch() {
|
||||
this._kill();
|
||||
const box = document.getElementById("m-arch-box");
|
||||
const list = document.getElementById("m-seglist");
|
||||
box.innerHTML = '<div class="m-empty">Выберите запись ниже</div>';
|
||||
if (!this.archId || !this.date) { list.innerHTML = ""; return; }
|
||||
const [y, m, d] = this.date.split("-").map(Number);
|
||||
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||||
const to = from + 86400;
|
||||
list.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
||||
let recs = [];
|
||||
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
||||
catch (e) { list.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
||||
if (!recs.length) { list.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
||||
this.segs = recs;
|
||||
list.innerHTML = recs.map((r) => {
|
||||
const tt = new Date(r.started_at * 1000);
|
||||
const mins = Math.round((r.duration_s || 0) / 60);
|
||||
return `<button class="m-seg" data-id="${r.id}">` +
|
||||
`${pad(tt.getHours())}:${pad(tt.getMinutes())}:${pad(tt.getSeconds())}` +
|
||||
`<span class="m-dur">${mins} мин</span></button>`;
|
||||
}).join("");
|
||||
list.onclick = (e) => {
|
||||
const b = e.target.closest(".m-seg");
|
||||
if (!b) return;
|
||||
list.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
||||
this.playSeg(+b.dataset.id);
|
||||
};
|
||||
},
|
||||
playSeg(id) {
|
||||
this._kill();
|
||||
const seg = this.segs.find((r) => r.id === id);
|
||||
const box = document.getElementById("m-arch-box");
|
||||
this.player = new ArchivePlayer(box, {
|
||||
mode: "transcode", autohide: false,
|
||||
duration: seg ? (seg.duration_s || 0) : 0,
|
||||
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => M.init());
|
||||
@@ -0,0 +1,48 @@
|
||||
/* CoRE.Vizion+ — фирменный плеер (.pjs). Общие стили: десктоп (архив) и мобила. */
|
||||
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
||||
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
||||
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
|
||||
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
||||
.pjs-bigplay {
|
||||
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
||||
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
|
||||
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
|
||||
transform: translate(-50%, -50%) scale(.9);
|
||||
}
|
||||
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
|
||||
.pjs-spinner {
|
||||
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
|
||||
@keyframes pjs-spin { to { transform: rotate(360deg); } }
|
||||
/* нижняя панель управления */
|
||||
.pjs-bar {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.8));
|
||||
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
|
||||
.pjs-btn {
|
||||
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
|
||||
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
|
||||
}
|
||||
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
|
||||
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
|
||||
/* шкала перемотки */
|
||||
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
||||
.pjs-seek:hover { height: 7px; }
|
||||
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||||
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
|
||||
.pjs-knob {
|
||||
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
|
||||
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
|
||||
}
|
||||
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
|
||||
.pjs.fs:not(.show) { cursor: none; }
|
||||
|
||||
/* live-режим: только пауза/плей + во весь экран (без шкалы/времени/скорости) */
|
||||
.pjs.live .pjs-time, .pjs.live .pjs-dur, .pjs.live .pjs-seek, .pjs.live .pjs-speed { display: none; }
|
||||
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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) { /**/ }
|
||||
}
|
||||
}
|
||||
@@ -300,50 +300,7 @@ footer.grid-bar {
|
||||
.player-wrap video { max-width: 100%; max-height: 100%; }
|
||||
.player-wrap .hint { color: var(--text-dim); }
|
||||
|
||||
/* ── плеер архива в стиле Playerjs ── */
|
||||
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
||||
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
||||
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
|
||||
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
||||
.pjs-bigplay {
|
||||
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
||||
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
|
||||
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
|
||||
transform: translate(-50%, -50%) scale(.9);
|
||||
}
|
||||
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
|
||||
.pjs-spinner {
|
||||
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
|
||||
@keyframes pjs-spin { to { transform: rotate(360deg); } }
|
||||
/* нижняя панель управления */
|
||||
.pjs-bar {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.8));
|
||||
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
|
||||
.pjs-btn {
|
||||
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
|
||||
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
|
||||
}
|
||||
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
|
||||
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
|
||||
/* шкала перемотки */
|
||||
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
||||
.pjs-seek:hover { height: 7px; }
|
||||
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||||
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
|
||||
.pjs-knob {
|
||||
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
|
||||
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
|
||||
}
|
||||
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
|
||||
.pjs.fs:not(.show) { cursor: none; }
|
||||
/* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
|
||||
|
||||
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
||||
.timeline {
|
||||
|
||||
Reference in New Issue
Block a user