/** * PTT Live Desktop - Renderer Process Logic */ const API_BASE = window.electronAPI?.serverUrl || 'http://localhost:3000'; // État global let serverRunning = false; let statsInterval = null; let logsBuffer = []; let audioLevelsWS = null; let routingData = null; let audioLevelsData = { inputs: {}, groups: {}, outputs: {}, routing: { activeInputs: [], activeGroups: [], activeOutputs: [] } }; // ========== Initialisation ========== document.addEventListener('DOMContentLoaded', async () => { console.log('🚀 Interface Electron chargĂ©e'); // Setup navigation setupNavigation(); // Setup contrĂŽles serveur setupServerControls(); // Setup logs listener setupLogsListener(); // VĂ©rifier le statut initial du serveur await checkServerStatus(); // Charger les donnĂ©es initiales SEULEMENT si serveur actif if (serverRunning) { loadInitialData(); } else { console.log('⏞ Serveur arrĂȘtĂ©, en attente de dĂ©marrage...'); } }); // ========== Navigation ========== function setupNavigation() { const navItems = document.querySelectorAll('.nav-item'); const views = document.querySelectorAll('.view'); navItems.forEach(item => { item.addEventListener('click', () => { const targetView = item.dataset.view; // Mettre Ă  jour l'Ă©tat actif navItems.forEach(nav => nav.classList.remove('active')); item.classList.add('active'); views.forEach(view => view.classList.remove('active')); document.getElementById(`view-${targetView}`).classList.add('active'); // Charger les donnĂ©es de la vue loadViewData(targetView); }); }); } // ========== ContrĂŽles Serveur ========== function setupServerControls() { const btnStart = document.getElementById('btn-start'); const btnStop = document.getElementById('btn-stop'); btnStart.addEventListener('click', async () => { btnStart.disabled = true; btnStart.textContent = 'DĂ©marrage...'; try { const result = await window.electronAPI.server.start(); console.log('RĂ©sultat dĂ©marrage:', result); if (result.success) { showNotification('Serveur dĂ©marrĂ© avec succĂšs', 'success'); } else { showNotification('Erreur dĂ©marrage: ' + result.message, 'error'); } } catch (error) { console.error('Erreur dĂ©marrage serveur:', error); showNotification('Erreur dĂ©marrage serveur', 'error'); } btnStart.disabled = false; btnStart.textContent = 'DĂ©marrer'; await checkServerStatus(); }); btnStop.addEventListener('click', async () => { btnStop.disabled = true; btnStop.textContent = 'ArrĂȘt...'; try { const result = await window.electronAPI.server.stop(); console.log('RĂ©sultat arrĂȘt:', result); if (result.success) { showNotification('Serveur arrĂȘtĂ©', 'info'); } } catch (error) { console.error('Erreur arrĂȘt serveur:', error); showNotification('Erreur arrĂȘt serveur', 'error'); } btnStop.disabled = false; btnStop.textContent = 'ArrĂȘter'; await checkServerStatus(); }); // Listener status depuis Main Process window.electronAPI.server.onStatus((data) => { console.log('Status update:', data); updateServerStatus(data.running); }); } function setupLogsListener() { window.electronAPI.server.onLog((logData) => { addLogEntry(logData); }); // Bouton clear logs document.getElementById('btn-clear-logs').addEventListener('click', () => { logsBuffer = []; renderLogs(); }); // Filtre niveau de log document.getElementById('log-level-filter').addEventListener('change', (e) => { renderLogs(e.target.value); }); } async function checkServerStatus() { try { const status = await window.electronAPI.server.status(); console.log('Status:', status); updateServerStatus(status.running); if (status.running) { startStatsPolling(); } else { stopStatsPolling(); } } catch (error) { console.error('Erreur check status:', error); updateServerStatus(false); } } function updateServerStatus(running) { serverRunning = running; const indicator = document.getElementById('status-indicator'); const statusText = document.getElementById('status-text'); const btnStart = document.getElementById('btn-start'); const btnStop = document.getElementById('btn-stop'); if (running) { indicator.textContent = '🟱'; statusText.textContent = 'Actif'; btnStart.disabled = true; btnStop.disabled = false; // DĂ©marrer le polling startStatsPolling(); // Connecter WebSocket audio levels connectAudioLevelsWS(); // Charger les donnĂ©es initiales loadInitialData(); } else { indicator.textContent = 'âšȘ'; statusText.textContent = 'ArrĂȘtĂ©'; btnStart.disabled = false; btnStop.disabled = true; // ArrĂȘter le polling stopStatsPolling(); // DĂ©connecter WebSocket audio levels disconnectAudioLevelsWS(); // QR code obsolĂšte tant que le serveur est arrĂȘtĂ© : revenir au placeholder document.getElementById('qr-code').removeAttribute('src'); document.getElementById('client-url').textContent = '--'; } } // ========== Polling Stats ========== function startStatsPolling() { if (statsInterval) return; // Poll toutes les 2 secondes statsInterval = setInterval(async () => { if (serverRunning) { await fetchStats(); await fetchUsers(); } }, 2000); // Premier fetch immĂ©diat fetchStats(); fetchUsers(); } function stopStatsPolling() { if (statsInterval) { clearInterval(statsInterval); statsInterval = null; } } // ========== API Calls ========== async function apiCall(endpoint) { try { const response = await fetch(`${API_BASE}${endpoint}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error(`API Error (${endpoint}):`, error); return null; } } async function fetchStats() { const data = await apiCall('/admin/stats'); if (!data) return; // Mettre Ă  jour les stats cards document.getElementById('stat-uptime').textContent = formatUptime(data.uptime); document.getElementById('stat-users').textContent = data.activeConnections || 0; document.getElementById('stat-total-connections').textContent = data.totalConnections || 0; // Groupes actifs (nĂ©cessite /admin/groups) const groups = await apiCall('/admin/groups'); if (groups) { document.getElementById('stat-groups').textContent = groups.groups?.length || 0; } } async function fetchUsers() { const data = await apiCall('/admin/users'); if (!data) return; const container = document.getElementById('users-list'); if (!data.users || data.users.length === 0) { container.innerHTML = '

