Compare commits
2 Commits
6f9142a795
...
847c87fd7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 847c87fd7e | |||
| f263d84a8d |
@@ -285,12 +285,19 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
|||||||
@router.delete("/{cam_id}")
|
@router.delete("/{cam_id}")
|
||||||
async def delete_camera(cam_id: str, request: Request) -> dict:
|
async def delete_camera(cam_id: str, request: Request) -> dict:
|
||||||
require_role(request, "admin")
|
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")
|
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)
|
save_config(runtime.config)
|
||||||
await runtime.supervisor.remove_camera(cam_id)
|
await runtime.supervisor.remove_camera(cam_id)
|
||||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
|
if shifts:
|
||||||
|
await runtime.supervisor.restart_all()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,29 @@ class Config:
|
|||||||
def enabled_cameras(self) -> list[Camera]:
|
def enabled_cameras(self) -> list[Camera]:
|
||||||
return [c for c in self.cameras if c.enabled]
|
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:
|
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||||
return next((s for s in self.storage.items if s.id == sid), None)
|
return next((s for s in self.storage.items if s.id == sid), None)
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ from ..config import Camera
|
|||||||
|
|
||||||
|
|
||||||
def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
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 копированием.
|
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||||
|
|
||||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||||
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||||
|
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
|
||||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
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, поэтому каталоги даты (сегодня+завтра)
|
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||||
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
# заранее создаёт 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 [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
|||||||
@@ -32,16 +32,17 @@ def _parse_started_at(path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
|
"""Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
|
||||||
|
легаси-id камеры — сопоставление в камеру делает вызывающий).
|
||||||
|
|
||||||
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||||
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
<folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||||
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
||||||
result: dict[str, list[str]] = {}
|
result: dict[str, list[str]] = {}
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
return result
|
return result
|
||||||
for cam_id in os.listdir(root):
|
for folder in os.listdir(root):
|
||||||
cam_dir = os.path.join(root, cam_id)
|
cam_dir = os.path.join(root, folder)
|
||||||
if not os.path.isdir(cam_dir):
|
if not os.path.isdir(cam_dir):
|
||||||
continue
|
continue
|
||||||
files: list[str] = []
|
files: list[str] = []
|
||||||
@@ -58,7 +59,7 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
|
|||||||
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
|
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
|
||||||
files.sort(key=os.path.basename)
|
files.sort(key=os.path.basename)
|
||||||
if files:
|
if files:
|
||||||
result[cam_id] = files
|
result[folder] = files
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -102,9 +103,10 @@ class Indexer:
|
|||||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
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):
|
if store.type == "nas" and not os.path.ismount(store.path):
|
||||||
continue
|
continue
|
||||||
|
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
|
||||||
for d in days:
|
for d in days:
|
||||||
try:
|
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:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -115,12 +117,23 @@ class Indexer:
|
|||||||
|
|
||||||
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
||||||
for root in self.config.storage_paths():
|
for root in self.config.storage_paths():
|
||||||
by_cam = _scan_segments(root)
|
by_dir = _scan_segments(root)
|
||||||
is_active = (root == active_path)
|
is_active = (root == active_path)
|
||||||
for cam_id, files in by_cam.items():
|
for folder, files in by_dir.items():
|
||||||
# в активном хранилище последний файл ещё пишется — пропускаем;
|
# Сопоставление папки с камерой по приоритету:
|
||||||
# в неактивных все сегменты завершены, индексируем целиком.
|
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
|
||||||
complete = files[:-1] if is_active else files
|
# иначе папка камеры с 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:
|
for path in complete:
|
||||||
if path in known:
|
if path in known:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ class RecorderTask:
|
|||||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||||
|
|
||||||
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
|
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.cam = cam
|
||||||
self.store = store # активное хранилище (куда пишем)
|
self.store = store # активное хранилище (куда пишем)
|
||||||
self.segment_seconds = segment_seconds
|
self.segment_seconds = segment_seconds
|
||||||
|
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
|
||||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||||
self.status = CameraStatus(
|
self.status = CameraStatus(
|
||||||
@@ -84,9 +85,9 @@ class RecorderTask:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
# подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||||
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
||||||
base = os.path.join(self.store.path, self.cam.id)
|
base = os.path.join(self.store.path, self.sub_dir)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
for d in (now, now + timedelta(days=1)):
|
for d in (now, now + timedelta(days=1)):
|
||||||
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
|
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
|
||||||
@@ -97,7 +98,8 @@ class RecorderTask:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._set_state(CameraState.STARTING)
|
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))
|
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||||
try:
|
try:
|
||||||
self._proc = await asyncio.create_subprocess_exec(
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class Supervisor:
|
|||||||
# id (удалённое хранилище) → тоже откат на активное.
|
# id (удалённое хранилище) → тоже откат на активное.
|
||||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||||
return RecorderTask(cam, store, self.config.storage.segment_seconds,
|
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:
|
def start_all(self) -> None:
|
||||||
for cam in self.config.cameras:
|
for cam in self.config.cameras:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>CoRE.Vizion+ — Архив</title>
|
<title>CoRE.Vizion+ — Архив</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/player.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="archive">
|
<body data-page="archive">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/player.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<div class="gb-split">
|
<div class="gb-split">
|
||||||
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки">▦</button>
|
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки">▦</button>
|
||||||
<div class="gb-split-menu" id="gb-split-menu">
|
<div class="gb-split-menu" id="gb-split-menu">
|
||||||
|
<button data-grid="1">1</button>
|
||||||
<button data-grid="2">4</button>
|
<button data-grid="2">4</button>
|
||||||
<button data-grid="3">9</button>
|
<button data-grid="3">9</button>
|
||||||
<button data-grid="4">16</button>
|
<button data-grid="4">16</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
<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">
|
<meta name="theme-color" content="#161a22">
|
||||||
<title>CoRE.Vizion+ — Mobile</title>
|
<title>CoRE.Vizion+ — Mobile</title>
|
||||||
|
<link rel="stylesheet" href="/static/player.css">
|
||||||
<link rel="stylesheet" href="/static/mobile.css">
|
<link rel="stylesheet" href="/static/mobile.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<div class="m-seglist" id="m-seglist"></div>
|
<div class="m-seglist" id="m-seglist"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/static/player.js"></script>
|
||||||
<script src="/static/mobile.js"></script>
|
<script src="/static/mobile.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-162
@@ -547,13 +547,13 @@ const Live = {
|
|||||||
initialDim() {
|
initialDim() {
|
||||||
const max = this.maxDim || 8;
|
const max = this.maxDim || 8;
|
||||||
const q = parseInt(new URLSearchParams(location.search).get("grid"), 10);
|
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);
|
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) {
|
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;
|
this.layout = this.dim * this.dim;
|
||||||
localStorage.setItem("corevision.grid", String(this.dim));
|
localStorage.setItem("corevision.grid", String(this.dim));
|
||||||
this.page = 0; // смена размера сетки → первая страница
|
this.page = 0; // смена размера сетки → первая страница
|
||||||
@@ -894,162 +894,8 @@ const Live = {
|
|||||||
// ── плеер архива в стиле Playerjs: своя панель управления над <video> ──
|
// ── плеер архива в стиле Playerjs: своя панель управления над <video> ──
|
||||||
// (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen,
|
// (play/pause, шкала перемотки с буфером и ползунком, время, скорость, fullscreen,
|
||||||
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
|
// большая центральная кнопка, спиннер, автоскрытие панели, горячие клавиши)
|
||||||
class ArchivePlayer {
|
// Фирменный плеер ArchivePlayer (.pjs) вынесен в общий модуль static/player.js
|
||||||
// opts: { mode:"transcode"|"file", duration, srcFor(startSec), src, offset, onEnded }
|
// (подключается archive.html и mobile.html ПЕРЕД app.js / mobile.js).
|
||||||
// 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) { /**/ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Archive = {
|
const Archive = {
|
||||||
cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0,
|
cameras: [], cam: null, date: null, segments: [], transcode: false, calYear: 0, calMonth: 0,
|
||||||
@@ -1321,6 +1167,8 @@ const Archive = {
|
|||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
const CamSettings = {
|
const CamSettings = {
|
||||||
SLOTS: 64,
|
SLOTS: 64,
|
||||||
|
// номер слота с ведущим нулём: 1‑9 → 01‑09, 10+ без изменений
|
||||||
|
slotId(n) { return String(n).padStart(2, "0"); },
|
||||||
editing: false, // идёт инлайн-редактирование — не перерисовываем по WS
|
editing: false, // идёт инлайн-редактирование — не перерисовываем по WS
|
||||||
bulk: false, // режим массового редактирования всей таблицы
|
bulk: false, // режим массового редактирования всей таблицы
|
||||||
cams: [],
|
cams: [],
|
||||||
@@ -1420,7 +1268,7 @@ const CamSettings = {
|
|||||||
const record = cam ? cam.record : true;
|
const record = cam ? cam.record : true;
|
||||||
const storageId = cam ? cam.storage_id : "";
|
const storageId = cam ? cam.storage_id : "";
|
||||||
return (
|
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="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` +
|
||||||
`<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></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>` +
|
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` +
|
||||||
@@ -1616,7 +1464,7 @@ const CamSettings = {
|
|||||||
: `<span class="muted">выключена</span>`;
|
: `<span class="muted">выключена</span>`;
|
||||||
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
`<td class="muted">${i + 1}</td>` +
|
`<td class="muted">${this.slotId(i + 1)}</td>` +
|
||||||
`<td>${c.name}</td>` +
|
`<td>${c.name}</td>` +
|
||||||
`<td class="muted">${c.ip}</td>` +
|
`<td class="muted">${c.ip}</td>` +
|
||||||
`<td class="muted">${streams}</td>` +
|
`<td class="muted">${streams}</td>` +
|
||||||
@@ -1630,7 +1478,7 @@ const CamSettings = {
|
|||||||
tr.className = "slot-empty";
|
tr.className = "slot-empty";
|
||||||
tr.dataset.slot = i + 1;
|
tr.dataset.slot = i + 1;
|
||||||
tr.innerHTML =
|
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 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>`;
|
`<td></td>`;
|
||||||
|
|||||||
@@ -69,38 +69,10 @@ html, body {
|
|||||||
.m-seg.active .m-dur { color: #fff; }
|
.m-seg.active .m-dur { color: #fff; }
|
||||||
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
||||||
|
|
||||||
/* ── фирменный плеер (.pjs) — единый для live и архива ── */
|
/* фирменный плеер (.pjs) — общий static/player.css; ниже только тач-подгонка размеров */
|
||||||
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
.pjs-btn { font-size: 20px; padding: 6px 8px; }
|
||||||
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
.pjs-seek { height: 6px; }
|
||||||
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
.pjs-seek:hover { height: 6px; }
|
||||||
.pjs-bigplay {
|
.pjs-knob { width: 16px; height: 16px; margin-left: -8px; }
|
||||||
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
.pjs-bar { gap: 12px; padding-bottom: calc(11px + env(safe-area-inset-bottom, 0)); }
|
||||||
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-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: 12px;
|
|
||||||
padding: 26px 12px calc(11px + env(safe-area-inset-bottom, 0));
|
|
||||||
background: linear-gradient(transparent, rgba(0,0,0,.85));
|
|
||||||
}
|
|
||||||
.pjs-btn { background: none; border: none; color: #fff; cursor: pointer; font-size: 20px; line-height: 1; padding: 6px 8px; border-radius: 6px; }
|
|
||||||
.pjs-btn:active { background: rgba(255,255,255,.18); }
|
|
||||||
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; }
|
|
||||||
.pjs-seek { position: relative; flex: 1; height: 6px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
|
||||||
.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: 16px; height: 16px; margin-left: -8px; border-radius: 50%; background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%); }
|
|
||||||
|
|
||||||
/* 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; }
|
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||||
|
|||||||
+5
-117
@@ -7,11 +7,7 @@
|
|||||||
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
|
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"); }
|
function pad(n) { return String(n).padStart(2, "0"); }
|
||||||
function 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");
|
|
||||||
}
|
|
||||||
async function api(url) {
|
async function api(url) {
|
||||||
const r = await fetch(url, { credentials: "same-origin" });
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||||||
@@ -19,116 +15,7 @@ async function api(url) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── фирменный плеер (.pjs) ──────────────────────────────────────────────
|
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
|
||||||
// opts: { live:bool, duration, srcFor(startSec)->url, src }
|
|
||||||
// live — без таймлайна: только пауза/плей + fullscreen.
|
|
||||||
// архив — серверный транскод без Range: виртуальный таймлайн на duration,
|
|
||||||
// перемотка = перезапуск транскода с -ss (srcFor(t)).
|
|
||||||
class Player {
|
|
||||||
constructor(wrap, opts = {}) {
|
|
||||||
this.live = !!opts.live;
|
|
||||||
this.total = opts.duration || 0;
|
|
||||||
this.srcFor = opts.srcFor || null;
|
|
||||||
this.startOffset = 0;
|
|
||||||
this.speeds = [0.5, 1, 2, 5]; this.spIdx = 1;
|
|
||||||
this.dragging = false; this.dragP = 0;
|
|
||||||
wrap.innerHTML = "";
|
|
||||||
const root = document.createElement("div");
|
|
||||||
root.className = "pjs" + (this.live ? " live" : "");
|
|
||||||
root.tabIndex = 0;
|
|
||||||
root.innerHTML =
|
|
||||||
'<video class="pjs-video" playsinline' + (this.live ? " muted autoplay" : " autoplay") + '></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="Во весь экран">⛶</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.v.src = this.live ? opts.src : this.srcFor(0);
|
|
||||||
this._bind();
|
|
||||||
this.v.play().catch(() => {});
|
|
||||||
}
|
|
||||||
_total() { return this.total || 0; }
|
|
||||||
_curTime() { return this.startOffset + (this.v.currentTime || 0); }
|
|
||||||
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; }
|
|
||||||
_seekToFrac(p) {
|
|
||||||
const total = this._total(); if (!total || this.live) return;
|
|
||||||
const t = Math.min(total, Math.max(0, p * total));
|
|
||||||
this.startOffset = t; // перезапуск транскода с нужной секунды
|
|
||||||
this.v.src = this.srcFor(t);
|
|
||||||
this.v.play().catch(() => {});
|
|
||||||
this._sync();
|
|
||||||
}
|
|
||||||
_bind() {
|
|
||||||
const v = this.v, root = this.root;
|
|
||||||
v.addEventListener("loadedmetadata", () => { this.durEl.textContent = fmt(this._total()); this._applyRate(); });
|
|
||||||
v.addEventListener("timeupdate", () => this._sync());
|
|
||||||
v.addEventListener("progress", () => this._syncBuf());
|
|
||||||
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); });
|
|
||||||
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); });
|
|
||||||
v.addEventListener("waiting", () => root.classList.add("buffering"));
|
|
||||||
v.addEventListener("playing", () => root.classList.remove("buffering"));
|
|
||||||
|
|
||||||
const toggle = () => { if (v.paused) v.play().catch(() => {}); else v.pause(); };
|
|
||||||
this.playBtn.onclick = toggle;
|
|
||||||
root.querySelector(".pjs-bigplay").onclick = toggle;
|
|
||||||
v.onclick = toggle;
|
|
||||||
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 = (x) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (x - r.left) / r.width)); };
|
|
||||||
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = 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); } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_sync() {
|
|
||||||
if (this.dragging || this.live) return;
|
|
||||||
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 = fmt(cur);
|
|
||||||
this.durEl.textContent = fmt(total);
|
|
||||||
}
|
|
||||||
_syncBuf() {
|
|
||||||
const v = this.v; if (this.live || !v.buffered.length) return;
|
|
||||||
const total = this._total(); if (!total) return;
|
|
||||||
const end = this.startOffset + v.buffered.end(v.buffered.length - 1);
|
|
||||||
this.buf.style.width = Math.min(100, end / total * 100) + "%";
|
|
||||||
}
|
|
||||||
_fs() {
|
|
||||||
const r = this.root;
|
|
||||||
if (document.fullscreenElement || document.webkitFullscreenElement) {
|
|
||||||
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
|
|
||||||
} else if (r.requestFullscreen) { r.requestFullscreen().catch(() => {}); }
|
|
||||||
else if (r.webkitRequestFullscreen) { r.webkitRequestFullscreen(); }
|
|
||||||
else if (this.v.webkitEnterFullscreen) { this.v.webkitEnterFullscreen(); } // iOS: только видео
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
|
|
||||||
if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const M = {
|
const M = {
|
||||||
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
||||||
@@ -185,7 +72,7 @@ const M = {
|
|||||||
this._kill();
|
this._kill();
|
||||||
const box = document.getElementById("m-live-box");
|
const box = document.getElementById("m-live-box");
|
||||||
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
||||||
this.player = new Player(box, { live: true, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
|
this.player = new ArchivePlayer(box, { live: true, autohide: false, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Архив ──
|
// ── Архив ──
|
||||||
@@ -222,7 +109,8 @@ const M = {
|
|||||||
this._kill();
|
this._kill();
|
||||||
const seg = this.segs.find((r) => r.id === id);
|
const seg = this.segs.find((r) => r.id === id);
|
||||||
const box = document.getElementById("m-arch-box");
|
const box = document.getElementById("m-arch-box");
|
||||||
this.player = new Player(box, {
|
this.player = new ArchivePlayer(box, {
|
||||||
|
mode: "transcode", autohide: false,
|
||||||
duration: seg ? (seg.duration_s || 0) : 0,
|
duration: seg ? (seg.duration_s || 0) : 0,
|
||||||
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 video { max-width: 100%; max-height: 100%; }
|
||||||
.player-wrap .hint { color: var(--text-dim); }
|
.player-wrap .hint { color: var(--text-dim); }
|
||||||
|
|
||||||
/* ── плеер архива в стиле Playerjs ── */
|
/* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
||||||
.timeline {
|
.timeline {
|
||||||
|
|||||||
Reference in New Issue
Block a user