/** * 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 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 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 => { 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 { const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, audioBitrate: newBitrate }) }); if (response.ok) { showNotification('Groupe modifiĂ©', 'success'); await fetchGroups(); } else { const err = await response.json().catch(() => ({})); showNotification('Erreur: ' + (err.error || '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 { const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'DELETE' }); if (response.ok) { showNotification('Groupe supprimĂ©', 'success'); await fetchGroups(); } else { const err = await response.json().catch(() => ({})); showNotification('Erreur: ' + (err.error || '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) { 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 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 { const response = await fetch(`${API_BASE}/admin/groups`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, audioBitrate }) }); if (response.ok) { showNotification('Groupe créé', 'success'); await fetchGroups(); } else { const err = await response.json().catch(() => ({})); showNotification('Erreur: ' + (err.error || '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); } }); } }); // ========== 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 => `
`).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); }