Aucun utilisateur connecté

'; return; } container.innerHTML = data.users.map(user => `
đŸ‘€

${user.username}

Groupe: ${user.groupId} ‱ ConnectĂ©: ${formatTime(user.connectedAt)}

${user.groupId}
`).join(''); } async function fetchDevices() { const data = await apiCall('/admin/devices/list'); if (!data) return; const inputSelect = document.getElementById('input-device'); const outputSelect = document.getElementById('output-device'); // Remplir les selects inputSelect.innerHTML = data.inputs.map(device => `` ).join(''); outputSelect.innerHTML = data.outputs.map(device => `` ).join(''); } async function fetchGroups() { const container = document.getElementById('groups-list'); // Lecture directe depuis config.yaml via IPC (fonctionne sans serveur) const data = await window.electronAPI.groups.list(); if (!data.groups || data.groups.length === 0) { container.innerHTML = '

Aucun groupe configuré

'; return; } const serverNote = serverRunning ? '' : '

Serveur arrĂȘtĂ© — les modifications seront appliquĂ©es au prochain dĂ©marrage.

'; container.innerHTML = serverNote + data.groups.map(group => { const id = slugify(group.name); return `

${escapeHtml(group.name)}

Bitrate: ${group.audioBitrate || 96} kbps ‱ ID: ${escapeHtml(id)}

