Files
hms-mediaengine/hms_app/ui.py
T
HMS MediaEngine Agent 70502458d8 PROJEKTE: Speichern/Laden/Autostart (25/25 E2E inkl. Server-Neustart)
- hms_app/projects.py: ProjectStore mit atomarem Speichern
  (.hms.json in projects/), Format-Kennung, Namens-Sanitize
  (Pfad-Traversal-Schutz, Leerzeichen erlaubt), Liste/Loeschen
- engine.py: export_state() (Layer mit Alpha/Pos/Groesse/Z/FX +
  Intensitaeten + Master) und load_state() (ersetzt alle Layer,
  fehlende Medien werden uebersprungen und gemeldet, Werte geclamped,
  ungueltige FX-IDs verworfen)
- server.py: /api/projects (GET Liste + Autostart),
  POST projects/save|load|delete|autostart; Loeschen des
  Autostart-Projekts raeumt config.json automatisch auf
- config.py: engine.autostart_project (validiert, optional)
- run.py: laedt Autostart-Projekt beim Programmstart
- ui.py: neuer Reiter 'Projekte': Name eingeben + Speichern,
  Tabelle mit Laden/Autostart/Loeschen, Autostart-Badge

E2E-BEWEIS (25/25 PASS, mit ECHTEM Server-Neustart):
1 Server startet · 2 Upload · 3 Layer · 4 FX/Alpha/Pos gesetzt ·
5 Projekt 'Show A' gespeichert · 6 Traversal-Name sanitizet zu 'evil' ·
7 Liste · 8 Layerzahl · 9 Zustand geleert (master=0.1) ·
10 Projekt geladen · 11 Layer wiederhergestellt ·
12 Alpha 0.7 wiederhergestellt · 13 FX1 blur 0.8 wiederhergestellt ·
14 Position x=50 y=20 wiederhergestellt · 15 Master 0.9 ·
16 Engine rendert · 17 Autostart gesetzt ·
18 Server NEU GESTARTET · 19 Autostart: Layer+FX automatisch geladen ·
20 Autostart: Engine rendert (60 Frames) · 21 config.json Autostart ·
22 fehlendes Projekt 404 · 23 geloescht ·
24 Autostart automatisch geleert · 25 config.json geleert

Regressionen: App-E2E 14/14 (app=0) + FX-E2E 16/16 (fx=0) unveraendert gruen
2026-09-11 12:55:50 +02:00

