diff --git a/VERSION b/VERSION
index 202169a..3905667 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.138
+0.0.139
diff --git a/frontend/index.html b/frontend/index.html
index 34a6136..249e7ed 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -26,8 +26,20 @@
⚙Настройки
-
+
+
diff --git a/frontend/static/app.js b/frontend/static/app.js
index bed7e84..6cda69f 100644
--- a/frontend/static/app.js
+++ b/frontend/static/app.js
@@ -483,7 +483,7 @@ class WebCodecsHevcPlayer {
// Страница LIVE VIEW
// ════════════════════════════════════════════════════════════════
const Live = {
- cameras: [], dim: 2, layout: 4, transcode: false, go2rtcUrl: "", selected: [], singleMain: false, maxDim: 8, playing: true,
+ cameras: [], dim: 2, layout: 4, transcode: false, go2rtcUrl: "", selected: [], singleMain: false, maxDim: 8, playing: true, page: 0,
async init() {
const data = await api("/api/cameras");
@@ -535,21 +535,52 @@ const Live = {
this.dim = Math.min(this.maxDim || 8, Math.max(2, dim));
this.layout = this.dim * this.dim;
localStorage.setItem("corevision.grid", String(this.dim));
- // приоритет — сохранённая раскладка (страница "Раскладка"); иначе автозаполнение
- const saved = getLayout(this.dim);
- if (Array.isArray(saved) && saved.length === this.layout) {
- this.selected = saved.slice();
- } else {
- this.selected = this.selected.slice(0, this.layout);
- while (this.selected.length < this.layout) {
- const next = this.cameras.find((c) => !this.selected.includes(c.id));
- if (!next) break;
- this.selected.push(next.id);
- }
- }
+ this.page = 0; // смена размера сетки → первая страница
+ this.fillSelected(); // selected = камеры текущей страницы
document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) =>
a.classList.toggle("active", +a.dataset.grid === this.dim));
if (rerender) this.renderGrid();
+ this.updatePager();
+ },
+
+ // число страниц: все камеры, разбитые по layout ячеек
+ pageCount() {
+ return Math.max(1, Math.ceil(this.cameras.length / this.layout));
+ },
+
+ // заполнить selected камерами текущей страницы; страница 0 уважает сохранённую раскладку
+ fillSelected() {
+ const pages = this.pageCount();
+ this.page = Math.min(Math.max(0, this.page), pages - 1);
+ const saved = getLayout(this.dim);
+ if (this.page === 0 && Array.isArray(saved) && saved.length === this.layout) {
+ this.selected = saved.slice();
+ return;
+ }
+ const start = this.page * this.layout;
+ this.selected = this.cameras.slice(start, start + this.layout).map((c) => c.id);
+ while (this.selected.length < this.layout) this.selected.push(undefined);
+ },
+
+ setPage(p) {
+ const pages = this.pageCount();
+ const np = Math.min(pages - 1, Math.max(0, p));
+ if (np === this.page) { this.updatePager(); return; }
+ this.page = np;
+ this.fillSelected();
+ this.renderGrid();
+ this.updatePager();
+ },
+
+ // состояние стрелок пагинации в футере (выкл на краях)
+ updatePager() {
+ const pages = this.pageCount();
+ const prev = document.getElementById("gb-prev");
+ const next = document.getElementById("gb-next");
+ if (prev) prev.disabled = this.page <= 0;
+ if (next) next.disabled = this.page >= pages - 1;
+ const bar = document.getElementById("grid-bar");
+ if (bar) bar.title = `Страница ${this.page + 1} из ${pages}`;
},
bindSubnav() {
@@ -561,9 +592,21 @@ const Live = {
};
});
const fs = document.getElementById("fs-grid");
- if (fs) fs.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("grid")); };
+ if (fs) fs.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("live-main") || document.getElementById("grid")); };
const pt = document.getElementById("play-toggle");
if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; }
+ // ── панель управления под сеткой (футер фрейма): ⏮ play/stop ⏭ + фулскрин ──
+ const fsMain = () => toggleFullscreen(document.getElementById("live-main") || document.getElementById("grid"));
+ const gbPrev = document.getElementById("gb-prev");
+ if (gbPrev) gbPrev.onclick = (e) => { e.preventDefault(); this.setPage(this.page - 1); };
+ const gbNext = document.getElementById("gb-next");
+ if (gbNext) gbNext.onclick = (e) => { e.preventDefault(); this.setPage(this.page + 1); };
+ const gbPlay = document.getElementById("gb-play");
+ if (gbPlay) gbPlay.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); };
+ const gbFs = document.getElementById("gb-fs");
+ if (gbFs) gbFs.onclick = (e) => { e.preventDefault(); fsMain(); };
+ this.updatePlayBtn();
+ this.updatePager();
},
bindFullscreenResize() {
@@ -584,6 +627,11 @@ const Live = {
pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр";
pt.classList.toggle("active", !this.playing);
}
+ const gb = document.getElementById("gb-play");
+ if (gb) {
+ gb.textContent = this.playing ? "⏸" : "▶";
+ gb.title = this.playing ? "Остановить просмотр" : "Начать просмотр";
+ }
},
setPlaying(on) {
@@ -774,7 +822,7 @@ const Live = {
grid.classList.add("solo");
tile.classList.add("solo");
// ещё не в полноэкранном — войти, чтобы камера заняла весь экран (выход — ESC)
- if (!(document.fullscreenElement || document.webkitFullscreenElement)) toggleFullscreen(grid);
+ if (!(document.fullscreenElement || document.webkitFullscreenElement)) toggleFullscreen(document.getElementById("live-main") || grid);
} // повторный двойной клик — назад к сетке (фуллскрин сохраняется)
this.resizePlayers();
},
diff --git a/frontend/static/style.css b/frontend/static/style.css
index 73176bc..14ec8a1 100644
--- a/frontend/static/style.css
+++ b/frontend/static/style.css
@@ -173,6 +173,23 @@ input[type="date"], input[type="number"], select {
/* «Соло»: одна камера на весь экран (двойной клик по ячейке в полноэкранном режиме) */
.grid.solo { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; }
.grid.solo > .tile:not(.solo) { display: none; }
+
+/* ── Панель управления под сеткой (футер фрейма live): листание + play/stop + фулскрин ── */
+footer.grid-bar {
+ height: 32px; flex: none; display: flex; align-items: center; gap: 8px;
+ padding: 0 8px; background: var(--panel); border-top: 1px solid var(--border);
+}
+.grid-bar .gb-left { flex: 1; display: flex; justify-content: flex-start; }
+.grid-bar .gb-center { flex: 0 0 auto; display: flex; justify-content: center; gap: 8px; }
+.grid-bar .gb-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; }
+.grid-bar .gb-btn {
+ height: 24px; min-width: 30px; padding: 0 8px; border: 1px solid var(--border);
+ background: var(--panel-2); color: var(--text); border-radius: 6px; cursor: pointer;
+ font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
+}
+.grid-bar .gb-btn:hover:not(:disabled) { border-color: #c0392b; color: #fff; }
+.grid-bar .gb-btn:disabled { opacity: .3; cursor: default; }
+.grid-bar .gb-play { min-width: 42px; font-size: 15px; }
.tile {
position: relative; background: #05070a; border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; overflow: hidden;