275 lines
25 KiB
JavaScript
275 lines
25 KiB
JavaScript
'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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)});
|
||
});
|