`; }).join(''); } async function editGroup(id, currentName, currentBitrate) { const result = await showModal({ title: 'Modifier le groupe', fields: [ { name: 'name', label: 'Nom', default: currentName }, { name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: currentBitrate, min: 32, max: 320, step: 1 } ], confirmLabel: 'Modifier' }); if (!result) return; const newName = result.name.trim(); const newBitrate = parseInt(result.bitrate); if (!newName) { showNotification('Nom requis', 'error'); return; } if (isNaN(newBitrate) || newBitrate < 32 || newBitrate > 320) { showNotification('Bitrate invalide (32-320 kbps)', 'error'); return; } try { let ok, errorMsg; if (serverRunning) { const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, audioBitrate: newBitrate }) }); ok = response.ok; if (!ok) errorMsg = (await response.json().catch(() => ({}))).error; } else { const res = await window.electronAPI.groups.update({ id, name: newName, audioBitrate: newBitrate }); ok = res.success; errorMsg = res.error; } if (ok) { showNotification('Groupe modifiĂ©', 'success'); await fetchGroups(); } else { showNotification('Erreur: ' + (errorMsg || 'Modification Ă©chouĂ©e'), 'error'); } } catch (error) { console.error('Erreur edit group:', error); showNotification('Erreur rĂ©seau', 'error'); } } async function deleteGroup(id, name) { const confirmed = await showModal({ title: 'Supprimer le groupe', message: `Supprimer le groupe "${name}" ? Cette action est irrĂ©versible.`, confirmLabel: 'Supprimer', confirmClass: 'btn-danger' }); if (!confirmed) return; try { let ok, errorMsg; if (serverRunning) { const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'DELETE' }); ok = response.ok; if (!ok) errorMsg = (await response.json().catch(() => ({}))).error; } else { const res = await window.electronAPI.groups.delete({ id }); ok = res.success; errorMsg = res.error; } if (ok) { showNotification('Groupe supprimĂ©', 'success'); await fetchGroups(); } else { showNotification('Erreur: ' + (errorMsg || 'Suppression Ă©chouĂ©e'), 'error'); } } catch (error) { console.error('Erreur delete group:', error); showNotification('Erreur rĂ©seau', 'error'); } } async function fetchConfig() { const data = await apiCall('/admin/config'); if (!data) return; // Remplir les champs de config audio if (data.audio) { const sampleRateSelect = document.getElementById('sample-rate'); if (sampleRateSelect) { sampleRateSelect.value = data.audio.sampleRate || 48000; } const bitrateInput = document.getElementById('default-bitrate'); if (bitrateInput) { bitrateInput.value = data.audio.defaultBitrate || 96; } const jitterInput = document.getElementById('jitter-buffer'); if (jitterInput) { jitterInput.value = data.audio.jitterBufferMs || 40; } } } // ========== QR Code ========== async function generateQRCode() { // RĂ©cupĂ©rer l'IP rĂ©seau depuis le serveur const status = await window.electronAPI.server.status(); if (!status || !status.running) { document.getElementById('client-url').textContent = 'Serveur non dĂ©marrĂ©'; return; } // RĂ©cupĂ©rer l'URL depuis l'API try { const response = await fetch(`${API_BASE}/health`); const data = await response.json(); // DĂ©tecter l'IP rĂ©seau (depuis hostname ou config) const networkIP = await getNetworkIP(); // En prod (Electron), le client buildĂ© est servi par le serveur Express // lui-mĂȘme (mĂȘme port que l'API), pas par Vite (port 5173, dev only) // API_BASE pointe sur 127.0.0.1 (loopback, pour le ping interne) : // on ne rĂ©utilise que protocole + port, l'IP doit ĂȘtre celle du rĂ©seau local const serverOrigin = new URL(API_BASE); const clientUrl = `${serverOrigin.protocol}//${networkIP}:${serverOrigin.port}`; document.getElementById('client-url').textContent = clientUrl; // GĂ©nĂ©rer QR Code (rendu cĂŽtĂ© Main Process, pas de dĂ©pendance rĂ©seau/CDN) const img = document.getElementById('qr-code'); if (img) { const result = await window.electronAPI.generateQRCode(clientUrl); if (result.success) { img.src = result.dataUrl; console.log('✅ QR Code gĂ©nĂ©rĂ©'); } else { console.error('Erreur gĂ©nĂ©ration QR Code:', result.error); } } } catch (error) { console.error('Erreur rĂ©cupĂ©ration URL:', error); document.getElementById('client-url').textContent = API_BASE; } // Bouton copier URL (setup une seule fois) const btnCopy = document.getElementById('btn-copy-url'); if (btnCopy && !btnCopy.dataset.initialized) { btnCopy.dataset.initialized = 'true'; btnCopy.addEventListener('click', () => { const url = document.getElementById('client-url').textContent; navigator.clipboard.writeText(url); showNotification('URL copiĂ©e !', 'success'); }); } } async function getNetworkIP() { // DĂ©tection via le Main Process (mĂȘme logique que pour les certs mkcert) : // /admin/config renvoie la valeur YAML brute ("AUTO"), jamais l'IP rĂ©solue, // donc inutilisable ici. try { const ip = await window.electronAPI.getNetworkIP(); if (ip) return ip; } catch (error) { console.error('Erreur dĂ©tection IP:', error); } // Fallback : localhost return 'localhost'; } // ========== Logs ========== function addLogEntry(logData) { const entry = { timestamp: new Date().toISOString(), level: logData.level || 'info', message: logData.message }; logsBuffer.unshift(entry); // Garder max 500 logs if (logsBuffer.length > 500) { logsBuffer = logsBuffer.slice(0, 500); } renderLogs(); } function renderLogs(levelFilter = '') { const container = document.getElementById('logs-container'); let logs = logsBuffer; // Filtrer par niveau si nĂ©cessaire if (levelFilter) { logs = logs.filter(log => log.level === levelFilter); } if (logs.length === 0) { container.innerHTML = '

Aucun log

