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>
2026-09-11 12:55:50 +02:00
<button data-p= " projects " >Projekte</button>
2026-09-11 11:05:01 +02:00
<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>
2026-09-11 13:11:54 +02:00
<h2 style= " margin-top:20px " >Remote-Server</h2>
<p style= " color:var(--dim);font-size:11px " >Medien von anderen HMS-Servern ins lokale Archiv ziehen:</p>
<div id= " rgrid " ></div>
2026-09-11 11:05:01 +02:00
</div>
<div id= " page-layers " class= " page " >
2026-09-11 13:11:54 +02:00
<div class= " row " >
<button class= " acc " onclick= " showTab( ' media ' ) " >+ Medien hinzufügen</button>
<select id= " gensel " style= " width:200px " ><option value= " " >+ Generator…</option></select>
<button onclick= " addGen() " >Hinzufügen</button>
</div>
2026-09-11 11:05:01 +02:00
<div id= " ledit " ></div>
</div>
2026-09-11 12:55:50 +02:00
<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>
2026-09-11 11:05:01 +02:00
<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>
2026-09-11 13:34:43 +02:00
<div class= " frow " ><label>Kanal Cue-GO</label><input type= " number " id= " s-artnet-cuego " min= " 1 " max= " 512 " ><span class= " hint " >Flanke ≥128 = nächster Cue je Layer</span></div>
2026-09-11 11:05:01 +02:00
<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>
2026-09-11 13:11:54 +02:00
<div class= " frow " ><label>Autostart-Projekt</label><select id= " s-autostart " ><option value= " " >– keins – </option></select><span class= " hint " >lädt beim Programmstart</span></div>
2026-09-11 11:05:01 +02:00
<div class= " frow " ><label>Fullscreen-Ausgabe</label><input type= " checkbox " id= " s-output-fs " ><span class= " hint " >Windows: D3D11 · Linux: autovideosink</span></div>
</div>
2026-09-11 13:11:54 +02:00
<div class= " setgrp " ><h3>Remote-Server (Medien-Verteilung)</h3>
<div class= " frow " ><label>Neuer Server</label><input type= " text " id= " srv-name " placeholder= " Name " style= " width:120px " ><input type= " text " id= " srv-url " placeholder= " http://192.168.1.50:8080 " style= " width:240px " ><button onclick= " addServer() " >+ Hinzufügen</button></div>
<div id= " srvlist " ></div>
</div>
2026-09-11 11:05:01 +02:00
<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));
2026-09-11 13:11:54 +02:00
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();loadServers()}if(p=== ' settings ' )loadSettings();if(p=== ' projects ' )loadProjects();}
2026-09-11 11:05:01 +02:00
// 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 13:34:43 +02:00
<h2 style= " margin-top:10px " >Cue-Liste (Szenen)</h2>
<div id= " cues-$ {L.id} " ></div>
<div class= " row " >
<button class= " acc " onclick= " addCue($ {L.id} ) " >Cue sichern</button>
<button onclick= " cueGo($ {L.id} ,1) " >GO ▶</button>
<button onclick= " cueGo($ {L.id} ,-1) " >◀ Zurück</button>
</div>
</div>`).join( ' ' )|| ' <p style= " color:var(--dim) " >Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.</p> ' }
ls.forEach(renderCues);
}
2026-09-11 11:05:01 +02:00
// 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();
2026-09-11 13:11:54 +02:00
try { const pr=await(await fetch( ' /api/projects ' )).json();const sel=$( ' s-autostart ' );sel.innerHTML= ' <option value= " " >– keins – </option> ' +(pr.items||[]).map(p=>`<option value= " $ {p.name} " $ { pr.autostart===p.name? ' selected ' : ' ' }>$ {p.name} </option>`).join( ' ' )}catch(e) {}
loadServers();
2026-09-11 11:05:01 +02:00
$( ' 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;
2026-09-11 13:34:43 +02:00
$( ' 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-cuego ' ).value=s.artnet.cue_go_channel?? ' ' ;
2026-09-11 11:05:01 +02:00
$( ' 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,
2026-09-11 13:34:43 +02:00
playback_channel:+$( ' s-artnet-playback ' ).value,retrigger_channel:+$( ' s-artnet-retrigger ' ).value,blackout_channel:+$( ' s-artnet-blackout ' ).value,cue_go_channel:+$( ' s-artnet-cuego ' ).value||null,
2026-09-11 11:05:01 +02:00
signal_loss_policy:$( ' s-artnet-policy ' ).value,signal_loss_timeout_s:+$( ' s-artnet-timeout ' ).value},
2026-09-11 13:11:54 +02:00
engine: { max_layers:+$( ' s-engine-max ' ).value,loop_default:$( ' s-engine-loop ' ).checked,autostart_project:$( ' s-autostart ' ).value||null},
2026-09-11 11:05:01 +02:00
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:55:50 +02:00
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()}}
2026-09-11 13:11:54 +02:00
// ---------- Generatoren ----------
let GENS=[];
async function loadGens() { try { const d=await(await fetch( ' /api/generators ' )).json();GENS=d.items||[];$( ' gensel ' ).innerHTML= ' <option value= " " >+ Generator…</option> ' +GENS.map(g=>`<option value= " $ {g.id} " >$ {g.label} </option>`).join( ' ' )}catch(e) {} }
async function addGen() { const g=$( ' gensel ' ).value;if(!g) { toast( ' Generator wählen ' ,true);return}
const d=await api( ' layers/add-generator ' , {generator:g} );if(d.layer) { toast( ' Generator-Layer: ' +g);$( ' gensel ' ).value= ' ' }else if(d.error)toast(d.error,true)}
// ---------- Remote-Server ----------
async function addServer() { const name=$( ' srv-name ' ).value.trim(),url=$( ' srv-url ' ).value.trim();
if(!name||!url) { toast( ' Name und URL eingeben ' ,true);return}
const d=await api( ' servers/add ' , { name,url});if(d.added) { toast( ' Server hinzugefügt: ' +name);$( ' srv-name ' ).value= ' ' ;$( ' srv-url ' ).value= ' ' ;loadServers()}
else toast(d.error|| ' Server nicht erreichbar oder kein HMS MediaEngine ' ,true)}
async function delServer(name) { if(!confirm(name+ ' entfernen? ' ))return;await api( ' servers/remove ' , {name} );loadServers()}
async function importRemote(srv,media) { toast( ' Importiere ' +media+ ' von ' +srv+ ' … ' );
const d=await api( ' servers/import ' , { server:srv,media});if(d.saved) { toast( ' Importiert: ' +d.saved);loadMedia()}else toast(d.error|| ' Import fehlgeschlagen ' ,true)}
async function loadServers() { try { const d=await(await fetch( ' /api/servers ' )).json();const servers=d.servers||[];
// Einstellungen: Server-Liste
const sl=$( ' srvlist ' );if(sl)sl.innerHTML=servers.map(S=>`<div class= " frow " ><label>$ {S.name} <br><small style= " color:var(--dim) " >$ {S.url} </small></label><span class= " badge$ { S.online? ' ' : ' err ' } " >$ { S.online? ' online ' : ' offline ' }</span><button class= " danger " onclick= " delServer( ' $ {S.name} ' ) " >✕</button></div>`).join( ' ' )|| ' <p style= " color:var(--dim);font-size:11px " >Noch keine Server registriert.</p> ' ;
// Medien-Tab: Remote-Medien
const rg=$( ' rgrid ' );if(rg)rg.innerHTML=servers.map(S=>
`<div class= " setgrp " ><h3>$ {S.name} <span class= " badge$ { S.online? ' ' : ' err ' } " >$ { S.online? ' online ' : ' offline ' }</span></h3>`+
2026-09-11 13:34:43 +02:00
(S.online?(S.items||[]).map(m=>`<div class= " frow " ><label>$ {m.name} <small style= " color:var(--dim) " >$ { fmtSize(m.size)}</small></label><button class= " acc " data-srv= " $ {S.name} " data-m= " $ {m.name} " onclick= " importRemote(this.dataset.srv,this.dataset.m) " >Ins Archiv ziehen</button></div>`).join( ' ' )|| ' <p style= " color:var(--dim);font-size:11px " >Keine Medien auf dem Server.</p> ' : ' ' )+ ' </div> '
2026-09-11 13:11:54 +02:00
).join( ' ' )|| ' <p style= " color:var(--dim);font-size:11px " >Noch keine Server. In den Einstellungen registrieren.</p> ' ;
}catch(e) {} }
2026-09-11 13:34:43 +02:00
// ---------- Cue-Listen (Szenen) pro Layer ----------
function cueRow(L,c,i) { return `<div class= " frow " style= " padding:2px 0 " >
<label>$ { i+1}. $ {c.name} <small style= " color:var(--dim) " >$ {c.fade_ms} ms Fade$ { c.follow_ms? ' · Auto ' +Math.round(c.follow_ms/1000)+ ' s ' : ' ' }</small></label>
$ { i===L.cue_index? ' <span class= " badge " >aktiv</span> ' : ' ' }
<button onclick= " cueGoto($ {L.id} ,$ {i} ) " >Goto</button>
<button class= " danger " onclick= " delCue($ {L.id} ,$ {c.cue_id} ) " >✕</button></div>`}
function renderCues(L) { const box=$( ' cues- ' +L.id);if(!box)return;
const ls=(L.cues||[]);
if(ls.length===0) { box.innerHTML= ' <p style= " color:var(--dim);font-size:11px " >Noch keine Cues. Button unten sichert den aktuellen Zustand als Cue.</p> ' ;return}
box.innerHTML=ls.map((c,i)=>cueRow(L,c,i)).join( ' ' )}
async function addCue(id) { const nm=prompt( ' Cue-Name: ' , ' Cue ' );if(nm===null)return;
const fm=prompt( ' Fade-Zeit in ms (0 = harter Schnitt): ' , ' 500 ' );if(fm===null)return;
const fo=prompt( ' Auto-Follow in ms (0 = aus, sonst Weiter nach Ablauf): ' , ' 0 ' );if(fo===null)return;
const d=await api( ' layers/cues/add ' , { id,name:nm||undefined,fade_ms:+fm||0,follow_ms:+fo||0});
if(d.cue)toast( ' Cue gesichert: ' +d.cue.name);else if(d.error)toast(d.error,true)}
async function cueGo(id,steps) { const d=await api( ' layers/cues/go ' , { id,steps});
if(d.cue)toast( ' GO → ' +d.cue);else if(d.error)toast(d.error,true)}
async function cueGoto(id,index) { const d=await api( ' layers/cues/goto ' , { id,index});
if(d.cue)toast( ' Goto: ' +d.cue)}
async function delCue(id,cueId) { if(!confirm( ' Cue entfernen? ' ))return;
await api( ' layers/cues/remove ' , { id,cue_id:cueId});
poll()}
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 13:11:54 +02:00
loadGens();
loadServers();
2026-09-11 11:05:01 +02:00
poll();
</script>
</body>
</html> '''