VoicePult/web/app.js
2026-10-03 11:07:47 +02:00

275 lines
25 KiB
JavaScript
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.

'use strict';
const icons = {
plus:'M12 5v14M5 12h14', arrow:'M5 12h14m-5-5 5 5-5 5', close:'m6 6 12 12M6 18 18 6',
apps:'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
sliders:'M5 4v7m0 4v5M12 4v3m0 4v9M19 4v11m0 4v1M2 11h6M9 7h6m1 8h6',
routing:'M5 6h5a3 3 0 0 1 3 3v6a3 3 0 0 0 3 3h3M5 18h4m7-12h3M3 4h3v4H3zM18 4h3v4h-3zM3 16h3v4H3zM18 16h3v4h-3z',
book:'M12 5v15M3 4c4-1 7 0 9 2 2-2 5-3 9-2v15c-4-1-7 0-9 2-2-2-5-3-9-2z',
info:'M12 11v6m0-10v.1M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',
headphones:'M4 14v-3a8 8 0 0 1 16 0v3M4 12h3v8H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2m16 0h-3v8h3a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2',
mic:'M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0zm-3 6v1a6 6 0 0 0 12 0v-1M12 18v4m-3 0h6',
volume:'M11 4 6 8H2v8h4l5 4zM15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14',
mute:'M11 4 6 8H2v8h4l5 4zm5 5 6 6m-6 0 6-6',
trash:'M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7',
edit:'m14 4 6 6M4 20l1-6L16 3a2 2 0 0 1 3 0l2 2a2 2 0 0 1 0 3L10 19z',
check:'m5 12 4 4L19 6', link:'m10 14 4-4m-6 6-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m4 0 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',
wave:'M3 10v4m4-8v12m5-15v18m5-15v12m4-8v4',
};
const svg = name => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${icons[name] || icons.wave}"/></svg>`;
document.querySelectorAll('[data-icon]').forEach(el => el.innerHTML = svg(el.dataset.icon));
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let accessCode = localStorage.getItem('voicepult-access') || '';
const accessHeaders = () => ({'X-VoicePult-Access': accessCode});
let state, token, busy = false, editing = null, deleting = null, toastTimer, lastRendered = '';
function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 4000); }
function option(id, name, current) { return `<option value="${esc(id)}" ${id === current ? 'selected' : ''}>${esc(name)}</option>`; }
function deviceOptions(list, current) { return (list.some(d => d.id === current) ? '' : option(current, 'Gerät nicht verfügbar', current)) + list.map(d => option(d.id, d.name, current)).join(''); }
function appCard(a, s, view) {
const cls = ['firefox','discord','spotify'].find(c => a.name.toLowerCase().includes(c)) || '';
return `<article class="app-card"><div class="app-top"><div class="app-icon ${cls}">${esc((a.name[0] || '?').toUpperCase())}</div><div><div class="app-name">${esc(a.name)}</div><div class="app-state ${a.active ? '' : 'idle'}"><span class="local-dot"></span>${a.active ? 'Spielt Audio' : a.present === false ? 'Ohne Audiostream' : 'Pausiert'}</div></div><span>${a.streams > 1 ? a.streams + ' Streams' : ''}</span></div><div class="field-label">KANÄLE · MEHRFACHAUSWAHL ${svg('routing')}</div><div class="channel-choices">${s.channels.map(c=>`<label><input type="checkbox" id="${esc(view + '-' + encodeURIComponent(a.id) + '-' + c.id)}" data-app-route="${esc(a.id)}" value="${c.id}" ${(a.channels||[]).includes(c.id)?'checked':''} aria-label="${esc(a.name)} an ${esc(c.name)}">${esc(c.name)}</label>`).join('') || '<small>Erstelle zuerst einen Kanal.</small>'}</div><small class="routing-note">${a.channels?.length ? a.channels.length + (a.channels.length===1?' Kanal verbunden':' Kanäle verbunden') : 'Keine Auswahl: direkter Geräteausgang'}</small></article>`;
}
function render(s) {
state = s;
updateScopeControls(s);
document.documentElement.dataset.theme = s.theme || 'light';
$('dark-mode').checked = s.theme === 'dark';
if (document.activeElement !== $('wave-gain')) $('wave-gain').value = s.wave_gain ?? 8;
$('wave-gain-value').textContent = '×' + $('wave-gain').value;
$('wave-scale-label').textContent = 'Ansicht ×' + $('wave-gain').value + ' · Pegel unverändert';
if (s.network) {
$('network-panel').hidden = !s.network.enabled;
$('network-links').innerHTML = s.network.urls.map(url=>`<a href="${esc(url)}">${esc(url)}</a>`).join('<br>');
$('network-code').value = s.network.key || 'Auf dem Rechner anzeigen';
$('reveal-code').hidden = !s.network.key;
}
$('engine-label').textContent = s.online ? 'PipeWire / PulseAudio bereit' : 'Audio nicht verbunden';
$('engine-dot').classList.toggle('online', s.online);
$('live-label').textContent = s.online ? 'Live verbunden' : 'Verbindung prüfen';
document.querySelector('.local-badge').lastChild.textContent = s.online ? ' Lokal verbunden' : ' Audio offline';
$('error-banner').hidden = !s.error;
$('error-banner').textContent = s.error ? `Audio-Verbindung: ${s.error}` : '';
$('app-count').textContent = s.apps.length;
$('channel-count').textContent = s.channels.length;
$('nav-count').textContent = s.channels.length;
$('route-count').textContent = s.channels.reduce((n,c) => n + Number(c.monitor) + Number(c.microphone) + (c.targets||[]).length, 0) + Number(s.mic_enabled);
$('saved-count').textContent = s.saved_apps?.length || 0;
const signature = JSON.stringify([s.targets,s.saved_apps,s.apps,s.channels,s.outputs,s.inputs,s.output,s.input,s.mic_enabled,s.mic_available,s.mic_consumers,s.mic_channels]);
// Preserve focus, open selectors and sliders while background state refreshes.
if (signature === lastRendered || document.activeElement?.matches('select,input[type="range"]') && !busy) return;
const focusedId = document.activeElement?.id;
lastRendered = signature;
$('apps').innerHTML = s.apps.length ? s.apps.map(a => appCard(a,s,'live')).join('') : `<div class="empty-card">${svg('apps')}<strong>Noch keine Audioquellen</strong><p>Spiele Audio in Firefox oder einer anderen Anwendung ab.</p><a href="#saved-apps">Gespeicherte Apps öffnen</a></div>`;
$('saved-app-list').innerHTML = s.saved_apps?.length ? s.saved_apps.map(a => appCard(a,s,'saved')).join('') : `<div class="empty-card">${svg('apps')}<strong>Noch keine gespeicherten Zuordnungen</strong><p>Ordne im Patchpanel einer Anwendung einen Kanal zu. Sie bleibt dann hier auch ohne Wiedergabe sichtbar.</p><a href="#patchpanel">Zum Patchpanel</a></div>`;
$('microphone-routing').innerHTML = `<article class="app-card microphone-source"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">Dein Mikrofon</div><div class="app-state">${esc(s.inputs.find(d=>d.id===s.input)?.name || 'Gerät wählen')}</div></div></div><div class="field-label">AN KANÄLE SENDEN · MEHRFACHAUSWAHL</div><div class="channel-choices">${s.channels.map(c=>`<label><input type="checkbox" data-mic-route value="${c.id}" ${(s.mic_channels||[]).includes(c.id)?'checked':''} aria-label="Mikrofon an ${esc(c.name)}">${esc(c.name)}</label>`).join('') || '<small>Erstelle zuerst einen Kanal.</small>'}</div><small class="routing-note">Mehrere Wege zum gleichen Ziel werden addiert. Kopfhörer am Mikrofon-Kanal nur zum Mithören aktivieren.</small></article>`;
$('channels').innerHTML = s.channels.length ? s.channels.map((c,i) => {
const apps = s.apps.filter(a => (a.channels||[]).includes(c.id));
const count = apps.length;
return `<article class="channel-card"><div class="channel-header"><span class="channel-symbol">${svg('sliders')}</span><span class="channel-name">${esc(c.name)}</span><span class="channel-id">CH ${String(i+1).padStart(2,'0')}</span><button class="icon-button" data-edit="${c.id}" aria-label="${esc(c.name)} umbenennen" title="Umbenennen">${svg('edit')}</button><button class="icon-button" data-delete="${c.id}" aria-label="${esc(c.name)} löschen" title="Kanal löschen">${svg('trash')}</button></div><div class="channel-body"><div class="channel-caption"><span>${count ? count + (count === 1 ? ' Anwendung verbunden' : ' Anwendungen verbunden') : 'Bereit für deine Anwendungen'}</span><span>${c.muted ? 'STUMM' : 'STEREO'}</span></div><div class="volume-row"><button class="icon-button ${c.muted?'muted':''}" data-mute="${c.id}" aria-label="${esc(c.name)} ${c.muted ? 'einschalten' : 'stummschalten'}" aria-pressed="${c.muted}">${svg(c.muted?'mute':'volume')}</button><input id="volume-${c.id}" type="range" min="0" max="100" value="${c.volume}" data-volume="${c.id}" aria-label="Lautstärke ${esc(c.name)}"><output>${c.volume}%</output></div><span class="route-label">WEITERLEITEN AN</span><div class="route-buttons"><button class="route-button ${c.monitor?'active':''}" data-route="monitor" data-channel="${c.id}" aria-pressed="${c.monitor}">${svg('headphones')}Kopfhörer ${c.monitor?svg('check'):''}</button><button class="route-button ${c.microphone?'active':''}" data-route="microphone" data-channel="${c.id}" aria-pressed="${c.microphone}">${svg('mic')}Mikrofon-Mix ${c.microphone?svg('check'):''}</button></div><div class="channel-choices extra-routes">${(s.targets||[]).map(t=>`<label><input type="checkbox" id="target-route-${c.id}-${t.id}" data-target-route="${c.id}" value="${t.id}" ${(c.targets||[]).includes(t.id)?'checked':''} aria-label="${esc(c.name)} an VoicePult ${esc(t.name)}">${svg('mic')}${esc(t.name)}</label>`).join('')}</div></div></article>`;
}).join('') : `<div class="empty-card">${svg('sliders')}<strong>Dein erster Kanal</strong><p>Ein Kanal verbindet eine oder mehrere Anwendungen mit deinen Ausgängen.</p></div>`;
const listening = s.channels.filter(c => c.monitor).length, sharing = s.channels.filter(c => c.microphone).length;
$('outputs').innerHTML = `<article class="output-card"><div class="app-top"><div class="app-icon">${svg('headphones')}</div><div><div class="app-name">Kopfhörer</div><div class="app-state">Dein persönlicher Monitor</div></div></div><label class="field-label" for="output-device">AUSGABEGERÄT</label><select id="output-device" data-setting="output">${deviceOptions(s.outputs,s.output)}</select><div class="output-caption">${svg('link')}${listening} ${listening===1?'Kanal verbunden':'Kanäle verbunden'}</div></article><article class="output-card mic-card"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">Mikrofon-Mix</div><div class="app-state">Eingabegerät für Element, OBS & Co.</div></div></div><div class="mic-name">${svg('wave')}VoicePult Mikrofon</div><div class="output-caption">${svg('link')}${sharing} ${sharing===1?'Kanal verbunden':'Kanäle verbunden'}</div><div class="output-caption">${svg(s.mic_available ? 'check' : 'info')}${s.mic_available ? 'Als Mikrofon verfügbar' : 'Eingabegerät nicht verfügbar'}</div><div class="output-caption">${svg('mic')}${s.mic_consumers?.length ? 'Verwendet von: ' + s.mic_consumers.map(esc).join(', ') : 'Noch in keiner App als Eingabe gewählt'}</div><div class="input-section"><label class="toggle-label" for="mic-enabled">Stimme direkt in den Mix<input id="mic-enabled" type="checkbox" data-setting="mic_enabled" ${s.mic_enabled?'checked':''}></label><label class="field-label" for="input-device">DEIN MIKROFON</label><select id="input-device" data-setting="input">${deviceOptions(s.inputs,s.input)}</select></div></article>`;
$('extra-targets').innerHTML = (s.targets||[]).map(t=>`<article class="output-card"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">${esc(t.name)}</div><div class="app-state">Virtuelles Mikrofon</div></div><button class="icon-button" data-target-delete="${t.id}" aria-label="Mikrofon ${esc(t.name)} löschen">${svg('trash')}</button></div><div class="mic-name">${svg('wave')}VoicePult ${esc(t.name)}</div><p class="routing-note">In deiner App als Eingabegerät auswählen.</p><div class="output-caption">${svg('link')}${s.channels.filter(c=>(c.targets||[]).includes(t.id)).length} Kanäle verbunden</div><div class="volume-row"><button class="icon-button" data-target-mute="${t.id}" aria-label="Mikrofon ${esc(t.name)} ${t.muted?'einschalten':'stummschalten'}" aria-pressed="${t.muted}">${svg(t.muted?'mute':'volume')}</button><input type="range" id="target-volume-${t.id}" data-target-volume="${t.id}" min="0" max="100" value="${t.volume}" aria-label="Lautstärke Mikrofon ${esc(t.name)}"><output>${t.volume}%</output></div></article>`).join('');
if (focusedId) document.getElementById(focusedId)?.focus({preventScroll:true});
}
async function refresh() {
if (busy || document.hidden) return;
try {
const res = await fetch('/api/state', {headers: accessHeaders(), signal: AbortSignal.timeout(12000)});
if (res.status === 401) { token=null; if(!$('access-dialog').open) $('access-dialog').showModal(); return; }
if (!res.ok) throw new Error('Server nicht erreichbar');
const s = await res.json(); token = s.token; render(s);
} catch (error) {
$('error-banner').hidden = false;
$('error-banner').textContent = 'VoicePult ist nicht erreichbar. Starte die App mit ./start.sh. Die Verbindung wird automatisch erneut versucht.';
$('engine-label').textContent = 'Server nicht verbunden'; $('engine-dot').classList.remove('online'); $('live-label').textContent = 'Offline';
}
}
async function action(payload, message) {
if (busy) { await new Promise(resolve=>setTimeout(resolve,80)); return action(payload,message); }
busy = true;
try {
const res = await fetch('/api/action', {method:'POST',headers:{...accessHeaders(),'Content-Type':'application/json','X-VoicePult-Token':token},body:JSON.stringify(payload),signal:AbortSignal.timeout(30000)});
const result = await res.json(); if (!res.ok) throw new Error(result.error || 'Änderung fehlgeschlagen');
render(result); if (message) toast(message); return true;
} catch(error) { toast(error.message); lastRendered = ''; return false; }
finally { busy = false; refresh(); }
}
function openChannel(id = null) {
editing = id;
$('dialog-title').textContent = id ? 'Kanal umbenennen' : 'Neuer virtueller Kanal';
$('channel-submit').textContent = id ? 'Speichern' : 'Kanal erstellen';
$('channel-name').value = id ? state.channels.find(c => c.id === id).name : '';
$('channel-dialog').showModal(); $('channel-name').focus();
}
$('add-channel').onclick = () => openChannel(); $('add-channel-secondary').onclick = () => openChannel();
$('help-button').onclick = $('help-inline').onclick = () => $('help-dialog').showModal();
document.querySelectorAll('[data-close]').forEach(b => b.onclick = () => $(b.dataset.close).close());
$('channel-form').onsubmit = async e => {
e.preventDefault(); $('channel-submit').disabled = true;
const ok = await action({action:editing?'channel':'create',id:editing,name:$('channel-name').value},editing?'Kanal umbenannt':'Kanal erstellt');
$('channel-submit').disabled = false; if (ok) $('channel-dialog').close();
};
$('confirm-delete').onclick = async () => {
$('confirm-delete').disabled = true;
if (await action({action:'delete',id:deleting},'Kanal gelöscht')) $('delete-dialog').close();
$('confirm-delete').disabled = false;
};
document.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.edit) openChannel(b.dataset.edit);
if (b.dataset.delete) { deleting=b.dataset.delete; $('delete-message').textContent=`„${state.channels.find(c=>c.id===deleting).name}“ wird entfernt. Anwendungen wechseln zurück auf ihren Geräteausgang.`; $('delete-dialog').showModal(); }
if (b.dataset.route) {
const ch = state.channels.find(c => c.id === b.dataset.channel);
action({action:'channel',id:ch.id,[b.dataset.route]:!ch[b.dataset.route]});
}
if (b.dataset.mute) { const ch=state.channels.find(c=>c.id===b.dataset.mute); action({action:'channel',id:ch.id,muted:!ch.muted}); }
});
document.addEventListener('input', e => { if(e.target.dataset.volume) e.target.nextElementSibling.textContent=e.target.value+'%'; });
document.addEventListener('change', e => {
const el=e.target;
if(el.dataset.app) action({action:'assign',app:el.dataset.app,channel:el.value},'Verbindung gespeichert');
if(el.dataset.volume) action({action:'channel',id:el.dataset.volume,volume:Number(el.value)});
if(el.dataset.setting) action({action:'settings',[el.dataset.setting]:el.type==='checkbox'?el.checked:el.value});
});
refresh(); setInterval(refresh, 2500); document.addEventListener('visibilitychange', refresh);
// Real PCM envelopes from the local audio server; no microphone browser permission.
let scopeEnabled = true, scopeInFlight = false, scopeOptions = '', scopeController;
function updateScopeControls(s) {
scopeEnabled = s.scope_enabled ?? true;
$('scope-toggle').textContent=scopeEnabled?'Anzeige pausieren':'Anzeige starten';
$('scope-toggle').setAttribute('aria-pressed',String(scopeEnabled));
if(!scopeEnabled) clearScope('Anzeige pausiert');
const options = option('voicepult_microphone', 'Mikrofon-Mix', $('scope-source').value) + (s.targets||[]).map(t=>option('voicepult_target_'+t.id+'_mic','Mikrofon: '+t.name,$('scope-source').value)).join('') + s.channels.map(c => option('voicepult_'+c.id+'.monitor',c.name,$('scope-source').value)).join('');
if (options !== scopeOptions) {
const previous = $('scope-source').value;
$('scope-source').innerHTML = options;
if ([...$('scope-source').options].some(o => o.value === previous)) $('scope-source').value = previous;
else clearScope('Quelle entfernt – Mikrofon-Mix gewählt.');
scopeOptions = options;
}
if(s.scope_source && [...$('scope-source').options].some(o=>o.value===s.scope_source)) $('scope-source').value=s.scope_source;
if (document.activeElement !== $('mix-volume')) {
$('mix-volume').value = s.mix_volume ?? 100;
$('mix-value').textContent = (s.mix_volume ?? 100) + '%';
}
$('mix-mute').setAttribute('aria-pressed', String(s.mix_muted ?? false));
$('mix-mute').setAttribute('aria-label', s.mix_muted ? 'Mikrofon-Mix einschalten' : 'Mikrofon-Mix stummschalten');
$('mix-mute').innerHTML = svg(s.mix_muted ? 'mute' : 'volume');
}
function drawScope(channels = []) {
const canvas = $('scope-canvas'), width = canvas.clientWidth, height = 180;
const scale = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = height * scale;
const ctx = canvas.getContext('2d'); ctx.scale(scale,scale);
ctx.strokeStyle = document.documentElement.dataset.theme==='dark' ? '#33423a' : '#e1e8d7'; ctx.lineWidth = 1;
for (let x = 0; x <= width; x += width / 10 || 1) { ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,height);ctx.stroke(); }
[45,135].forEach(y=>{ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(width,y);ctx.stroke();});
channels.forEach((channel,index) => {
const mid = index ? 135 : 45, wave = channel.wave;
ctx.strokeStyle = channel.clipping ? '#ec775d' : index ? '#a9c977' : '#7ea94f'; ctx.lineWidth = 2;
ctx.beginPath();
wave.forEach((point,i) => { const x=(i+.5)*width/wave.length; ctx.moveTo(x,mid-Math.max(-1,Math.min(1,point[1]*Number($('wave-gain').value)))*40);ctx.lineTo(x,mid-Math.max(-1,Math.min(1,point[0]*Number($('wave-gain').value)))*40); });ctx.stroke();
});
}
function clearScope(message) {
drawScope(); $('scope-status').textContent=message; $('scope-clip').textContent='—';
['left','right'].forEach(side=>{ $('meter-'+side).value=-60; $('level-'+side).textContent='—'; });
}
async function pollScope() {
if ($('patchpanel').hidden || !scopeEnabled || document.hidden || !token || scopeInFlight) return;
scopeInFlight=true; const source=$('scope-source').value;
scopeController=new AbortController(); const timeout=setTimeout(()=>scopeController?.abort(),3000);
try {
const res=await fetch('/api/meter?source='+encodeURIComponent(source),{headers:{...accessHeaders(),'X-VoicePult-Token':token},signal:scopeController.signal});
const data=await res.json();
if (!scopeEnabled || source!==$('scope-source').value || document.hidden) return;
if (!res.ok || !data.ready) { clearScope(data.error || 'Verbinde Audioanzeige…'); return; }
drawScope(data.channels);
$('scope-status').textContent='Live · '+$('scope-source').selectedOptions[0].textContent;
data.channels.forEach((c,i)=>{ const side=i?'right':'left'; $('meter-'+side).value=c.db ?? -60; $('level-'+side).textContent=c.db===null?'−∞ dBFS':c.db.toFixed(1)+' dBFS'; });
$('scope-clip').textContent=data.channels.some(c=>c.clipping)?'Übersteuerung erkannt':'Kein Clipping';
} catch(e) { if(scopeEnabled && !document.hidden) clearScope('Keine Live-Audiodaten'); }
finally {clearTimeout(timeout);scopeInFlight=false;scopeController=null;}
}
$('scope-toggle').onclick=()=>{
scopeEnabled=!scopeEnabled; scopeController?.abort();
$('scope-toggle').textContent=scopeEnabled?'Anzeige pausieren':'Anzeige starten';
$('scope-toggle').setAttribute('aria-pressed',String(scopeEnabled));
clearScope(scopeEnabled?'Verbinde Audioanzeige…':'Anzeige pausiert'); action({action:'preferences',scope_enabled:scopeEnabled}); if(scopeEnabled) pollScope();
};
$('scope-source').onchange=()=>{scopeController?.abort();clearScope('Verbinde Audioanzeige…');action({action:'preferences',scope_source:$('scope-source').value});};
$('mix-volume').oninput=e=>$('mix-value').textContent=e.target.value+'%';
$('mix-volume').onchange=()=>action({action:'mix',volume:Number($('mix-volume').value)});
$('mix-mute').onclick=()=>{if(state) action({action:'mix',muted:!state.mix_muted});};
document.addEventListener('visibilitychange',()=>{if(document.hidden){scopeController?.abort();clearScope('Anzeige pausiert');}else pollScope();});
window.addEventListener('resize',()=>drawScope());
setInterval(pollScope,100);
$('dark-mode').onchange=()=>action({action:'preferences',theme:$('dark-mode').checked?'dark':'light'});
$('wave-gain').oninput=()=>{
$('wave-gain-value').textContent='×'+$('wave-gain').value;
$('wave-scale-label').textContent='Ansicht ×'+$('wave-gain').value+' · Pegel unverändert';
};
$('wave-gain').onchange=()=>action({action:'preferences',wave_gain:Number($('wave-gain').value)});
$('reveal-code').onclick=()=>{ const show=$('network-code').type==='password';$('network-code').type=show?'text':'password';$('reveal-code').textContent=show?'Code verbergen':'Code anzeigen'; };
$('access-dialog').addEventListener('cancel',e=>e.preventDefault());
$('access-form').onsubmit=async e=>{
e.preventDefault(); accessCode=$('access-code').value.trim();
try {
const res=await fetch('/api/state',{headers:accessHeaders()});
if(!res.ok) throw new Error('Zugangscode nicht korrekt.');
const s=await res.json(); token=s.token; localStorage.setItem('voicepult-access',accessCode);
$('access-dialog').close();$('access-error').textContent='';render(s);
} catch(error){$('access-error').textContent=error.message;}
};
document.addEventListener('change',e=>{
const el=e.target;
if(el.hasAttribute('data-app-route')) {
const channels=[...el.closest('.app-card').querySelectorAll('[data-app-route]')].filter(c=>c.dataset.appRoute===el.dataset.appRoute && c.checked).map(c=>c.value);
action({action:'assign',app:el.dataset.appRoute,channels},channels.length ? 'App-Zuordnung gespeichert' : 'App-Zuordnung entfernt');
}
if(el.hasAttribute('data-mic-route')) action({action:'mic_channels',channels:[...document.querySelectorAll('[data-mic-route]:checked')].map(c=>c.value)});
});
function showWorkspace() {
const saved = location.hash === '#saved-apps';
$('patchpanel').hidden = saved;
$('saved-apps').hidden = !saved;
$('workspace-title').textContent = saved ? 'Gespeicherte Apps' : 'Patchpanel';
document.querySelectorAll('[data-workspace]').forEach(link => {
const selected = link.dataset.workspace === (saved ? 'saved-apps' : 'patchpanel');
link.classList.toggle('selected', selected);
if (selected) link.setAttribute('aria-current','page'); else link.removeAttribute('aria-current');
});
if (saved) scopeController?.abort(); else pollScope();
}
window.addEventListener('hashchange', showWorkspace);
showWorkspace();
let deletingTarget = null;
$('add-target').onclick=()=>{ $('target-name').value=''; $('target-dialog').showModal(); $('target-name').focus(); };
$('target-form').onsubmit=async e=>{
e.preventDefault(); $('target-submit').disabled=true;
if(await action({action:'target_create',name:$('target-name').value},'Virtuelles Mikrofon erstellt')) $('target-dialog').close();
$('target-submit').disabled=false;
};
$('target-delete-confirm').onclick=async()=>{
$('target-delete-confirm').disabled=true;
if(await action({action:'target_delete',id:deletingTarget},'Virtuelles Mikrofon gelöscht')) $('target-delete-dialog').close();
$('target-delete-confirm').disabled=false;
};
document.addEventListener('click',e=>{
const b=e.target.closest('button'); if(!b)return;
if(b.dataset.targetDelete){deletingTarget=b.dataset.targetDelete;$('target-delete-message').textContent='„VoicePult '+state.targets.find(t=>t.id===deletingTarget).name+'“ und seine Kanalverbindungen werden entfernt.';$('target-delete-dialog').showModal();}
if(b.dataset.targetMute){const t=state.targets.find(t=>t.id===b.dataset.targetMute);action({action:'target',id:t.id,muted:!t.muted});}
});
document.addEventListener('input',e=>{if(e.target.dataset.targetVolume)e.target.nextElementSibling.textContent=e.target.value+'%';});
document.addEventListener('change',e=>{
const el=e.target;
if(el.dataset.targetVolume)action({action:'target',id:el.dataset.targetVolume,volume:Number(el.value)});
if(el.dataset.targetRoute)action({action:'channel',id:el.dataset.targetRoute,targets:[...el.closest('.extra-routes').querySelectorAll('input:checked')].map(c=>c.value)});
});