c9c5b93ef8
UI loadLayouts() теперь fetches /layouts/{inst} — берёт actual layout_map
из config'а (не hardcoded), показывает только existing layouts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
316 lines
14 KiB
HTML
316 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>cuda-grid control</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<script src="/ui/lib/hls.js"></script>
|
|
<style>
|
|
:root { --bg:#1a1a1f; --fg:#e6e6e6; --accent:#4af; --muted:#888; --good:#4f8; --warn:#fa4; --err:#f44; }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; font-family:system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
|
header { padding:8px 16px; background:#0f0f12; border-bottom:1px solid #2a2a2f; display:flex; justify-content:space-between; align-items:center; }
|
|
header h1 { margin:0; font-size:16px; font-weight:600; }
|
|
header .status { font-size:12px; color:var(--muted); }
|
|
.layout { display:grid; grid-template-columns:1fr 360px; gap:12px; padding:12px; height:calc(100vh - 44px); }
|
|
@media (max-width:900px) { .layout { grid-template-columns:1fr; height:auto; } }
|
|
.video-pane { background:#000; border-radius:6px; overflow:hidden; position:relative; min-height:300px; }
|
|
.video-pane video { width:100%; height:100%; object-fit:contain; display:block; }
|
|
.controls { overflow-y:auto; }
|
|
.card { background:#22232a; border-radius:6px; padding:12px; margin-bottom:10px; }
|
|
.card h2 { margin:0 0 8px; font-size:13px; font-weight:600; text-transform:uppercase; color:var(--accent); letter-spacing:.5px; }
|
|
.row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
|
|
button { background:#333540; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px; }
|
|
button:hover { background:#3d404a; }
|
|
button.primary { background:var(--accent); color:#000; border-color:var(--accent); }
|
|
button.danger { background:var(--err); border-color:var(--err); }
|
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
input,select,textarea { background:#1a1b22; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 8px; font-size:13px; width:100%; }
|
|
label { font-size:11px; color:var(--muted); display:block; margin-bottom:2px; }
|
|
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:6px; }
|
|
.toast { position:fixed; bottom:16px; right:16px; padding:8px 16px; border-radius:4px; font-size:13px; transition:opacity .3s; opacity:0; pointer-events:none; }
|
|
.toast.show { opacity:1; }
|
|
.toast.ok { background:var(--good); color:#000; }
|
|
.toast.err { background:var(--err); color:#fff; }
|
|
pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overflow-x:auto; max-height:180px; margin:0; }
|
|
.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; font-size:12px; }
|
|
.kv .k { color:var(--muted); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>cuda-grid control</h1>
|
|
<div class="status" id="status">connecting…</div>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<div class="video-pane">
|
|
<video id="video" controls autoplay muted playsinline></video>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<!-- Layout -->
|
|
<div class="card">
|
|
<h2>Layout</h2>
|
|
<div class="row" id="layout-buttons"></div>
|
|
</div>
|
|
|
|
<!-- Audio -->
|
|
<div class="card">
|
|
<h2>Audio source</h2>
|
|
<div class="row" id="audio-buttons"></div>
|
|
</div>
|
|
|
|
<!-- Intercom -->
|
|
<div class="card">
|
|
<h2>Intercom (ducking)</h2>
|
|
<div class="row">
|
|
<button onclick="api('POST','/intercom/tv_grid/start',null,'intercom ON')">Start (music↓)</button>
|
|
<button onclick="api('POST','/intercom/tv_grid/end',null,'intercom OFF')">End</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Snapshot -->
|
|
<div class="card">
|
|
<h2>Snapshot</h2>
|
|
<div class="row">
|
|
<button class="primary" onclick="snap()">Take now</button>
|
|
<button onclick="loadHistory()">Reload history</button>
|
|
</div>
|
|
<div id="history" style="display:grid; grid-template-columns:repeat(3,1fr); gap:4px; max-height:200px; overflow-y:auto; margin-top:4px"></div>
|
|
</div>
|
|
|
|
<!-- Manual overlay -->
|
|
<div class="card">
|
|
<h2>Manual overlay</h2>
|
|
<div class="form-grid">
|
|
<div><label>type</label>
|
|
<select id="ov-type" onchange="ovTypeChanged()">
|
|
<option value="rect">rect</option>
|
|
<option value="text">text</option>
|
|
<option value="dim">dim</option>
|
|
<option value="icon">icon</option>
|
|
</select></div>
|
|
<div><label>cell (0..3 / empty=absolute)</label>
|
|
<input id="ov-cell" type="number" placeholder=""></div>
|
|
<div><label>x</label><input id="ov-x" type="number" step="0.01" value="0.1"></div>
|
|
<div><label>y</label><input id="ov-y" type="number" step="0.01" value="0.1"></div>
|
|
<div><label>w</label><input id="ov-w" type="number" step="0.01" value="0.3"></div>
|
|
<div><label>h</label><input id="ov-h" type="number" step="0.01" value="0.2"></div>
|
|
<div><label>color (hex)</label><input id="ov-color" type="color" value="#ff8800"></div>
|
|
<div><label>opacity (0..1)</label><input id="ov-opacity" type="number" step="0.05" value="1.0" min="0" max="1"></div>
|
|
</div>
|
|
<div id="ov-extra" class="form-grid"></div>
|
|
<div class="row">
|
|
<button class="primary" onclick="addOverlay()">Add</button>
|
|
<button class="danger" onclick="api('DELETE','/overlay/tv_grid',null,'cleared all')">Clear all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat publish -->
|
|
<div class="card">
|
|
<h2>Chat message</h2>
|
|
<div class="row">
|
|
<input id="chat-msg" type="text" placeholder="message" style="flex:1">
|
|
<button onclick="pubChat()">Send</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State -->
|
|
<div class="card">
|
|
<h2>State</h2>
|
|
<div class="kv" id="state-kv">…</div>
|
|
<details><summary style="cursor:pointer; font-size:12px; color:var(--muted); margin-top:6px">overlays raw</summary>
|
|
<pre id="overlays-raw">…</pre></details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
const INSTANCE = 'tv_grid';
|
|
const HLS_URL = `http://${location.hostname}:8888/live/index.m3u8`;
|
|
|
|
// ── Video ─────────────────────────────────────────────
|
|
function initVideo() {
|
|
const v = document.getElementById('video');
|
|
if (Hls.isSupported()) {
|
|
const hls = new Hls({ liveDurationInfinity:true, lowLatencyMode:true, maxBufferLength:6 });
|
|
hls.loadSource(HLS_URL);
|
|
hls.attachMedia(v);
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(()=>{}));
|
|
hls.on(Hls.Events.ERROR, (_,d) => { if (d.fatal) setStatus('HLS error: '+d.type, true); });
|
|
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
|
v.src = HLS_URL; v.play().catch(()=>{});
|
|
} else {
|
|
setStatus('HLS не supported in this browser', true);
|
|
}
|
|
}
|
|
|
|
// ── API helpers ───────────────────────────────────────
|
|
function setStatus(msg, err=false) {
|
|
const s = document.getElementById('status');
|
|
s.textContent = msg; s.style.color = err ? 'var(--err)' : 'var(--muted)';
|
|
}
|
|
function toast(msg, ok=true) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg; t.className = 'toast show ' + (ok?'ok':'err');
|
|
setTimeout(()=>t.className='toast', 1800);
|
|
}
|
|
async function api(method, path, body=null, okMsg='ok') {
|
|
try {
|
|
const opts = { method, headers:{} };
|
|
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
|
const r = await fetch(path, opts);
|
|
const j = r.headers.get('content-type')?.includes('json') ? await r.json() : await r.text();
|
|
if (r.ok) { toast(okMsg, true); return j; }
|
|
toast(`${r.status}: ${JSON.stringify(j).slice(0,80)}`, false);
|
|
} catch (e) { toast('fail: '+e.message, false); }
|
|
}
|
|
|
|
// ── Layout buttons ────────────────────────────────────
|
|
async function loadLayouts() {
|
|
const r = await fetch(`/layouts/${INSTANCE}`);
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
const box = document.getElementById('layout-buttons');
|
|
box.innerHTML = '';
|
|
d.layouts.forEach(name => {
|
|
const b = document.createElement('button');
|
|
b.textContent = name;
|
|
b.onclick = () => api('POST', `/layout/${INSTANCE}/set`, {layout:name}, '→ '+name);
|
|
box.appendChild(b);
|
|
});
|
|
}
|
|
|
|
// ── Audio buttons (build dynamically) ─────────────────
|
|
async function loadAudio() {
|
|
const r = await fetch(`/audio/${INSTANCE}`);
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
const box = document.getElementById('audio-buttons');
|
|
box.innerHTML = '';
|
|
d.sources.forEach(s => {
|
|
const b = document.createElement('button');
|
|
b.textContent = s.label; b.dataset.name = s.name;
|
|
b.onclick = () => api('POST', `/audio/${INSTANCE}/set`, {source:s.name}, '→ '+s.label);
|
|
box.appendChild(b);
|
|
});
|
|
}
|
|
|
|
// ── Snapshot ──────────────────────────────────────────
|
|
async function snap() {
|
|
const r = await fetch(`/snapshot/${INSTANCE}`, {method:'POST'});
|
|
if (!r.ok) { toast('snapshot fail', false); return; }
|
|
const blob = await r.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
window.open(url, '_blank');
|
|
}
|
|
async function loadHistory() {
|
|
const r = await fetch(`/snapshots/${INSTANCE}?limit=24`);
|
|
if (!r.ok) { document.getElementById('history').innerHTML = '<span style="color:var(--muted); font-size:11px">disabled</span>'; return; }
|
|
const d = await r.json();
|
|
const box = document.getElementById('history');
|
|
box.innerHTML = '';
|
|
d.items.forEach(it => {
|
|
const a = document.createElement('a');
|
|
a.href = it.url; a.target = '_blank';
|
|
a.title = new Date(it.timestamp*1000).toLocaleString();
|
|
const img = document.createElement('img');
|
|
img.src = it.url; img.style.cssText = 'width:100%; border-radius:2px; cursor:pointer';
|
|
a.appendChild(img);
|
|
box.appendChild(a);
|
|
});
|
|
}
|
|
|
|
// ── Manual overlay ────────────────────────────────────
|
|
function ovTypeChanged() {
|
|
const t = document.getElementById('ov-type').value;
|
|
const extra = document.getElementById('ov-extra');
|
|
extra.innerHTML = '';
|
|
const wh = document.querySelectorAll('#ov-w,#ov-h');
|
|
wh.forEach(e => e.parentElement.style.display = (t==='rect'||t==='dim') ? '' : 'none');
|
|
if (t === 'text') {
|
|
extra.innerHTML = `<div style="grid-column:1/-1"><label>text</label><input id="ov-text" type="text" value="Hello"></div>
|
|
<div><label>font_size</label><input id="ov-font" type="number" value="32" min="8" max="256"></div>`;
|
|
} else if (t === 'icon') {
|
|
extra.innerHTML = `<div style="grid-column:1/-1"><label>icon name</label><input id="ov-icon" type="text" value="domofon"></div>`;
|
|
} else if (t === 'rect') {
|
|
extra.innerHTML = `<div><label>border only</label><input id="ov-border" type="checkbox"></div>
|
|
<div><label>border width</label><input id="ov-bw" type="number" value="2" min="1" max="32"></div>`;
|
|
}
|
|
}
|
|
function rndId() { return 'ui_'+Math.random().toString(36).slice(2,8); }
|
|
async function addOverlay() {
|
|
const t = document.getElementById('ov-type').value;
|
|
const body = {
|
|
id: rndId(),
|
|
type: t,
|
|
x: +document.getElementById('ov-x').value,
|
|
y: +document.getElementById('ov-y').value,
|
|
opacity: +document.getElementById('ov-opacity').value,
|
|
};
|
|
const cell = document.getElementById('ov-cell').value;
|
|
if (cell !== '') body.cell = +cell;
|
|
if (t==='rect' || t==='dim' || t==='image') {
|
|
body.w = +document.getElementById('ov-w').value;
|
|
body.h = +document.getElementById('ov-h').value;
|
|
}
|
|
if (t==='rect' || t==='text' || t==='dim') body.color = document.getElementById('ov-color').value;
|
|
if (t==='text') {
|
|
body.text = document.getElementById('ov-text').value;
|
|
body.font_size = +document.getElementById('ov-font').value;
|
|
}
|
|
if (t==='icon') body.name = document.getElementById('ov-icon').value;
|
|
if (t==='rect') {
|
|
body.border_only = document.getElementById('ov-border').checked;
|
|
body.border_width = +document.getElementById('ov-bw').value;
|
|
}
|
|
await api('POST', `/overlay/${INSTANCE}/add`, body, '+overlay '+body.id);
|
|
}
|
|
|
|
// ── Chat ──────────────────────────────────────────────
|
|
async function pubChat() {
|
|
const msg = document.getElementById('chat-msg').value.trim();
|
|
if (!msg) return;
|
|
// No direct MQTT publish endpoint in controller — use manual text overlay в качестве workaround
|
|
// (controller's chat subscribes к MQTT — нужен external mosquitto_pub).
|
|
// Здесь делаем quick — add text overlay временно.
|
|
toast('Chat = MQTT topic cuda_grid/chat/alerts (use mosquitto_pub externally)', false);
|
|
}
|
|
|
|
// ── State refresh ─────────────────────────────────────
|
|
async function refreshState() {
|
|
try {
|
|
const r = await fetch('/state');
|
|
if (!r.ok) { setStatus('controller offline', true); return; }
|
|
const d = await r.json();
|
|
const inst = d.instances?.[INSTANCE];
|
|
if (!inst) return;
|
|
document.getElementById('state-kv').innerHTML =
|
|
`<span class="k">layout:</span><span>${inst.active_layout||'—'}</span>` +
|
|
`<span class="k">overlays:</span><span>${inst.overlays_count}</span>` +
|
|
`<span class="k">zmq:</span><span style="font-size:11px">${inst.zmq_endpoint}</span>`;
|
|
const r2 = await fetch(`/overlay/${INSTANCE}`);
|
|
if (r2.ok) {
|
|
const d2 = await r2.json();
|
|
document.getElementById('overlays-raw').textContent =
|
|
d2.overlays.map(o => `${o.id} ${o.type} cell=${o.cell ?? '-'}`).join('\n');
|
|
}
|
|
setStatus(`controller OK · ${inst.overlays_count} overlays`);
|
|
} catch (e) { setStatus('refresh fail: '+e.message, true); }
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────
|
|
// Defensive init — async (non-video) FIRST so кнопки работают независимо от HLS.
|
|
try { ovTypeChanged(); } catch(e) { console.error('ovTypeChanged', e); }
|
|
try { loadLayouts(); } catch(e) { console.error('loadLayouts', e); }
|
|
try { loadAudio(); } catch(e) { console.error('loadAudio', e); }
|
|
try { loadHistory(); } catch(e) { console.error('loadHistory', e); }
|
|
try { refreshState(); setInterval(refreshState, 2000); } catch(e) { console.error('refreshState', e); }
|
|
try { initVideo(); } catch(e) { console.error('initVideo', e); setStatus('video init failed (HLS?)', true); }
|
|
</script>
|
|
</body>
|
|
</html>
|