2026-09-11 11:05:01 +02:00
""" 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= " 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-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 & 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();}
// 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>
2026-09-11 12:01:22 +02:00
<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>
2026-09-11 11:05:01 +02:00
</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)}}
2026-09-11 12:01:22 +02:00
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();
2026-09-11 11:05:01 +02:00
poll();
</script>
</body>
</html> '''