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;