/** * PTT Live Desktop - Renderer Process Logic */ const API_BASE = 'http://localhost:3000'; // État global let serverRunning = false; let statsInterval = null; let logsBuffer = []; let audioLevelsWS = 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(); } } // ========== 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 data = await apiCall('/admin/groups'); if (!data) return; const container = document.getElementById('groups-list'); if (!data.groups || data.groups.length === 0) { container.innerHTML = '

Aucun groupe configuré

'; return; } container.innerHTML = data.groups.map(group => `

${group.name}

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

`).join(''); } 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(); const clientUrl = `https://${networkIP}:5173`; // Mode dev Vite document.getElementById('client-url').textContent = clientUrl; // GĂ©nĂ©rer QR Code const canvas = document.getElementById('qr-code'); if (canvas && window.QRCode) { QRCode.toCanvas(canvas, clientUrl, { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff' } }, (error) => { if (error) { console.error('Erreur gĂ©nĂ©ration QR Code:', error); } else { console.log('✅ QR Code gĂ©nĂ©rĂ©'); } }); } } catch (error) { console.error('Erreur rĂ©cupĂ©ration URL:', error); document.getElementById('client-url').textContent = 'https://localhost:5173'; } // 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() { // MĂ©thode 1 : depuis l'API serveur (qui dĂ©tecte dĂ©jĂ  l'IP) try { const config = await apiCall('/admin/config'); if (config && config.server && config.server.livekit && config.server.livekit.url) { const url = config.server.livekit.url; // Extraire l'IP depuis ws://IP:7880 const match = url.match(/ws:\/\/([^:]+):/); if (match) return match[1]; } } 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) { if (!serverRunning) return; switch (view) { case 'dashboard': await fetchStats(); await fetchUsers(); await generateQRCode(); break; case 'config': await fetchDevices(); await fetchConfig(); break; case 'groups': await fetchGroups(); 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 name = prompt('Nom du groupe:'); if (!name) return; try { const response = await fetch(`${API_BASE}/admin/groups`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, audioBitrate: 96 }) }); if (response.ok) { showNotification('Groupe créé', 'success'); await fetchGroups(); } else { showNotification('Erreur création groupe', 'error'); } } catch (error) { console.error('Erreur add group:', error); showNotification('Erreur réseau', 'error'); } }); } }); // ========== Helpers ========== 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 = 'ws://localhost:3000/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); }