Files
PTT-Live/electron/ui/app.js
T
benoit 9a2bec6d2f feat: page Routing complète + gestion server_audio_users dans Config
- Page Routing : matrices Entrées→Groupes et Groupes→Sorties avec checkboxes,
  éditeur de noms de canaux (ajout/suppression dynamique), sauvegarde YAML directe
- Page Config : section Utilisateurs Audio Serveur (CRUD complet, modal avec
  sélecteur de groupe, canaux entrée/sortie)
- IPC main.js : handlers server-audio-users:list/create/update/delete,
  routing:get, routing:save
- preload.js : namespaces electronAPI.serverAudioUsers et electronAPI.routing
- showModal : support du type 'select' avec options
- loadViewData : routing et config lisibles sans serveur (YAML direct)
2026-07-01 14:27:35 +02:00

1490 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = '<p class="empty-state">Aucun utilisateur connecté</p>';
return;
}
container.innerHTML = data.users.map(user => `
<div class="user-item">
<div class="user-info">
<span class="user-status">👤</span>
<div class="user-details">
<h4>${user.username}</h4>
<p>Groupe: ${user.groupId} • Connecté: ${formatTime(user.connectedAt)}</p>
</div>
</div>
<div class="user-badge">${user.groupId}</div>
</div>
`).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 =>
`<option value="${device.id}" ${device.isDefault ? 'selected' : ''}>
${device.name} ${device.channels ? `(${device.channels}ch)` : ''}
</option>`
).join('');
outputSelect.innerHTML = data.outputs.map(device =>
`<option value="${device.id}" ${device.isDefault ? 'selected' : ''}>
${device.name} ${device.channels ? `(${device.channels}ch)` : ''}
</option>`
).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 = '<p class="empty-state">Aucun groupe configuré</p>';
return;
}
const serverNote = serverRunning ? '' : '<p class="config-note" style="margin-bottom:1rem">Serveur arrêté — les modifications seront appliquées au prochain démarrage.</p>';
container.innerHTML = serverNote + data.groups.map(group => {
const id = slugify(group.name);
return `
<div class="group-item">
<div class="group-info">
<h4>${escapeHtml(group.name)}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(id)}</p>
</div>
<div class="group-actions">
<button class="btn btn-small btn-secondary"
data-action="edit"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}"
data-bitrate="${group.audioBitrate || 96}">Modifier</button>
<button class="btn btn-small btn-danger"
data-action="delete"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}">Supprimer</button>
</div>
</div>
`;
}).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 = '<p class="empty-state">Aucun log</p>';
return;
}
container.innerHTML = logs.map(log => `
<div class="log-entry">
<span class="log-time">${formatLogTime(log.timestamp)}</span>
<span class="log-level ${log.level}">${log.level}</span>
<span class="log-message">${escapeHtml(log.message)}</span>
</div>
`).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 ? '' :
'<p class="config-note" style="margin-bottom:1rem">Serveur arrêté — les modifications seront appliquées au prochain démarrage.</p>';
if (!data.users || data.users.length === 0) {
container.innerHTML = serverNote + '<p class="empty-state">Aucun utilisateur audio serveur configuré</p>';
return;
}
container.innerHTML = serverNote + data.users.map(user => `
<div class="group-item">
<div class="group-info">
<h4>${escapeHtml(user.name)}</h4>
<p>Groupe: <strong>${escapeHtml(user.group)}</strong> · Entrée: canal ${user.input_channel} · Sortie: canal ${user.output_channel}</p>
</div>
<div class="group-actions">
<button class="btn btn-small btn-secondary"
data-sau-action="edit"
data-sau-name="${escapeHtml(user.name)}"
data-sau-group="${escapeHtml(user.group)}"
data-sau-input="${user.input_channel}"
data-sau-output="${user.output_channel}">Modifier</button>
<button class="btn btn-small btn-danger"
data-sau-action="delete"
data-sau-name="${escapeHtml(user.name)}">Supprimer</button>
</div>
</div>
`).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('')
: '<p class="config-note">Aucun canal d\'entrée défini.</p>';
}
if (outputsContainer) {
outputsContainer.innerHTML = outputChannels.length > 0
? outputChannels.map(ch => channelNameRow('output', ch, outputs[ch] || '')).join('')
: '<p class="config-note">Aucun canal de sortie défini.</p>';
}
}
function channelNameRow(dir, ch, name) {
return `
<div class="channel-name-row" data-channel="${ch}" data-dir="${dir}">
<span class="channel-index">Canal ${ch}</span>
<input type="text" class="form-control form-control-small channel-name-input"
data-dir="${dir}" data-channel="${ch}"
value="${escapeHtml(name)}" placeholder="Nom du canal">
<button class="btn btn-small btn-danger channel-name-delete" data-dir="${dir}" data-channel="${ch}">✕</button>
</div>`;
}
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 = `<p class="config-note">Aucun canal de ${dir === 'input' ? 'entrée' : 'sortie'} défini.</p>`;
}
}
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 = '<p class="empty-state">Définissez des canaux d\'entrée et des groupes pour configurer le routing.</p>';
} else {
let html = '<div class="routing-matrix-scroll"><table class="routing-matrix"><thead><tr>';
html += '<th class="matrix-label-cell">Canal Entrée</th>';
groups.forEach(g => { html += `<th class="matrix-group-header">${escapeHtml(g.name)}</th>`; });
html += '</tr></thead><tbody>';
inputChannels.forEach(ch => {
const chGroups = inputToGroup[ch] || [];
html += '<tr>';
html += `<td class="matrix-channel-label"><span class="ch-index">${ch}</span><span class="ch-name">${escapeHtml(inputs[ch] || `Canal ${ch}`)}</span></td>`;
groups.forEach(g => {
const gId = slugify(g.name);
const checked = chGroups.includes(gId) ? 'checked' : '';
html += `<td class="matrix-cell"><input type="checkbox" class="routing-check" data-direction="input" data-channel="${ch}" data-group="${gId}" ${checked}></td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
inputMatrixEl.innerHTML = html;
}
}
// Matrice Groupes → Sorties
const outputMatrixEl = document.getElementById('routing-output-matrix');
if (outputMatrixEl) {
if (groups.length === 0 || outputChannels.length === 0) {
outputMatrixEl.innerHTML = '<p class="empty-state">Définissez des canaux de sortie et des groupes pour configurer le routing.</p>';
} else {
let html = '<div class="routing-matrix-scroll"><table class="routing-matrix"><thead><tr>';
html += '<th class="matrix-label-cell">Groupe</th>';
outputChannels.forEach(ch => {
html += `<th class="matrix-group-header"><span class="ch-index">${ch}</span><br><span class="ch-name">${escapeHtml(outputs[ch] || `Canal ${ch}`)}</span></th>`;
});
html += '</tr></thead><tbody>';
groups.forEach(g => {
const gId = slugify(g.name);
const gOutputs = groupToOutput[gId] || [];
html += '<tr>';
html += `<td class="matrix-channel-label">${escapeHtml(g.name)}</td>`;
outputChannels.forEach(ch => {
const checked = gOutputs.includes(ch) ? 'checked' : '';
html += `<td class="matrix-cell"><input type="checkbox" class="routing-check" data-direction="output" data-channel="${ch}" data-group="${gId}" ${checked}></td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
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 = `<p class="modal-message">${escapeHtml(message)}</p>`;
} else {
bodyEl.innerHTML = fields.map(field => {
if (field.type === 'select') {
const optionsHtml = (field.options || []).map(opt =>
`<option value="${escapeHtml(opt.value)}" ${opt.value === field.default ? 'selected' : ''}>${escapeHtml(opt.label)}</option>`
).join('');
return `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
</div>`;
}
return `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
<input
type="${field.type || 'text'}"
id="modal-field-${field.name}"
class="form-control"
value="${escapeHtml(String(field.default ?? ''))}"
${field.min !== undefined ? `min="${field.min}"` : ''}
${field.max !== undefined ? `max="${field.max}"` : ''}
${field.step !== undefined ? `step="${field.step}"` : ''}>
</div>`;
}).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 = `
<p class="vu-status">WebSocket: En attente de connexion...</p>
<p class="empty-state">Aucune donnée audio disponible</p>
`;
return;
}
let html = '<div class="vu-status connected">WebSocket: Connecté</div>';
// Inputs
if (Object.keys(audioLevelsData.inputs).length > 0) {
html += '<div class="vu-section"><h4>Entrées Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.inputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'input');
});
html += '</div></div>';
}
// Groups
if (Object.keys(audioLevelsData.groups).length > 0) {
html += '<div class="vu-section"><h4>Groupes</h4><div class="vu-grid">';
Object.entries(audioLevelsData.groups).forEach(([groupName, data]) => {
html += renderVUMeter(groupName, data, 'group');
});
html += '</div></div>';
}
// Outputs
if (Object.keys(audioLevelsData.outputs).length > 0) {
html += '<div class="vu-section"><h4>Sorties Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.outputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'output');
});
html += '</div></div>';
}
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 `
<div class="vu-meter ${clippingClass}">
<div class="vu-label">${escapeHtml(label)}</div>
<div class="vu-bar-container">
<div class="vu-bar ${barClass}" style="width: ${rmsPercent}%"></div>
<div class="vu-peak" style="left: ${peakPercent}%"></div>
</div>
<div class="vu-values">
<span class="vu-rms">${rms.toFixed(1)} dB</span>
${clipping ? '<span class="vu-clip">CLIP!</span>' : ''}
</div>
</div>
`;
}
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 = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close">×</button>
`;
// 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);
}