295 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Web-UI (Single-Page): Tabs Live, Medien (Upload), Layer, Einstellungen."""
HTML_UI = r'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HMS MediaEngine</title>
<style>
:root{--bg:#0d0f12;--pan:#16181d;--pan2:#12141a;--line:#262a33;--tx:#c8ccd4;--dim:#8a8f9a;--acc:#4a90d9;--ok:#3fa34d;--err:#ff7a7a}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--tx);font:13px/1.45 system-ui,sans-serif;height:100vh;display:flex;flex-direction:column}
header{background:var(--pan);padding:8px 16px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--line)}
h1{font-size:14px;color:#e8eaee;margin-right:10px}
.badge{font-size:11px;padding:2px 8px;border-radius:3px;background:#233042;color:#7ab8ff}
.badge.err{background:#3a2020;color:var(--err)}
#tabbar{display:flex;gap:2px;margin-left:20px}
#tabbar button{background:none;border:none;color:var(--dim);padding:6px 12px;font-size:12px;cursor:pointer;border-bottom:2px solid transparent}
#tabbar button.on{color:#e8eaee;border-bottom-color:var(--acc)}
main{flex:1;overflow:hidden}
.page{display:none;height:100%;overflow:auto;padding:16px}
.page.on{display:block}
#page-live{display:none;height:100%}#page-live.on{display:flex;gap:16px}
#live-left{flex:1;display:flex;align-items:center;justify-content:center;min-width:0}
.preview{max-width:100%;max-height:calc(100vh - 120px);border:1px solid var(--line);border-radius:4px;background:#000;overflow:hidden}
.preview img{display:block;width:100%;height:auto}
#live-right{width:320px;background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:12px;overflow-y:auto}
h2{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:12px 0 6px}
h2:first-child{margin-top:0}
.ctl{margin-bottom:8px}
.ctl label{display:flex;justify-content:space-between;font-size:12px;margin-bottom:2px}
input[type=range]{width:100%;accent-color:var(--acc)}
input[type=number],input[type=text],select{background:#0d0f12;color:var(--tx);border:1px solid var(--line);border-radius:3px;padding:4px 6px;font-size:12px;width:90px}
input[type=checkbox]{accent-color:var(--acc)}
.row{display:flex;gap:6px;margin-bottom:8px}
button{background:#262a33;color:var(--tx);border:1px solid #333842;border-radius:4px;padding:6px 10px;font-size:12px;cursor:pointer}
button:hover{background:#333842}button.danger{border-color:#5a2a2a;color:#ff9a9a}button.acc{background:#1d3a5f;border-color:var(--acc)}
table{width:100%;border-collapse:collapse;font-size:12px;margin:6px 0}
td,th{padding:4px 6px;border-bottom:1px solid #21252d;text-align:left}th{color:var(--dim);font-weight:400}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.card{background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:6px;text-align:center}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:3px;background:#000;cursor:pointer}
.card .nm{font-size:11px;margin:4px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .meta{font-size:10px;color:var(--dim)}
.card .btns{display:flex;gap:4px;margin-top:4px}
.card .btns button{flex:1;padding:3px 0;font-size:11px}
#drop{border:2px dashed var(--line);border-radius:6px;padding:22px;text-align:center;color:var(--dim);cursor:pointer;margin-bottom:14px}
#drop.hot{border-color:var(--acc);color:#7ab8ff}
.setgrp{background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:12px;margin-bottom:12px}
.setgrp h3{font-size:12px;color:#e8eaee;margin-bottom:8px}
.frow{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}
.frow label{width:210px;color:var(--dim)}
.frow .hint{color:#5a5f6a;font-size:11px}
footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);border-top:1px solid var(--line);display:flex;gap:16px}
.fps{color:var(--ok);font-weight:600}
#toast{position:fixed;bottom:44px;right:16px;background:#1d3a5f;color:#dbe9ff;border:1px solid var(--acc);border-radius:4px;padding:8px 14px;font-size:12px;display:none;max-width:420px}
#toast.err{background:#3a2020;border-color:#5a2a2a;color:#ffb0b0}
.ll{display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px;border-radius:3px;background:#0d0f12}
.ll input[type=range]{flex:1}
.ll .nm{width:110px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ll .val{width:34px;text-align:right;font-size:11px;color:var(--dim)}
</style>
</head>
<body>
<header>
<h1>HMS MediaEngine</h1>
<span class="badge" id="b-run"></span>
<span class="badge" id="b-dmx">Art-Net: …</span>
<div id="tabbar">
<button data-p="live" class="on">Live</button>
<button data-p="media">Medien</button>
<button data-p="layers">Layer</button>
<button data-p="projects">Projekte</button>
<button data-p="settings">Einstellungen</button>
</div>
</header>
<main>
<div id="page-live" class="page on">
<div id="live-left"><div class="preview"><img src="/stream.mjpg" id="pv"></div></div>
<div id="live-right">
<h2>Master</h2>
<div class="ctl"><label>Intensität <span id="mv">100%</span></label>
<input type="range" id="master" min="0" max="100" value="100"></div>
<div class="row">
<button onclick="api('playback',{playing:true})">Play</button>
<button onclick="api('playback',{playing:false})">Pause</button>
<button onclick="api('retrigger',{})">Restart</button>
<button class="danger" id="blk">BLK</button>
</div>
<h2>Layer (Schnellzugriff)</h2>
<div id="quick"></div>
<h2>Diagnose</h2>
<table><tbody>
<tr><th>Frames</th><td id="d-frames"></td></tr>
<tr><th>DMX-Pakete</th><td id="d-pkts"></td></tr>
<tr><th>DMX angenommen</th><td id="d-acc"></td></tr>
<tr><th>Medien</th><td id="d-media"></td></tr>
<tr><th>Fehler</th><td id="d-err"></td></tr>
</tbody></table>
</div>
</div>
<div id="page-media" class="page">
<div id="drop">📄 Dateien hierher ziehen oder klicken zum Hochladen<br><small>Videos: mp4, mkv, avi, mov, webm · Bilder: jpg, png, bmp</small></div>
<input type="file" id="file" multiple accept=".mp4,.m4v,.avi,.mkv,.mov,.webm,.jpg,.jpeg,.png,.bmp" style="display:none">
<div id="upbar" style="display:none;margin-bottom:10px"><div id="uptext" style="font-size:12px;color:var(--dim)"></div><progress id="uprog" style="width:100%"></progress></div>
<div class="grid" id="mgrid"></div>
</div>
<div id="page-layers" class="page">
<div class="row"><button class="acc" onclick="location.hash='#media';showTab('media')">+ Medien hinzufügen</button>
<span style="color:var(--dim);font-size:11px;align-self:center">Aktive Layer bearbeiten:</span></div>
<div id="ledit"></div>
</div>
<div id="page-projects" class="page">
<div class="row" style="max-width:480px">
<input type="text" id="projname" placeholder="Projektname…" style="flex:1;width:auto">
<button class="acc" onclick="saveProject()">Speichern</button>
</div>
<p style="color:var(--dim);font-size:11px;margin:6px 0 14px">Speichert alle Layer inkl. Effekte, Position, Alpha und Master. Autostart lädt das Projekt beim Programmstart automatisch.</p>
<table id="projtable"><thead><tr><th>Projekt</th><th>Layer</th><th>Gespeichert</th><th>Aktionen</th></tr></thead><tbody></tbody></table>
</div>
<div id="page-settings" class="page">
<div class="setgrp"><h3>Web-UI</h3>
<div class="frow"><label>Port</label><input type="number" id="s-web-port"></div>
<div class="frow"><label>Bind-Adresse</label><input type="text" id="s-web-bind" style="width:140px"><span class="hint">0.0.0.0 = alle Interfaces</span></div>
</div>
<div class="setgrp"><h3>Preview</h3>
<div class="frow"><label>Breite</label><input type="number" id="s-preview-width"> × <input type="number" id="s-preview-height"> px</div>
<div class="frow"><label>FPS</label><input type="number" id="s-preview-fps"></div>
<div class="frow"><label>JPEG-Qualität</label><input type="number" id="s-preview-q"></div>
</div>
<div class="setgrp"><h3>Art-Net (DMX)</h3>
<div class="frow"><label>Aktiviert</label><input type="checkbox" id="s-artnet-enabled"></div>
<div class="frow"><label>UDP-Port</label><input type="number" id="s-artnet-port"><span class="hint">Standard 6454</span></div>
<div class="frow"><label>Universes</label><input type="text" id="s-artnet-universes" style="width:200px"><span class="hint">z. B. 0,1,2</span></div>
<div class="frow"><label>Kanal Master</label><input type="number" id="s-artnet-master" min="1" max="512"></div>
<div class="frow"><label>Kanal Layer (Start)</label><input type="number" id="s-artnet-layer" min="1" max="512"><span class="hint">je +1 pro Layer</span></div>
<div class="frow"><label>Kanal Playback</label><input type="number" id="s-artnet-playback" min="1" max="512"></div>
<div class="frow"><label>Kanal Retrigger</label><input type="number" id="s-artnet-retrigger" min="1" max="512"></div>
<div class="frow"><label>Kanal Blackout</label><input type="number" id="s-artnet-blackout" min="1" max="512"></div>
<div class="frow"><label>Signalverlust</label><select id="s-artnet-policy"><option value="hold">Letzten Zustand halten</option><option value="fade_black">Auf schwarz fallen</option></select>
<div class="frow"><label>Timeout Signalverlust</label><input type="number" id="s-artnet-timeout"> s</div>
</div>
<div class="setgrp"><h3>Engine</h3>
<div class="frow"><label>Max. Layer</label><input type="number" id="s-engine-max"></div>
<div class="frow"><label>Loop als Standard</label><input type="checkbox" id="s-engine-loop"></div>
<div class="frow"><label>Fullscreen-Ausgabe</label><input type="checkbox" id="s-output-fs"><span class="hint">Windows: D3D11 · Linux: autovideosink</span></div>
</div>
<div class="setgrp"><h3>Medien</h3>
<div class="frow"><label>Ordnername</label><input type="text" id="s-media-dir" style="width:140px"></div>
<div class="frow"><label>Max. Upload</label><input type="number" id="s-media-max"> MB</div>
</div>
<button class="acc" style="padding:8px 20px" onclick="saveSettings()">Speichern &amp; Anwenden</button>
<span id="setmsg" style="margin-left:10px;color:var(--ok)"></span>
</div>
</main>
<footer><span class="fps" id="fps"></span> fps · <span id="f-frames"></span> Frames · MJPEG-Preview · REST /api/status</footer>
<div id="toast"></div>
<script>
'use strict';
const $=id=>document.getElementById(id);
let lastStatus=null;
function toast(msg,err){const t=$('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(t._h);t._h=setTimeout(()=>t.style.display='none',3500)}
async function api(path,body){try{const r=await fetch('/api/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});const d=await r.json();if(!r.ok)toast(d.error||'Fehler',true);return d}catch(e){toast('Netzwerkfehler',true);return{}}}
// Tabs
document.querySelectorAll('#tabbar button').forEach(b=>b.onclick=()=>showTab(b.dataset.p));
function showTab(p){document.querySelectorAll('.page').forEach(x=>x.classList.remove('on'));$('page-'+p).classList.add('on');document.querySelectorAll('#tabbar button').forEach(x=>x.classList.toggle('on',x.dataset.p===p));if(p==='media')loadMedia();if(p==='settings')loadSettings();if(p==='projects')loadProjects();}
// Live-Steuerung
$('master').oninput=e=>{api('master',{intensity:e.target.value/100});$('mv').textContent=e.target.value+'%'};
$('blk').onclick=async()=>{await api('blackout',{on:true});setTimeout(()=>api('blackout',{on:false}),500)};
// FPS
let fc=0,fpsC=0,last=performance.now();
$('pv').onload=()=>{fc++;fpsC++;const n=performance.now();if(n-last>1000){$('fps').textContent=fpsC;fpsC=0;last=n}$('f-frames').textContent=fc};
// Status-Poll
async function poll(){try{const s=await(await fetch('/api/status')).json();lastStatus=s;
$('b-run').textContent=s.running?'läuft':'gestoppt';$('b-run').className='badge'+(s.running?'':' err');
const a=s.artnet||{},st=a.stats||{};
$('b-dmx').textContent=a.enabled?('Art-Net: '+st.packets+' Pkts'):'Art-Net: aus';
$('d-frames').textContent=s.frames_rendered;$('d-pkts').textContent=st.packets||0;
$('d-acc').textContent=st.accepted||0;$('d-media').textContent=s.media_count??'';
$('d-err').textContent=s.error||'';
if(document.activeElement!==$('master')){$('master').value=s.master*100;$('mv').textContent=Math.round(s.master*100)+'%'}
renderQuick(s.layers||[]);renderLayerEdit(s.layers||[]);
}catch(e){}setTimeout(poll,1000)}
function renderQuick(ls){const q=$('quick');
if(q.childElementCount!==ls.length){q.innerHTML=ls.map(L=>`<div class="ll"><span class="nm" title="${L.name}">${L.id}: ${L.name}</span><input type="range" min="0" max="100" value="${L.alpha*100}" oninput="api('layers/update',{id:${L.id},alpha:this.value/100});this.nextElementSibling.textContent=this.value+'%'"><span class="val">${Math.round(L.alpha*100)}%</span></div>`).join('')}
else{ls.forEach((L,i)=>{const r=q.children[i].children[1];if(document.activeElement!==r)r.value=L.alpha*100;q.children[i].children[2].textContent=Math.round(L.alpha*100)+'%'})}}
function renderLayerEdit(ls){const e=$('ledit');
if(e.dataset.n!=String(ls.length)){e.dataset.n=ls.length;
e.innerHTML=ls.map(L=>`<div class="setgrp"><h3>#${L.id} ${L.name} <button class="danger" style="float:right" onclick="api('layers/remove',{id:${L.id}})">Entfernen</button></h3>
<div class="frow"><label>Alpha</label><input type="range" id="la${L.id}" min="0" max="100" value="${L.alpha*100}" oninput="api('layers/update',{id:${L.id},alpha:this.value/100})"></div>
<div class="frow"><label>Position X</label><input type="number" id="lx${L.id}" value="${L.x}" onchange="api('layers/update',{id:${L.id},x:this.value})"> Y <input type="number" id="ly${L.id}" value="${L.y}" onchange="api('layers/update',{id:${L.id},y:this.value})"></div>
<div class="frow"><label>Größe</label><input type="number" id="lw${L.id}" value="${L.width}" onchange="api('layers/update',{id:${L.id},width:this.value})"> × <input type="number" id="lh${L.id}" value="${L.height}" onchange="api('layers/update',{id:${L.id},height:this.value})"> px</div>
<div class="frow"><label>Z-Order</label><input type="number" id="lz${L.id}" min="0" max="15" value="${L.z}" onchange="api('layers/update',{id:${L.id},z:this.value})"></div>
<div class="frow"><label>Effekt 1</label><select id="fx1-${L.id}" onchange="api('layers/update',{id:${L.id},fx1:this.value||null})"></select></div>
<div class="frow"><label>FX1 Intensität</label><input type="range" id="fx1i-${L.id}" min="0" max="100" value="${Math.round((L.fx1_intensity??0.5)*100)}" oninput="api('layers/update',{id:${L.id},fx1_intensity:this.value/100})"></div>
<div class="frow"><label>Effekt 2</label><select id="fx2-${L.id}" onchange="api('layers/update',{id:${L.id},fx2:this.value||null})"></select></div>
<div class="frow"><label>FX2 Intensität</label><input type="range" id="fx2i-${L.id}" min="0" max="100" value="${Math.round((L.fx2_intensity??0.5)*100)}" oninput="api('layers/update',{id:${L.id},fx2_intensity:this.value/100})"></div>
</div>`).join('')||'<p style="color:var(--dim)">Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.</p>'}}
// Medien
async function loadMedia(){try{const d=await(await fetch('/api/media')).json();
$('mgrid').innerHTML=(d.items||[]).map(m=>`<div class="card">
<img src="/thumb/${encodeURIComponent(m.name)}" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22192%22 height=%22108%22%3E%3Crect fill=%22%230d0f12%22 width=%22192%22 height=%22108%22/%3E%3Ctext x=%2260%22 y=%2256%22 fill=%22%238a8f9a%22 font-size=%2213%22%3E${m.type==='video'?'🎬 Video':'🖼 Bild'}%3C/text%3E%3C/svg%3E'" onclick="window.open('/media/${encodeURIComponent(m.name)}')">
<div class="nm" title="${m.name}">${m.name}</div>
<div class="meta">${fmtSize(m.size)}${m.duration_s?' · '+m.duration_s+'s':''}</div>
<div class="btns"><button class="acc" onclick="addLayer('${m.name.replace(/'/g,"\\'")}')">Als Layer</button><button class="danger" onclick="delMedia('${m.name.replace(/'/g,"\\'")}')">✕</button></div>
</div>`).join('')||'<p style="color:var(--dim)">Noch keine Medien. Dateien oben hochladen.</p>'}catch(e){}}
function fmtSize(b){if(b>1048576)return(b/1048576).toFixed(1)+' MB';if(b>1024)return(b/1024).toFixed(0)+' KB';return b+' B'}
async function addLayer(name){const d=await api('layers/add',{name});if(d.layer){toast('Layer #'+d.layer.id+' hinzugefügt ('+name+')');showTab('live')}}
async function delMedia(name){if(!confirm(name+' löschen?'))return;await api('media/delete',{name});loadMedia()}
// Upload (Drag&Drop + Klick + Fortschritt)
const drop=$('drop'),file=$('file');
drop.onclick=()=>file.click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hot')};
drop.ondragleave=()=>drop.classList.remove('hot');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hot');uploadFiles(e.dataTransfer.files)};
file.onchange=()=>{uploadFiles(file.files);file.value=''};
function uploadFiles(files){let i=0;
const next=()=>{if(i>=files.length){$('upbar').style.display='none';loadMedia();return}
const f=files[i++];upOne(f,()=>next())};next()}
function upOne(f,done){
$('upbar').style.display='block';$('uptext').textContent='Lade '+f.name+' ('+fmtSize(f.size)+')…';$('uprog').value=0;
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/media/upload');
xhr.setRequestHeader('X-Filename',encodeURIComponent(f.name));
xhr.upload.onprogress=e=>{if(e.lengthComputable)$('uprog').value=e.loaded/e.total};
xhr.onload=()=>{if(xhr.status===200){toast(f.name+' gespeichert')}else{let m='';try{m=JSON.parse(xhr.responseText).error}catch(e){}toast('Upload fehlgeschlagen: '+(m||xhr.status),true)}done()};
xhr.onerror=()=>{toast('Netzwerkfehler beim Upload',true);done()};
xhr.send(f);}
// Settings
async function loadSettings(){try{const s=await(await fetch('/api/settings')).json();
$('s-web-port').value=s.web.port;$('s-web-bind').value=s.web.bind;
$('s-preview-width').value=s.preview.width;$('s-preview-height').value=s.preview.height;$('s-preview-fps').value=s.preview.fps;$('s-preview-q').value=s.preview.jpeg_quality;
$('s-artnet-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port;
$('s-artnet-universes').value=s.artnet.universes.join(',');
$('s-artnet-master').value=s.artnet.master_channel;$('s-artnet-layer').value=s.artnet.layer_start_channel;
$('s-artnet-playback').value=s.artnet.playback_channel;$('s-artnet-retrigger').value=s.artnet.retrigger_channel;$('s-artnet-blackout').value=s.artnet.blackout_channel;
$('s-artnet-policy').value=s.artnet.signal_loss_policy;$('s-artnet-timeout').value=s.artnet.signal_loss_timeout_s;
$('s-engine-max').value=s.engine.max_layers;$('s-engine-loop').checked=s.engine.loop_default;$('s-output-fs').checked=s.output.fullscreen;
$('s-media-dir').value=s.media.dir;$('s-media-max').value=s.media.max_upload_mb;
}catch(e){}}
async function saveSettings(){const body={
web:{port:+$('s-web-port').value,bind:$('s-web-bind').value},
preview:{width:+$('s-preview-width').value,height:+$('s-preview-height').value,fps:+$('s-preview-fps').value,jpeg_quality:+$('s-preview-q').value},
artnet:{enabled:$('s-artnet-enabled').checked,listen_port:+$('s-artnet-port').value,
universes:$('s-artnet-universes').value.split(',').map(x=>parseInt(x.trim())).filter(x=>!isNaN(x)),
master_channel:+$('s-artnet-master').value,layer_start_channel:+$('s-artnet-layer').value,
playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,
signal_loss_policy:$('s-artnet-policy').value,signal_loss_timeout_s:+$('s-artnet-timeout').value},
engine:{max_layers:+$('s-engine-max').value,loop_default:$('s-engine-loop').checked},
output:{fullscreen:$('s-output-fs').checked},
media:{dir:$('s-media-dir').value,max_upload_mb:+$('s-media-max').value}};
const d=await api('settings',body);
if(d.saved){$('setmsg').textContent='Gespeichert ✓';setTimeout(()=>$('setmsg').textContent='',2500);
if(d.hint)toast(d.hint)}}
async function loadProjects(){try{const d=await(await fetch('/api/projects')).json();
const tb=document.querySelector('#projtable tbody');
tb.innerHTML=(d.items||[]).map(p=>`<tr><td>${p.name}${d.autostart===p.name?' <span class="badge">Autostart</span>':''}</td><td>${p.layers}</td><td>${p.saved_at||''}</td>
<td><button class="acc" onclick="loadProject('${p.name}')">Laden</button> <button onclick="setAutostart('${p.name}')">Autostart</button> <button class="danger" onclick="delProject('${p.name}')">✕</button></td></tr>`).join('')||'<tr><td colspan="4" style="color:var(--dim)">Noch keine Projekte gespeichert.</td></tr>'}catch(e){}}
async function saveProject(){const name=$('projname').value.trim();if(!name){toast('Bitte Projektnamen eingeben',true);return}
const d=await api('projects/save',{name});if(d.saved){toast('Projekt gespeichert: '+d.saved);$('projname').value='';loadProjects()}}
async function loadProject(name){const d=await api('projects/load',{name});if(d.loaded){toast('Projekt geladen: '+name);showTab('live')}else toast(d.error||'Laden fehlgeschlagen',true)}
async function delProject(name){if(!confirm(name+' löschen?'))return;await api('projects/delete',{name});loadProjects()}
async function setAutostart(name){const d=await api('projects/autostart',{name});if(d.autostart){toast('Autostart aktiv: '+d.autostart);loadProjects()}}
let FX=[];
async function loadFx(){try{FX=await(await fetch('/api/fx')).json();fillFxSelects()}catch(e){}}
function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id^="fx2-"]').forEach(sel=>{
const cur=sel.dataset.cur||'';const isFx1=sel.id.startsWith('fx1-');
const key=isFx1?'fx1':'fx2';const lay=sel.dataset.lay;
const val=(lastStatus?.layers||[]).find(l=>String(l.id)===lay)?.[key]||'';
sel.innerHTML='<option value=""> keiner </option>'+FX.map(f=>`<option value="${f.id}"${f.id===val?' selected':''}>${f.label}</option>`).join('');});}
// Selects bei jedem Layer-Render nachfuellen + dataset setzen
const _origRender=renderLayerEdit;
renderLayerEdit=function(ls){_origRender(ls);document.querySelectorAll('select[id^="fx1-"]').forEach(s=>s.dataset.lay=s.id.slice(4));document.querySelectorAll('select[id^="fx2-"]').forEach(s=>s.dataset.lay=s.id.slice(4));fillFxSelects()};
loadFx();
poll();
</script>
</body>
</html>'''