live: add a control footer under the grid (paging + play/stop + fullscreen)

The live-view <main> 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 <main>
so the control bar stays visible in fullscreen (the global stats footer is
outside <main> and hides as before). The sidebar play/fullscreen items now
target <main> too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:36:52 +05:00
parent b29b808c6d
commit c6cdcf55b2
4 changed files with 94 additions and 17 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.138 0.0.139
+13 -1
View File
@@ -26,8 +26,20 @@
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a> <a class="nav-item" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a>
</aside> </aside>
<main> <main id="live-main">
<div class="grid g4" id="grid"></div> <div class="grid g4" id="grid"></div>
<footer class="grid-bar" id="grid-bar">
<div class="gb-left">
<button class="gb-btn" id="gb-prev" title="Предыдущая страница" aria-label="Предыдущая страница"></button>
</div>
<div class="gb-center">
<button class="gb-btn gb-play" id="gb-play" title="Просмотр / стоп" aria-label="Просмотр / стоп"></button>
</div>
<div class="gb-right">
<button class="gb-btn" id="gb-next" title="Следующая страница" aria-label="Следующая страница"></button>
<button class="gb-btn" id="gb-fs" title="Полный экран" aria-label="Полный экран"></button>
</div>
</footer>
</main> </main>
</div> </div>
+63 -15
View File
@@ -483,7 +483,7 @@ class WebCodecsHevcPlayer {
// Страница LIVE VIEW // Страница LIVE VIEW
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
const Live = { 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() { async init() {
const data = await api("/api/cameras"); const data = await api("/api/cameras");
@@ -535,21 +535,52 @@ const Live = {
this.dim = Math.min(this.maxDim || 8, Math.max(2, dim)); this.dim = Math.min(this.maxDim || 8, Math.max(2, 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; // смена размера сетки → первая страница
const saved = getLayout(this.dim); this.fillSelected(); // selected = камеры текущей страницы
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);
}
}
document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) => document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) =>
a.classList.toggle("active", +a.dataset.grid === this.dim)); a.classList.toggle("active", +a.dataset.grid === this.dim));
if (rerender) this.renderGrid(); 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() { bindSubnav() {
@@ -561,9 +592,21 @@ const Live = {
}; };
}); });
const fs = document.getElementById("fs-grid"); 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"); const pt = document.getElementById("play-toggle");
if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; } 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() { bindFullscreenResize() {
@@ -584,6 +627,11 @@ const Live = {
pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр"; pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр";
pt.classList.toggle("active", !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) { setPlaying(on) {
@@ -774,7 +822,7 @@ const Live = {
grid.classList.add("solo"); grid.classList.add("solo");
tile.classList.add("solo"); tile.classList.add("solo");
// ещё не в полноэкранном — войти, чтобы камера заняла весь экран (выход — ESC) // ещё не в полноэкранном — войти, чтобы камера заняла весь экран (выход — ESC)
if (!(document.fullscreenElement || document.webkitFullscreenElement)) toggleFullscreen(grid); if (!(document.fullscreenElement || document.webkitFullscreenElement)) toggleFullscreen(document.getElementById("live-main") || grid);
} // повторный двойной клик — назад к сетке (фуллскрин сохраняется) } // повторный двойной клик — назад к сетке (фуллскрин сохраняется)
this.resizePlayers(); this.resizePlayers();
}, },
+17
View File
@@ -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 { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; }
.grid.solo > .tile:not(.solo) { display: none; } .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 { .tile {
position: relative; background: #05070a; border: 1px solid var(--border); position: relative; background: #05070a; border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; overflow: hidden; display: flex; align-items: center; justify-content: center; overflow: hidden;