From c6cdcf55b23c76d10a2027c02f5eed68d91f8a8f Mon Sep 17 00:00:00 2001 From: core Date: Mon, 1 Jun 2026 21:36:52 +0500 Subject: [PATCH] live: add a control footer under the grid (paging + play/stop + fullscreen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-view
now hosts its own 32px footer bar beneath the grid: prev page on the left, play/stop in the centre, next page + fullscreen on the right — all icon buttons. Pagination is new: cameras are split into pages of dim² cells, prev/next walk the pages (page 0 still honours a saved manual layout), and the arrows disable at the bounds. Fullscreen now targets
so the control bar stays visible in fullscreen (the global stats footer is outside
and hides as before). The sidebar play/fullscreen items now target
too. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- frontend/index.html | 14 ++++++- frontend/static/app.js | 78 +++++++++++++++++++++++++++++++-------- frontend/static/style.css | 17 +++++++++ 4 files changed, 94 insertions(+), 17 deletions(-) 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;