'; return; } container.innerHTML = logs.map(log => `
${formatLogTime(log.timestamp)} ${log.level} ${escapeHtml(log.message)}
`).join(''); // Scroll vers le bas (dernier log) container.scrollTop = 0; } // ========== Chargement donnĂ©es ========== async function loadInitialData() { if (!serverRunning) return; await fetchStats(); await fetchUsers(); await generateQRCode(); } async function loadViewData(view) { // Ces vues lisent config.yaml directement (fonctionne sans serveur) if (view === 'groups') { await fetchGroups(); return; } if (view === 'routing') { await fetchRouting(); return; } if (view === 'config') { await fetchServerAudioUsers(); if (serverRunning) { await fetchDevices(); await fetchConfig(); } return; } if (!serverRunning) return; switch (view) { case 'dashboard': await fetchStats(); await fetchUsers(); await generateQRCode(); break; case 'monitoring': renderVUMeters(); break; case 'logs': renderLogs(); break; } } // ========== Boutons de sauvegarde ========== document.addEventListener('DOMContentLoaded', () => { // Sauvegarder device audio const btnSaveDevice = document.getElementById('btn-save-device'); if (btnSaveDevice) { btnSaveDevice.addEventListener('click', async () => { const inputDeviceId = document.getElementById('input-device').value; const outputDeviceId = document.getElementById('output-device').value; try { const response = await fetch(`${API_BASE}/admin/audio/device`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inputDeviceId, outputDeviceId }) }); if (response.ok) { showNotification('PĂ©riphĂ©rique audio configurĂ©', 'success'); } else { showNotification('Erreur configuration', 'error'); } } catch (error) { console.error('Erreur save device:', error); showNotification('Erreur rĂ©seau', 'error'); } }); } // Sauvegarder config audio const btnSaveAudio = document.getElementById('btn-save-audio'); if (btnSaveAudio) { btnSaveAudio.addEventListener('click', async () => { const sampleRate = parseInt(document.getElementById('sample-rate').value); const defaultBitrate = parseInt(document.getElementById('default-bitrate').value); const jitterBufferMs = parseInt(document.getElementById('jitter-buffer').value); try { const response = await fetch(`${API_BASE}/admin/config/audio`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sampleRate, defaultBitrate, jitterBufferMs }) }); if (response.ok) { showNotification('Configuration sauvegardĂ©e', 'success'); } else { showNotification('Erreur sauvegarde', 'error'); } } catch (error) { console.error('Erreur save config:', error); showNotification('Erreur rĂ©seau', 'error'); } }); } // Ajouter groupe const btnAddGroup = document.getElementById('btn-add-group'); if (btnAddGroup) { btnAddGroup.addEventListener('click', async () => { const result = await showModal({ title: 'Nouveau groupe', fields: [ { name: 'name', label: 'Nom du groupe' }, { name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: 96, min: 32, max: 320, step: 1 } ], confirmLabel: 'CrĂ©er' }); if (!result || !result.name.trim()) return; const name = result.name.trim(); const audioBitrate = parseInt(result.bitrate) || 96; try { let ok, errorMsg; if (serverRunning) { const response = await fetch(`${API_BASE}/admin/groups`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, audioBitrate }) }); ok = response.ok; if (!ok) errorMsg = (await response.json().catch(() => ({}))).error; } else { const res = await window.electronAPI.groups.create({ name, audioBitrate }); ok = res.success; errorMsg = res.error; } if (ok) { showNotification('Groupe créé', 'success'); await fetchGroups(); } else { showNotification('Erreur: ' + (errorMsg || 'CrĂ©ation Ă©chouĂ©e'), 'error'); } } catch (error) { console.error('Erreur add group:', error); showNotification('Erreur rĂ©seau', 'error'); } }); } // Exporter config.yaml const btnExportConfig = document.getElementById('btn-export-config'); if (btnExportConfig) { btnExportConfig.addEventListener('click', async () => { const result = await window.electronAPI.config.export(); if (result.success) { showNotification('Configuration exportĂ©e', 'success'); } else if (!result.cancelled) { showNotification('Erreur export: ' + (result.error || 'Échec'), 'error'); } }); } // Importer config.yaml const btnImportConfig = document.getElementById('btn-import-config'); if (btnImportConfig) { btnImportConfig.addEventListener('click', async () => { const result = await window.electronAPI.config.import(); if (result.success) { showNotification('Configuration importĂ©e - RedĂ©marrez le serveur pour appliquer', 'warning'); if (serverRunning) { await fetchConfig(); } } else if (!result.cancelled) { showNotification('Erreur import: ' + (result.error || 'Échec'), 'error'); } }); } // Exporter les logs const btnExportLogs = document.getElementById('btn-export-logs'); if (btnExportLogs) { btnExportLogs.addEventListener('click', () => { const levelFilter = document.getElementById('log-level-filter').value; const logs = levelFilter ? logsBuffer.filter(l => l.level === levelFilter) : logsBuffer; if (logs.length === 0) { showNotification('Aucun log Ă  exporter', 'info'); return; } const content = JSON.stringify(logs, null, 2); const blob = new Blob([content], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ptt-live-logs-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(`${logs.length} logs exportĂ©s`, 'success'); }); } // DĂ©lĂ©gation d'Ă©vĂ©nements pour modifier/supprimer un groupe const groupsList = document.getElementById('groups-list'); if (groupsList) { groupsList.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const id = btn.dataset.id; const name = btn.dataset.name; if (action === 'edit') { await editGroup(id, name, parseInt(btn.dataset.bitrate)); } else if (action === 'delete') { await deleteGroup(id, name); } }); } // Bouton ajouter utilisateur audio serveur const btnAddSAU = document.getElementById('btn-add-server-audio-user'); if (btnAddSAU) { btnAddSAU.addEventListener('click', addServerAudioUser); } // DĂ©lĂ©gation modifier/supprimer utilisateur audio serveur const sauList = document.getElementById('server-audio-users-list'); if (sauList) { sauList.addEventListener('click', async (e) => { const btn = e.target.closest('[data-sau-action]'); if (!btn) return; const action = btn.dataset.sauAction; const name = btn.dataset.sauName; if (action === 'edit') { await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput)); } else if (action === 'delete') { await deleteServerAudioUser(name); } }); } // Boutons routing document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting); document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting); document.getElementById('btn-add-input-channel')?.addEventListener('click', () => addChannelRow('input')); document.getElementById('btn-add-output-channel')?.addEventListener('click', () => addChannelRow('output')); // DĂ©lĂ©gation suppression lignes canal (boutons ✕) document.addEventListener('click', (e) => { const btn = e.target.closest('.channel-name-delete'); if (!btn) return; deleteChannelRow(btn.dataset.dir, btn.dataset.channel); }); }); // ========== Server Audio Users ========== async function fetchServerAudioUsers() { const container = document.getElementById('server-audio-users-list'); if (!container) return; const data = await window.electronAPI.serverAudioUsers.list(); const serverNote = serverRunning ? '' : '

Serveur arrĂȘtĂ© — les modifications seront appliquĂ©es au prochain dĂ©marrage.

'; if (!data.users || data.users.length === 0) { container.innerHTML = serverNote + '

Aucun utilisateur audio serveur configuré

'; return; } container.innerHTML = serverNote + data.users.map(user => `

