/** * Audio Sources — CRUD for multichannel and mono audio sources. * * Audio sources are managed entities that encapsulate audio device * configuration. Multichannel sources represent physical audio devices; * mono sources extract a single channel from a multichannel source. * CSS audio type references a mono source by ID. * * Card rendering is handled by streams.js (Audio tab). * This module manages the editor modal and API operations. */ import { _cachedAudioSources, set_cachedAudioSources } from '../core/state.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { loadPictureSources } from './streams.js'; class AudioSourceModal extends Modal { constructor() { super('audio-source-modal'); } snapshotValues() { return { name: document.getElementById('audio-source-name').value, description: document.getElementById('audio-source-description').value, type: document.getElementById('audio-source-type').value, device: document.getElementById('audio-source-device').value, parent: document.getElementById('audio-source-parent').value, channel: document.getElementById('audio-source-channel').value, }; } } const audioSourceModal = new AudioSourceModal(); // ── Modal ───────────────────────────────────────────────────── export async function showAudioSourceModal(sourceType, editData) { const isEdit = !!editData; const titleKey = isEdit ? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') : (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel'); document.getElementById('audio-source-modal-title').textContent = t(titleKey); document.getElementById('audio-source-id').value = isEdit ? editData.id : ''; document.getElementById('audio-source-error').style.display = 'none'; const typeSelect = document.getElementById('audio-source-type'); typeSelect.value = isEdit ? editData.source_type : sourceType; typeSelect.disabled = isEdit; // can't change type after creation onAudioSourceTypeChange(); if (isEdit) { document.getElementById('audio-source-name').value = editData.name || ''; document.getElementById('audio-source-description').value = editData.description || ''; if (editData.source_type === 'multichannel') { await _loadAudioDevices(); _selectAudioDevice(editData.device_index, editData.is_loopback); } else { _loadMultichannelSources(editData.audio_source_id); document.getElementById('audio-source-channel').value = editData.channel || 'mono'; } } else { document.getElementById('audio-source-name').value = ''; document.getElementById('audio-source-description').value = ''; if (sourceType === 'multichannel') { await _loadAudioDevices(); } else { _loadMultichannelSources(); } } audioSourceModal.open(); audioSourceModal.snapshot(); } export async function closeAudioSourceModal() { await audioSourceModal.close(); } export function onAudioSourceTypeChange() { const type = document.getElementById('audio-source-type').value; document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none'; document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none'; } // ── Save ────────────────────────────────────────────────────── export async function saveAudioSource() { const id = document.getElementById('audio-source-id').value; const name = document.getElementById('audio-source-name').value.trim(); const sourceType = document.getElementById('audio-source-type').value; const description = document.getElementById('audio-source-description').value.trim() || null; const errorEl = document.getElementById('audio-source-error'); if (!name) { errorEl.textContent = t('audio_source.error.name_required'); errorEl.style.display = ''; return; } const payload = { name, source_type: sourceType, description }; if (sourceType === 'multichannel') { const deviceVal = document.getElementById('audio-source-device').value || '-1:1'; const [devIdx, devLoop] = deviceVal.split(':'); payload.device_index = parseInt(devIdx) || -1; payload.is_loopback = devLoop !== '0'; } else { payload.audio_source_id = document.getElementById('audio-source-parent').value; payload.channel = document.getElementById('audio-source-channel').value; } try { const method = id ? 'PUT' : 'POST'; const url = id ? `/audio-sources/${id}` : '/audio-sources'; const resp = await fetchWithAuth(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); audioSourceModal.forceClose(); await loadPictureSources(); } catch (e) { errorEl.textContent = e.message; errorEl.style.display = ''; } } // ── Edit ────────────────────────────────────────────────────── export async function editAudioSource(sourceId) { try { const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); await showAudioSourceModal(data.source_type, data); } catch (e) { showToast(e.message, 'error'); } } // ── Clone ───────────────────────────────────────────────────── export async function cloneAudioSource(sourceId) { try { const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); delete data.id; data.name = data.name + ' (copy)'; await showAudioSourceModal(data.source_type, data); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } // ── Delete ──────────────────────────────────────────────────── export async function deleteAudioSource(sourceId) { const confirmed = await showConfirm(t('audio_source.delete.confirm')); if (!confirmed) return; try { const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t('audio_source.deleted'), 'success'); await loadPictureSources(); } catch (e) { showToast(e.message, 'error'); } } // ── Helpers ─────────────────────────────────────────────────── async function _loadAudioDevices() { const select = document.getElementById('audio-source-device'); if (!select) return; try { const resp = await fetchWithAuth('/audio-devices'); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); const devices = data.devices || []; select.innerHTML = devices.map(d => { const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; return ``; }).join(''); if (devices.length === 0) { select.innerHTML = ''; } } catch { select.innerHTML = ''; } } function _selectAudioDevice(deviceIndex, isLoopback) { const select = document.getElementById('audio-source-device'); if (!select) return; const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`; const opt = Array.from(select.options).find(o => o.value === val); if (opt) select.value = val; } function _loadMultichannelSources(selectedId) { const select = document.getElementById('audio-source-parent'); if (!select) return; const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); select.innerHTML = multichannel.map(s => `` ).join(''); }