'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 ``; } 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=>``).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('') : `
${svg('apps')}Noch keine Audioquellen

Spiele Audio in Firefox oder einer anderen Anwendung ab.

Gespeicherte Apps öffnen
`; $('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=>``).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 `
${svg('sliders')}${esc(c.name)}CH ${String(i+1).padStart(2,'0')}
${count ? count + (count === 1 ? ' Anwendung verbunden' : ' Anwendungen verbunden') : 'Bereit für deine Anwendungen'}${c.muted ? 'STUMM' : 'STEREO'}
${c.volume}%
WEITERLEITEN AN
${(s.targets||[]).map(t=>``).join('')}
`; }).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
${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'}
`; $('extra-targets').innerHTML = (s.targets||[]).map(t=>`
${svg('mic')}
${esc(t.name)}
Virtuelles Mikrofon
${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
${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)}); });