${escapeHtml(user.name)}

Groupe: ${escapeHtml(user.group)} · Entrée: canal ${user.input_channel} · Sortie: canal ${user.output_channel}

`).join(''); } async function addServerAudioUser() { const groupsData = await window.electronAPI.groups.list(); const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name })); const defaultGroup = groupOptions[0]?.value || 'default'; const result = await showModal({ title: 'Nouvel utilisateur audio serveur', fields: [ { name: 'name', label: 'Nom (identifiant unique, ex: foh)' }, { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup }, { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 }, { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: 0, min: 0, max: 63, step: 1 } ], confirmLabel: 'Ajouter' }); if (!result || !result.name.trim()) return; const res = await window.electronAPI.serverAudioUsers.create({ name: result.name.trim(), group: result.group, input_channel: parseInt(result.input_channel), output_channel: parseInt(result.output_channel) }); if (res.success) { showNotification('Utilisateur audio serveur ajouté', 'success'); await fetchServerAudioUsers(); } else { showNotification('Erreur: ' + (res.error || 'Création échouée'), 'error'); } } async function editServerAudioUser(name, group, input_channel, output_channel) { const groupsData = await window.electronAPI.groups.list(); const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name })); const result = await showModal({ title: `Modifier "${name}"`, fields: [ { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group }, { name: 'input_channel', label: 'Canal entrée (index physique)', type: 'number', default: input_channel, min: 0, max: 63, step: 1 }, { name: 'output_channel', label: 'Canal sortie (index physique)', type: 'number', default: output_channel, min: 0, max: 63, step: 1 } ], confirmLabel: 'Modifier' }); if (!result) return; const res = await window.electronAPI.serverAudioUsers.update({ name, group: result.group, input_channel: parseInt(result.input_channel), output_channel: parseInt(result.output_channel) }); if (res.success) { showNotification('Utilisateur audio serveur modifié', 'success'); await fetchServerAudioUsers(); } else { showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error'); } } async function deleteServerAudioUser(name) { const confirmed = await showModal({ title: 'Supprimer l\'utilisateur audio serveur', message: `Supprimer "${name}" ? Cette action est irréversible.`, confirmLabel: 'Supprimer', confirmClass: 'btn-danger' }); if (!confirmed) return; const res = await window.electronAPI.serverAudioUsers.delete({ name }); if (res.success) { showNotification('Utilisateur audio serveur supprimé', 'success'); await fetchServerAudioUsers(); } else { showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error'); } } // ========== Routing ========== async function fetchRouting() { const data = await window.electronAPI.routing.get(); if (data.error) { showNotification('Erreur chargement routing: ' + data.error, 'error'); return; } routingData = data; renderRoutingView(); } function renderRoutingView() { if (!routingData) return; renderChannelNamesEditor(); renderRoutingMatrices(); } function renderChannelNamesEditor() { const { channelNames } = routingData; const inputs = channelNames?.inputs || {}; const outputs = channelNames?.outputs || {}; const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); const inputsContainer = document.getElementById('channel-names-inputs'); const outputsContainer = document.getElementById('channel-names-outputs'); if (inputsContainer) { inputsContainer.innerHTML = inputChannels.length > 0 ? inputChannels.map(ch => channelNameRow('input', ch, inputs[ch] || '')).join('') : '

Aucun canal d\'entrée défini.

'; } if (outputsContainer) { outputsContainer.innerHTML = outputChannels.length > 0 ? outputChannels.map(ch => channelNameRow('output', ch, outputs[ch] || '')).join('') : '

Aucun canal de sortie défini.

'; } } function channelNameRow(dir, ch, name) { return `
Canal ${ch}
`; } function addChannelRow(dir) { const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; const container = document.getElementById(containerId); if (!container) return; // Retirer le message "Aucun canal" si présent const emptyMsg = container.querySelector('.config-note'); if (emptyMsg) emptyMsg.remove(); const existing = Array.from(container.querySelectorAll('.channel-name-row')) .map(r => parseInt(r.dataset.channel)) .filter(n => !isNaN(n)); const nextCh = existing.length > 0 ? Math.max(...existing) + 1 : 0; const row = document.createElement('div'); row.innerHTML = channelNameRow(dir, nextCh, ''); const newRow = row.firstElementChild; container.appendChild(newRow); newRow.querySelector('input')?.focus(); } function deleteChannelRow(dir, channel) { const containerId = dir === 'input' ? 'channel-names-inputs' : 'channel-names-outputs'; const container = document.getElementById(containerId); const row = container?.querySelector(`.channel-name-row[data-channel="${channel}"][data-dir="${dir}"]`); if (!row) return; row.remove(); if (!container.querySelector('.channel-name-row')) { container.innerHTML = `

