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:
+13
-1
@@ -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
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user