'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 => ` `;
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 `${esc(name)} `; }
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 `${esc((a.name[0] || '?').toUpperCase())}
${esc(a.name)}
${a.active ? 'Spielt Audio' : a.present === false ? 'Ohne Audiostream' : 'Pausiert'}
${a.streams > 1 ? a.streams + ' Streams' : ''} KANÄLE · MEHRFACHAUSWAHL ${svg('routing')}
${s.channels.map(c=>` ${esc(c.name)} `).join('') || 'Erstelle zuerst einen Kanal. '}
${a.channels?.length ? a.channels.length + (a.channels.length===1?' Kanal verbunden':' Kanäle verbunden') : 'Keine Auswahl: direkter Geräteausgang'} `;
}
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=>`${esc(url)} `).join(' ');
$('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('') : `
`;
$('saved-app-list').innerHTML = s.saved_apps?.length ? s.saved_apps.map(a => appCard(a,s,'saved')).join('') : `${svg('apps')}
Noch keine gespeicherten Zuordnungen Ordne im Patchpanel einer Anwendung einen Kanal zu. Sie bleibt dann hier auch ohne Wiedergabe sichtbar.
Zum Patchpanel `;
$('microphone-routing').innerHTML = `${svg('mic')}
Dein Mikrofon
${esc(s.inputs.find(d=>d.id===s.input)?.name || 'Gerät wählen')}
AN KANÄLE SENDEN · MEHRFACHAUSWAHL
${s.channels.map(c=>` ${esc(c.name)} `).join('') || 'Erstelle zuerst einen Kanal. '}
Mehrere Wege zum gleichen Ziel werden addiert. Kopfhörer am Mikrofon-Kanal nur zum Mithören aktivieren. `;
$('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 `${count ? count + (count === 1 ? ' Anwendung verbunden' : ' Anwendungen verbunden') : 'Bereit für deine Anwendungen'} ${c.muted ? 'STUMM' : 'STEREO'}
${svg(c.muted?'mute':'volume')} ${c.volume}%
WEITERLEITEN AN ${svg('headphones')}Kopfhörer ${c.monitor?svg('check'):''} ${svg('mic')}Mikrofon-Mix ${c.microphone?svg('check'):''}
`;
}).join('') : `${svg('sliders')}
Dein erster Kanal Ein Kanal verbindet eine oder mehrere Anwendungen mit deinen Ausgängen.
`;
const listening = s.channels.filter(c => c.monitor).length, sharing = s.channels.filter(c => c.microphone).length;
$('outputs').innerHTML = `${svg('headphones')}
Kopfhörer
Dein persönlicher Monitor
AUSGABEGERÄT ${deviceOptions(s.outputs,s.output)} ${svg('link')}${listening} ${listening===1?'Kanal verbunden':'Kanäle verbunden'}
${svg('mic')}
Mikrofon-Mix
Eingabegerät für Element, OBS & Co.
${svg('wave')}VoicePult Mikrofon
${svg('link')}${sharing} ${sharing===1?'Kanal verbunden':'Kanäle verbunden'}
${svg(s.mic_available ? 'check' : 'info')}${s.mic_available ? 'Als Mikrofon verfügbar' : 'Eingabegerät nicht verfügbar'}
${svg('mic')}${s.mic_consumers?.length ? 'Verwendet von: ' + s.mic_consumers.map(esc).join(', ') : 'Noch in keiner App als Eingabe gewählt'}
Stimme direkt in den Mix DEIN MIKROFON ${deviceOptions(s.inputs,s.input)}
`;
$('extra-targets').innerHTML = (s.targets||[]).map(t=>`${svg('mic')}
${esc(t.name)}
Virtuelles Mikrofon
${svg('trash')} ${svg('wave')}VoicePult ${esc(t.name)}
In deiner App als Eingabegerät auswählen.
${svg('link')}${s.channels.filter(c=>(c.targets||[]).includes(t.id)).length} Kanäle verbunden
${svg(t.muted?'mute':'volume')} ${t.volume}%
`).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)});
});