Aucun canal de ${dir === 'input' ? 'entrée' : 'sortie'} défini.

`; } } function renderRoutingMatrices() { const { routing, channelNames, groups } = routingData; const inputs = channelNames?.inputs || {}; const outputs = channelNames?.outputs || {}; const inputToGroup = routing?.inputToGroup || {}; const groupToOutput = routing?.groupToOutput || {}; const inputChannels = Object.keys(inputs).sort((a, b) => parseInt(a) - parseInt(b)); const outputChannels = Object.keys(outputs).sort((a, b) => parseInt(a) - parseInt(b)); // Matrice EntrĂ©es → Groupes const inputMatrixEl = document.getElementById('routing-input-matrix'); if (inputMatrixEl) { if (inputChannels.length === 0 || groups.length === 0) { inputMatrixEl.innerHTML = '

Définissez des canaux d\'entrée et des groupes pour configurer le routing.

'; } else { let html = '
'; html += ''; groups.forEach(g => { html += ``; }); html += ''; inputChannels.forEach(ch => { const chGroups = inputToGroup[ch] || []; html += ''; html += ``; groups.forEach(g => { const gId = slugify(g.name); const checked = chGroups.includes(gId) ? 'checked' : ''; html += ``; }); html += ''; }); html += '
Canal Entrée${escapeHtml(g.name)}
${ch}${escapeHtml(inputs[ch] || `Canal ${ch}`)}
'; inputMatrixEl.innerHTML = html; } } // Matrice Groupes → Sorties const outputMatrixEl = document.getElementById('routing-output-matrix'); if (outputMatrixEl) { if (groups.length === 0 || outputChannels.length === 0) { outputMatrixEl.innerHTML = '

Définissez des canaux de sortie et des groupes pour configurer le routing.

'; } else { let html = '
'; html += ''; outputChannels.forEach(ch => { html += ``; }); html += ''; groups.forEach(g => { const gId = slugify(g.name); const gOutputs = groupToOutput[gId] || []; html += ''; html += ``; outputChannels.forEach(ch => { const checked = gOutputs.includes(ch) ? 'checked' : ''; html += ``; }); html += ''; }); html += '
Groupe${ch}
${escapeHtml(outputs[ch] || `Canal ${ch}`)}
${escapeHtml(g.name)}
'; outputMatrixEl.innerHTML = html; } } } async function saveRouting() { if (!routingData) return; // Collecter les noms de canaux depuis le DOM const newChannelNames = { inputs: {}, outputs: {} }; document.querySelectorAll('.channel-name-input[data-dir="input"]').forEach(el => { newChannelNames.inputs[el.dataset.channel] = el.value; }); document.querySelectorAll('.channel-name-input[data-dir="output"]').forEach(el => { newChannelNames.outputs[el.dataset.channel] = el.value; }); // Collecter le routing depuis les checkboxes const newInputToGroup = {}; const newGroupToOutput = {}; document.querySelectorAll('.routing-check[data-direction="input"]:checked').forEach(cb => { const ch = cb.dataset.channel; if (!newInputToGroup[ch]) newInputToGroup[ch] = []; if (!newInputToGroup[ch].includes(cb.dataset.group)) newInputToGroup[ch].push(cb.dataset.group); }); document.querySelectorAll('.routing-check[data-direction="output"]:checked').forEach(cb => { const g = cb.dataset.group; if (!newGroupToOutput[g]) newGroupToOutput[g] = []; if (!newGroupToOutput[g].includes(cb.dataset.channel)) newGroupToOutput[g].push(cb.dataset.channel); }); const newRouting = { ...(routingData.routing || {}), inputToGroup: newInputToGroup, groupToOutput: newGroupToOutput }; const result = await window.electronAPI.routing.save({ routing: newRouting, channelNames: newChannelNames }); if (result.success) { routingData.routing = newRouting; routingData.channelNames = newChannelNames; renderRoutingView(); showNotification('Routing sauvegardĂ©', 'success'); const note = document.getElementById('routing-server-note'); if (note) note.classList.remove('hidden'); } else { showNotification('Erreur: ' + (result.error || 'Sauvegarde Ă©chouĂ©e'), 'error'); } } // ========== Helpers ========== /** * Modal gĂ©nĂ©rique (remplace prompt/confirm, non supportĂ©s dans Electron). * - fields[] → formulaire ; message → confirmation simple * Retourne : objet {champ: valeur} | true (confirm) | null (annulĂ©) */ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClass = 'btn-primary', message = null }) { return new Promise((resolve) => { const overlay = document.getElementById('modal-overlay'); const titleEl = document.getElementById('modal-title'); const bodyEl = document.getElementById('modal-body'); const cancelBtn = document.getElementById('modal-cancel'); const confirmBtn = document.getElementById('modal-confirm'); titleEl.textContent = title; confirmBtn.textContent = confirmLabel; confirmBtn.className = `btn ${confirmClass}`; if (message) { bodyEl.innerHTML = ``; } else { bodyEl.innerHTML = fields.map(field => { if (field.type === 'select') { const optionsHtml = (field.options || []).map(opt => `` ).join(''); return `
`; } return `
`; }).join(''); } overlay.classList.remove('hidden'); const firstInput = bodyEl.querySelector('input'); if (firstInput) { firstInput.focus(); firstInput.select(); } function cleanup() { overlay.classList.add('hidden'); cancelBtn.removeEventListener('click', onCancel); confirmBtn.removeEventListener('click', onConfirm); overlay.removeEventListener('click', onOverlayClick); document.removeEventListener('keydown', onKeydown); } function onCancel() { cleanup(); resolve(null); } function onConfirm() { if (message) { cleanup(); resolve(true); } else { const result = {}; fields.forEach(f => { const input = document.getElementById(`modal-field-${f.name}`); result[f.name] = input ? input.value : ''; }); cleanup(); resolve(result); } } function onOverlayClick(e) { if (e.target === overlay) onCancel(); } function onKeydown(e) { if (e.key === 'Escape') onCancel(); if (e.key === 'Enter' && document.activeElement?.tagName !== 'BUTTON') onConfirm(); } cancelBtn.addEventListener('click', onCancel); confirmBtn.addEventListener('click', onConfirm); overlay.addEventListener('click', onOverlayClick); document.addEventListener('keydown', onKeydown); }); } function slugify(text) { return text.toString().normalize('NFD').replace(/[̀-ÍŻ]/g, '') .toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-'); } function formatUptime(seconds) { if (!seconds) return '--'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h}h ${m}m ${s}s`; } // ========== WebSocket Audio Levels ========== function connectAudioLevelsWS() { if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) { console.log('WebSocket audio-levels dĂ©jĂ  connectĂ©'); return; } const wsUrl = API_BASE.replace(/^http/, 'ws') + '/audio-levels'; console.log('Connexion WebSocket audio-levels...', wsUrl); try { audioLevelsWS = new WebSocket(wsUrl); audioLevelsWS.onopen = () => { console.log('WebSocket audio-levels connectĂ©'); updateVUMetersStatus('ConnectĂ©'); }; audioLevelsWS.onmessage = (event) => { try { const message = JSON.parse(event.data); switch (message.type) { case 'initial': case 'levels': audioLevelsData = message.data; renderVUMeters(); break; case 'pong': break; default: console.warn('Message WebSocket inconnu:', message.type); } } catch (error) { console.error('Erreur parsing message WebSocket:', error); } }; audioLevelsWS.onerror = (error) => { console.error('Erreur WebSocket audio-levels:', error); updateVUMetersStatus('Erreur de connexion'); }; audioLevelsWS.onclose = () => { console.log('WebSocket audio-levels dĂ©connectĂ©'); audioLevelsWS = null; updateVUMetersStatus('DĂ©connectĂ©'); // Reconnexion automatique si serveur actif if (serverRunning) { setTimeout(() => { connectAudioLevelsWS(); }, 3000); } }; // Ping pĂ©riodique const pingInterval = setInterval(() => { if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) { audioLevelsWS.send(JSON.stringify({ type: 'ping' })); } else { clearInterval(pingInterval); } }, 10000); } catch (error) { console.error('Erreur crĂ©ation WebSocket:', error); updateVUMetersStatus('Erreur de connexion'); } } function disconnectAudioLevelsWS() { if (audioLevelsWS) { audioLevelsWS.close(); audioLevelsWS = null; } } function updateVUMetersStatus(status) { const container = document.getElementById('vu-meters'); if (!container) return; const statusEl = container.querySelector('.vu-status'); if (statusEl) { statusEl.textContent = `WebSocket: ${status}`; statusEl.className = `vu-status ${status === 'ConnectĂ©' ? 'connected' : 'disconnected'}`; } } function renderVUMeters() { const container = document.getElementById('vu-meters'); if (!container) return; const hasData = Object.keys(audioLevelsData.inputs).length > 0 || Object.keys(audioLevelsData.groups).length > 0 || Object.keys(audioLevelsData.outputs).length > 0; if (!hasData) { container.innerHTML = `

