Compare commits

..

9 Commits

Author SHA1 Message Date
git_admin aa290a51b7 v160 2026-06-03 16:09:49 +05:00
git_admin 847c87fd7e v158 2026-06-02 14:02:09 +05:00
git_admin f263d84a8d player: extract branded .pjs player into shared static/player.{js,css}
One source of truth for the branded player on desktop (archive) and
mobile. The ArchivePlayer class moves out of app.js into player.js
(name kept; app.js still does `new ArchivePlayer`); the .pjs styles
move out of style.css into player.css. Both archive.html and mobile.html
load the shared module before their own script.

The shared player gains two additive, backward-compatible options:
- live: timeline hidden, only pause/play + fullscreen (mobile Live).
- autohide:false: control bar always visible (touch); desktop keeps
  autohide (default true), so desktop archive behaves exactly as before.
Fullscreen is now self-contained (no global toggleFullscreen dep) so it
works on mobile too, with an iOS <video> fallback.

mobile.js drops its duplicated Player class and uses ArchivePlayer.

Verified on .79: /archive 200 loads player.{js,css}; app.js has no
class def; mobile (Host :8443) loads the shared module.

VERSION 0.0.154 -> 0.0.155

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:28:04 +05:00
git_admin 6f9142a795 mobile: both modes use the branded .pjs player
Replace the split mobile UI (live = round pause button, archive = native
<video controls>) with one branded player for both modes, ported from the
desktop ArchivePlayer (.pjs) and kept self-contained in the mobile bundle
so the live desktop is untouched.

- Live: branded player with pause/play + fullscreen only (no seekbar).
- Archive: branded player with full seek (transcode virtual timeline,
  seek = restart transcode with -ss) + playback speed (0.5/1/2/5x).
- H.264 server transcode path unchanged; touch-friendly always-on bar.

Verified end-to-end on .79: live.mp4 200 video/mp4, play.mp4 200,
duration_s present for the seek timeline.

VERSION 0.0.153 -> 0.0.154

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:08:55 +05:00
git_admin cfb125784d mobile: fix both modes showing at once (hidden view not hidden)
[hidden] sets display:none, but .m-view{display:flex} has equal
specificity and comes later, so it overrode the hidden attribute and
both #view-live and #view-arch rendered together. Add
.m-view[hidden]{display:none} so the inactive mode is actually hidden.

VERSION 0.0.152 -> 0.0.153

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:59:07 +05:00
git_admin c408fcbe92 mobile: hamburger drawer for mode select; live=pause/play, archive=seek
Move mode selection (Live / Архив) into a top hamburger side-drawer
instead of the bottom bar. Header now shows the ☰ button + current mode
title. Live view has a single pause/play button (no seek bar); Archive
keeps native controls for seeking (перемотка).

VERSION 0.0.151 -> 0.0.152

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:55:20 +05:00
git_admin 126b588dc7 mobile: one mode-toggle button instead of two bottom tabs
Replace the two-tab bottom bar (Live / Архив) with a single centered
toggle button. It switches the mode either/or: shows the current mode
with a swap glyph (⇆), tap flips Live ⇄ Архив.

VERSION 0.0.150 -> 0.0.151

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:49:02 +05:00
git_admin c91f82bf28 mobile: add a phone dashboard on a second port (8443) — Live + Archive
A new mobile dashboard is served on a second host port (8443) mapped to the
same backend, so the recorder/supervisor is not duplicated. main.py detects
the mobile port via the Host header and serves mobile.html instead of the
desktop index. The mobile SPA has two modes: Live (one camera with a selector)
and Archive (camera + date + segment list). Video is delivered as server-side
H.264 (live.mp4 / play.mp4 transcode) so a plain <video> plays on any phone.
Bottom tab bar, dark theme, safe-area aware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:36:50 +05:00
git_admin 157893b516 fix: pin archive layout to row; fit sidebar version into the 72px rail
Layout review found archive-main relied on the default flex-direction while
the base `main` rule forces column, so the sidebar/content split was fragile —
now set explicitly to row. The sidebar app-version pill is shrunk and centred
so it fits the 72px icon rail instead of being clipped. HTML tag balance, CSS
brace balance and all table column counts (cameras 9, storages 9, users 4)
checked out clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:19:55 +05:00
19 changed files with 708 additions and 376 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.148
0.0.160
+9 -2
View File
@@ -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}
+23
View File
@@ -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
View File
@@ -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")
+4 -3
View File
@@ -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",
+25 -12
View File
@@ -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
+6 -4
View File
@@ -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(
+2 -1
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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>
+1
View File
@@ -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>
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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`);
+78
View File
@@ -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; }
+120
View File
@@ -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());
+48
View File
@@ -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; }
+185
View File
@@ -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) { /**/ }
}
}
+4 -46
View File
@@ -96,7 +96,7 @@ aside {
.subitem.active { color: #fff; border-left-color: #c0392b; background: var(--panel-2); }
.subitem.subfs { padding-left: 16px; margin-top: 4px; border-top: 1px solid var(--border); padding-top: 8px; }
.subitem.subplay { padding-left: 16px; font-weight: 600; color: var(--text); margin-bottom: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.app-version { padding: 8px 16px; font-size: 11px; color: var(--text-dim); }
.app-version { padding: 6px 2px; font-size: 10px; color: var(--text-dim); text-align: center; width: 100%; }
.nav-item.logout { color: var(--text-dim); border-top: 1px solid var(--border); }
.nav-item.logout:hover { color: #e57373; }
@@ -250,7 +250,8 @@ footer.grid-bar {
}
/* ── Архив ── */
.archive-main { flex: 1; display: flex; overflow: hidden; } /* сайдбар + контент */
/* в ряд: сайдбар 220px слева + контент справа (явно, т.к. базовое main = column) */
.archive-main { flex: 1; display: flex; flex-direction: row; overflow: hidden; }
/* левый сайдбар архива: 220px, поделён по высоте пополам */
.arch-side {
width: 220px; flex: none; display: flex; flex-direction: column;
@@ -299,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 {