Files
hms-mediaengine/hms_app/ui.py
T
HMS MediaEngine Agent 02765e6b34 CUE-SZENENLISTE PRO LAYER + macOS-SUPPORT (35/35 E2E, 123 Checks gesamt)
1) Cue-/Szenenliste JE Layer (35/35 E2E):
- Cue sichern = Snapshot (Quelle, Alpha, Position, Groesse, Z-Order,
  FX1/FX2 + Intensitaeten); Name + fade_ms + follow_ms pro Cue
- GO vor/zurueck (steps), Goto (Index), Wrap-around am Listenende
- FADE: Quellwechsel = dip-to-black (Phase1 runter, Wechsel bei 0,
  Phase2 hoch; E2E: alpha 0.49 -> 0.32 -> Ziel 0.8), sonst sanfter
  Tween von Alpha + FX-Intensitaeten; laufende Fades abbrechbar
- AUTO-FOLLOW: follow_ms schaltet nach Ablauf automatisch weiter
  (E2E: Cue mit 600ms -> Wrap zum naechsten)
- DMX CUE-GO: cue_go_channel (Settings + UI), steigende Flanke
  >=128 = GO auf allen Layern; ohne Flanke kein GO (E2E: 0->1,
  1==1 bei High-Halten, neue Flanke 1->0); Sequenzfilter aktiv
- Cue-Entfernen mit Indexkorrektur; Cues in Projekten
  speicher-/ladbar (3 Cues nach Projekt-Load erhalten)
- UI: Cue-Bereich je Layer (Cue sichern / GO / Zurueck / Goto /
  Loeschen, aktiver Cue markiert, Fade+Follow-Anzeige)
- Fehlerfaelle: unbekannter Layer 400, fehlendes Medium 400,
  unbekannter Generator 400

2) macOS (Apple Silicon + Intel) mit Skript:
- setup_macos.sh: Homebrew installiert python@3.12, python-tk,
  GStreamer + alle Plugin-Sets + PyGObject; Apple-Silicon-PATH-
  Hinweis (/opt/homebrew); Erklaerung warum Homebrew statt
  gstreamer-.pkg (keine PyGObject-Bindings in .pkg)
- Bootstrap (--bootstrap): darwin-Zweig mit brew-Installationspfad
- run.py --generate-setup erzeugt jetzt 3 Skripte
  (Windows .ps1 / Linux .sh / macOS .sh); Bash-Syntax geprueft

3) Gefundene + behobene FEHLER:
- WEB-UI-ABSTURZ (echter JS-Ternary-Klammerfehler in loadServers,
  seit Verteilungs-Commit unentdeckt, weil API-Tests den Browser-
  Code nie parsen): Klammerstruktur korrigiert + JS-Syntax nun per
  node --check verifizierbar (Syntax OK) + Remote-Buttons robust
  per data-Attribute (kein Quote-Escaping mehr)
- update_layer blockierte Quellwechsel Video->Generator (Cues
  brauchen beides): Name entscheidet jetzt den Typ, Wechsel in
  beide Richtungen
- Cue-E2E-Timing: dip-to-black hat 2 Phasen (Pruefung 0.2s/0.6s),
  Auto-Follow-Index-Fix, Art-Net-Sequenznummern im Test
  inkrementierend (Sequenzfilter verwirft Duplikate korrekt)

E2E-Bilanz: Cue 35/35 + Regressionen Projekte 25/25,
Verteilung 33/33, App 14/14, FX 16/16 = 123 Checks gruen
2026-09-11 13:34:43 +02:00

368 lines
28 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>
<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>
</div>
<div id="page-layers" class="page">
<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>
<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>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>
<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>Autostart-Projekt</label><select id="s-autostart"><option value=""> keins </option></select><span class="hint">lädt beim Programmstart</span></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>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>
<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();loadServers()}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>
<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);
}
// 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();
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();
$('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-cuego').value=s.artnet.cue_go_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,cue_go_channel:+$('s-artnet-cuego').value||null,
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,autostart_project:$('s-autostart').value||null},
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()}}
// ---------- 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>`+
(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>'
).join('')||'<p style="color:var(--dim);font-size:11px">Noch keine Server. In den Einstellungen registrieren.</p>';
}catch(e){}}
// ---------- 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()}
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();
loadGens();
loadServers();
poll();
</script>
</body>
</html>'''