WebSocket: En attente de connexion...

Aucune donnée audio disponible

`; return; } let html = '
WebSocket: Connecté
'; // Inputs if (Object.keys(audioLevelsData.inputs).length > 0) { html += '

Entrées Audio

'; Object.entries(audioLevelsData.inputs).forEach(([channelId, data]) => { html += renderVUMeter(channelId, data, 'input'); }); html += '
'; } // Groups if (Object.keys(audioLevelsData.groups).length > 0) { html += '

Groupes

'; Object.entries(audioLevelsData.groups).forEach(([groupName, data]) => { html += renderVUMeter(groupName, data, 'group'); }); html += '
'; } // Outputs if (Object.keys(audioLevelsData.outputs).length > 0) { html += '

Sorties Audio

'; Object.entries(audioLevelsData.outputs).forEach(([channelId, data]) => { html += renderVUMeter(channelId, data, 'output'); }); html += '
'; } container.innerHTML = html; } function renderVUMeter(label, data, type) { const { rms, peak, clipping } = data; // Convertir dBFS en pourcentage pour la barre (0dB = 100%, -60dB = 0%) const rmsPercent = Math.max(0, Math.min(100, ((rms + 60) / 60) * 100)); const peakPercent = Math.max(0, Math.min(100, ((peak * 60 - 60 + 60) / 60) * 100)); // Couleur selon le niveau let barClass = 'vu-bar-green'; if (rms > -6) barClass = 'vu-bar-red'; else if (rms > -12) barClass = 'vu-bar-yellow'; const clippingClass = clipping ? 'vu-meter-clipping' : ''; return `
${escapeHtml(label)}
${rms.toFixed(1)} dB ${clipping ? 'CLIP!' : ''}
`; } function formatTime(isoString) { if (!isoString) return '--'; const date = new Date(isoString); return date.toLocaleTimeString('fr-FR'); } function formatLogTime(isoString) { if (!isoString) return '--'; const date = new Date(isoString); return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function showNotification(message, type = 'info') { console.log(`[${type.toUpperCase()}] ${message}`); const container = document.getElementById('toast-container'); if (!container) return; // IcĂŽnes par type const icons = { success: '✅', error: '❌', warning: '⚠', info: 'â„č' }; // CrĂ©er le toast const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = ` ${icons[type] || icons.info} ${escapeHtml(message)} `; // Ajouter au container container.appendChild(toast); // Bouton fermer const closeBtn = toast.querySelector('.toast-close'); closeBtn.addEventListener('click', () => { toast.remove(); }); // Auto-remove aprĂšs 5 secondes setTimeout(() => { if (toast.parentElement) { toast.style.animation = 'slideIn 0.3s ease-out reverse'; setTimeout(() => toast.remove(), 300); } }, 5000); }