diff --git a/.gitignore b/.gitignore
index cbb1fd9..8cec756 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ pnpm-lock.yaml
.env.local
.env.*.local
server/.env
+server/config/config.yaml
client/.env
# Keep .env.example files (templates)
diff --git a/client/src/Admin.jsx b/client/src/Admin.jsx
index 82f275b..49c8c7a 100644
--- a/client/src/Admin.jsx
+++ b/client/src/Admin.jsx
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import './Admin.css';
-import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -409,9 +408,6 @@ function Admin() {
-
- Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
-
diff --git a/client/src/components/AudioRoutingMatrix.css b/client/src/components/AudioRoutingMatrix.css
deleted file mode 100644
index aa844ce..0000000
--- a/client/src/components/AudioRoutingMatrix.css
+++ /dev/null
@@ -1,245 +0,0 @@
-.routing-matrix-container {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: 12px;
- padding: var(--spacing-xl);
- margin-top: var(--spacing-lg);
-}
-
-.routing-actions {
- margin-top: var(--spacing-xl);
- display: flex;
- justify-content: flex-start;
- gap: var(--spacing-sm);
-}
-
-.routing-matrix-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: var(--spacing-xl);
-}
-
-.routing-matrix-header h3 {
- margin: 0;
- font-size: 1.25rem;
- font-weight: 600;
-}
-
-.ws-status {
- font-size: 0.8rem;
- font-weight: 600;
- padding: 4px 10px;
- border-radius: 12px;
- white-space: nowrap;
-}
-
-.ws-status.connected {
- color: #44ff44;
- background: rgba(68, 255, 68, 0.1);
-}
-
-.ws-status.disconnected {
- color: #888;
- background: rgba(136, 136, 136, 0.1);
-}
-
-.routing-section {
- margin-bottom: var(--spacing-xl);
-}
-
-.routing-section:last-child {
- margin-bottom: 0;
-}
-
-.routing-section h4 {
- margin: 0 0 var(--spacing-sm) 0;
- font-size: 1rem;
- font-weight: 600;
- color: var(--color-text);
-}
-
-.routing-description {
- margin: 0 0 var(--spacing-lg) 0;
- color: var(--color-text-secondary);
- font-size: 0.9rem;
-}
-
-.routing-matrix {
- display: inline-grid;
- gap: 2px;
- background: var(--color-border);
- border: 1px solid var(--color-border);
- border-radius: 8px;
- overflow-x: auto;
- max-width: 100%;
-}
-
-.matrix-corner {
- background: var(--color-surface-hover);
- min-height: 50px;
-}
-
-.matrix-header-cell {
- background: var(--color-surface-hover);
- padding: var(--spacing-sm);
- font-size: 0.85rem;
- font-weight: 600;
- color: var(--color-text-secondary);
- text-align: center;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 50px;
- word-break: break-word;
- hyphens: auto;
-}
-
-.matrix-label-cell {
- background: var(--color-surface-hover);
- padding: var(--spacing-sm);
- font-size: 0.85rem;
- font-weight: 600;
- color: var(--color-text-secondary);
- display: flex;
- align-items: center;
- min-width: 120px;
- word-break: break-word;
-}
-
-.label-content {
- display: flex;
- flex-direction: column;
- gap: 4px;
- width: 100%;
-}
-
-.label-text {
- flex: 1;
-}
-
-.header-content {
- display: flex;
- flex-direction: column;
- gap: 4px;
- width: 100%;
- align-items: center;
-}
-
-.header-text {
- text-align: center;
-}
-
-.matrix-cell {
- background: var(--color-bg);
- padding: var(--spacing-sm);
- min-height: 60px;
- min-width: 80px;
- transition: all 0.2s;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: var(--spacing-xs);
- position: relative;
-}
-
-.cell-checkbox {
- width: 100%;
- min-height: 30px;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
-}
-
-.matrix-cell:hover {
- background: var(--color-surface-hover);
- border: 1px solid var(--color-primary);
-}
-
-.matrix-cell.active {
- background: var(--color-primary);
- color: white;
-}
-
-.matrix-cell.active:hover {
- background: var(--color-primary-hover);
-}
-
-.checkmark {
- font-size: 1.2rem;
- font-weight: bold;
-}
-
-.gain-select {
- width: 100%;
- padding: 4px 8px;
- font-size: 0.75rem;
- background: rgba(59, 130, 246, 0.2);
- color: #ffffff;
- border: 1px solid rgba(255, 255, 255, 1);
- border-radius: 4px;
- cursor: pointer;
- font-weight: 600;
- text-align: center;
-}
-
-.gain-select:focus {
- outline: none;
- background: rgba(59, 130, 246, 0.3);
- border-color: rgba(255, 255, 255, 1);
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
-}
-
-@media (max-width: 1024px) {
- .matrix-header-cell,
- .matrix-label-cell {
- font-size: 0.75rem;
- padding: var(--spacing-xs);
- min-width: 80px;
- }
-
- .matrix-cell {
- min-width: 70px;
- min-height: 50px;
- padding: var(--spacing-sm);
- }
-
- .gain-select {
- font-size: 0.7rem;
- padding: 3px 6px;
- }
-}
-
-@media (max-width: 768px) {
- .routing-matrix-container {
- padding: var(--spacing-md);
- }
-
- .routing-matrix-header {
- flex-direction: column;
- gap: var(--spacing-md);
- align-items: stretch;
- }
-
- .matrix-header-cell,
- .matrix-label-cell {
- font-size: 0.7rem;
- min-width: 60px;
- }
-
- .matrix-cell {
- min-width: 65px;
- min-height: 45px;
- }
-
- .checkmark {
- font-size: 1rem;
- }
-
- .gain-select {
- font-size: 0.65rem;
- padding: 2px 4px;
- }
-}
diff --git a/client/src/components/AudioRoutingMatrix.jsx b/client/src/components/AudioRoutingMatrix.jsx
deleted file mode 100644
index 5662f1c..0000000
--- a/client/src/components/AudioRoutingMatrix.jsx
+++ /dev/null
@@ -1,349 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import './AudioRoutingMatrix.css';
-import VUMeter from './VUMeter.jsx';
-import { useAudioLevels } from '../hooks/useAudioLevels.js';
-
-const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
-
-function AudioRoutingMatrix({ groups, channelNames }) {
- const { levels, connected: wsConnected } = useAudioLevels();
- const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
- const [loading, setLoading] = useState(true);
- const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
- const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
-
- useEffect(() => {
- loadRouting();
- loadAudioDevice();
- }, []);
-
- const loadRouting = async () => {
- try {
- const res = await fetch(`${API_URL}/admin/audio/routing`);
- if (!res.ok) {
- throw new Error(`HTTP error! status: ${res.status}`);
- }
- const data = await res.json();
- setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
- } catch (error) {
- console.error('Erreur chargement routing:', error);
- } finally {
- setLoading(false);
- }
- };
-
- const loadAudioDevice = async () => {
- try {
- const res = await fetch(`${API_URL}/admin/audio/device`);
- if (res.ok) {
- const data = await res.json();
- setAudioDevice({
- inputChannels: data.device?.inputChannels || 8,
- outputChannels: data.device?.outputChannels || 8
- });
- }
- } catch (error) {
- console.error('Erreur chargement audio device:', error);
- }
- };
-
- const saveRouting = async () => {
- try {
- const res = await fetch(`${API_URL}/admin/audio/routing`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(routing)
- });
-
- if (res.ok) {
- alert('Configuration de routing sauvegardée!');
- } else {
- const errorText = await res.text();
- console.error('Erreur serveur:', errorText);
- alert(`Erreur: ${res.status} - ${errorText}`);
- }
- } catch (error) {
- console.error('Erreur sauvegarde routing:', error);
- alert('Erreur lors de la sauvegarde');
- }
- };
-
- const toggleInputToGroup = (inputId, groupId) => {
- setRouting(prev => {
- const inputToGroup = { ...prev.inputToGroup };
- if (!inputToGroup[inputId]) {
- inputToGroup[inputId] = [];
- }
-
- const groupArray = [...inputToGroup[inputId]];
- const index = groupArray.indexOf(groupId);
-
- if (index > -1) {
- groupArray.splice(index, 1);
- } else {
- groupArray.push(groupId);
- }
-
- inputToGroup[inputId] = groupArray;
-
- return { ...prev, inputToGroup };
- });
- };
-
- const toggleGroupToOutput = (groupId, outputId) => {
- setRouting(prev => {
- const groupToOutput = { ...prev.groupToOutput };
- if (!groupToOutput[groupId]) {
- groupToOutput[groupId] = [];
- }
-
- const outputArray = [...groupToOutput[groupId]];
- const index = outputArray.indexOf(outputId);
-
- if (index > -1) {
- outputArray.splice(index, 1);
- } else {
- outputArray.push(outputId);
- }
-
- groupToOutput[groupId] = outputArray;
-
- return { ...prev, groupToOutput };
- });
- };
-
- const isInputRoutedToGroup = (inputId, groupId) => {
- return routing.inputToGroup[inputId]?.includes(groupId) || false;
- };
-
- const isGroupRoutedToOutput = (groupId, outputId) => {
- return routing.groupToOutput[groupId]?.includes(outputId) || false;
- };
-
- const getGainForInputToGroup = (inputId, groupId) => {
- const key = `in_${inputId}_${groupId}`;
- return routing.gains?.[key] || 0.0;
- };
-
- const getGainForGroupToOutput = (groupId, outputId) => {
- const key = `${groupId}_out_${outputId}`;
- return routing.gains?.[key] || 0.0;
- };
-
- const setGainForInputToGroup = (inputId, groupId, gainDb) => {
- setRouting(prev => {
- const gains = { ...prev.gains };
- const key = `in_${inputId}_${groupId}`;
- gains[key] = parseFloat(gainDb);
- return { ...prev, gains };
- });
- };
-
- const setGainForGroupToOutput = (groupId, outputId, gainDb) => {
- setRouting(prev => {
- const gains = { ...prev.gains };
- const key = `${groupId}_out_${outputId}`;
- gains[key] = parseFloat(gainDb);
- return { ...prev, gains };
- });
- };
-
- const formatGain = (gainDb) => {
- if (gainDb === 0) return '0dB';
- return gainDb > 0 ? `+${gainDb}dB` : `${gainDb}dB`;
- };
-
- const getChannelName = (type, id) => {
- const name = channelNames?.[type]?.[id];
- return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
- };
-
- const hasCustomName = (type, id) => {
- return channelNames?.[type]?.[id] !== undefined;
- };
-
- const getVisibleInputChannels = () => {
- const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
- if (showOnlyNamedChannels) {
- return allInputs.filter(i => hasCustomName('inputs', i));
- }
- return allInputs;
- };
-
- const getVisibleOutputChannels = () => {
- const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
- if (showOnlyNamedChannels) {
- return allOutputs.filter(i => hasCustomName('outputs', i));
- }
- return allOutputs;
- };
-
- if (loading) {
- return
Chargement...
;
- }
-
- return (
-
-
-
-
Matrice de routing audio
-
- {wsConnected ? '● Live' : '○ Offline'}
-
-
-
-
-
-
-
Inputs vers Groupes
-
- Sélectionnez quels inputs audio alimentent chaque groupe
-
-
-
-
-
- {groups.map(group => (
-
- {group.name}
-
- ))}
-
- {getVisibleInputChannels().map(i => (
-
-
-
- {getChannelName('inputs', i)}
- {wsConnected && levels.inputs[i] && (
-
- )}
-
-
-
- {groups.map(group => {
- const isRouted = isInputRoutedToGroup(String(i), group.id);
- const gain = getGainForInputToGroup(String(i), group.id);
-
- return (
-
-
toggleInputToGroup(String(i), group.id)}
- >
- {isRouted && ✓}
-
- {isRouted && (
-
- )}
-
- );
- })}
-
- ))}
-
-
-
-
-
Groupes vers Outputs
-
- Sélectionnez vers quels outputs chaque groupe envoie son audio
-
-
-
-
-
- {getVisibleOutputChannels().map(i => (
-
-
- {getChannelName('outputs', i)}
- {wsConnected && levels.outputs[i] && (
-
- )}
-
-
- ))}
-
- {groups.map(group => (
-
-
-
- {group.name}
- {wsConnected && levels.groups[group.id] && (
-
- )}
-
-
-
- {getVisibleOutputChannels().map(i => {
- const isRouted = isGroupRoutedToOutput(group.id, String(i));
- const gain = getGainForGroupToOutput(group.id, String(i));
-
- return (
-
-
toggleGroupToOutput(group.id, String(i))}
- >
- {isRouted && ✓}
-
- {isRouted && (
-
- )}
-
- );
- })}
-
- ))}
-
-
-
-
-
-
-
- );
-}
-
-export default AudioRoutingMatrix;
diff --git a/client/src/hooks/useLiveKit.js b/client/src/hooks/useLiveKit.js
index 3c8b003..536f13b 100644
--- a/client/src/hooks/useLiveKit.js
+++ b/client/src/hooks/useLiveKit.js
@@ -283,8 +283,17 @@ export default function useLiveKit() {
});
});
- // Participants distants (utilisateurs WebRTC)
+ // Participants distants (utilisateurs WebRTC + server audio users)
+ // Exclure les participants internes de routage (role: 'bridge')
room.remoteParticipants.forEach((participant) => {
+ let role = null;
+ try {
+ const meta = participant.metadata ? JSON.parse(participant.metadata) : {};
+ role = meta.role || null;
+ } catch (_) {}
+
+ if (role === 'bridge') return;
+
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
const audioPublication = audioTracks[0];
const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity);
diff --git a/electron/main.js b/electron/main.js
index 7aadda2..4fade6c 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -436,6 +436,129 @@ app.whenReady().then(async () => {
}
});
+ // ========== Server Audio Users (lecture/écriture YAML directe) ==========
+
+ ipcMain.handle('server-audio-users:list', () => {
+ try {
+ const config = readConfig();
+ return { users: config.server_audio_users || [] };
+ } catch (error) {
+ return { users: [], error: error.message };
+ }
+ });
+
+ ipcMain.handle('server-audio-users:create', (event, { name, group, input_channel, output_channel }) => {
+ try {
+ const config = readConfig();
+ const users = config.server_audio_users || [];
+ if (users.find(u => u.name === name)) {
+ return { success: false, error: `Un utilisateur "${name}" existe déjà` };
+ }
+ const user = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
+ config.server_audio_users = [...users, user];
+ writeConfig(config);
+ return { success: true, user };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
+ ipcMain.handle('server-audio-users:update', (event, { name, group, input_channel, output_channel }) => {
+ try {
+ const config = readConfig();
+ const users = config.server_audio_users || [];
+ const idx = users.findIndex(u => u.name === name);
+ if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
+ config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
+ writeConfig(config);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
+ ipcMain.handle('server-audio-users:delete', (event, { name }) => {
+ try {
+ const config = readConfig();
+ const users = config.server_audio_users || [];
+ const idx = users.findIndex(u => u.name === name);
+ if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
+ config.server_audio_users.splice(idx, 1);
+ writeConfig(config);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
+ // ========== Routing (lecture/écriture YAML directe) ==========
+
+ ipcMain.handle('routing:get', () => {
+ try {
+ const config = readConfig();
+ return {
+ channelNames: config.audio?.channelNames || { inputs: {}, outputs: {} },
+ groups: config.groups || [],
+ serverAudioUsers: config.server_audio_users || []
+ };
+ } catch (error) {
+ return { error: error.message };
+ }
+ });
+
+ // ========== Devices : découverte canaux physiques ==========
+
+ ipcMain.handle('devices:getChannels', () => {
+ try {
+ const config = readConfig();
+ const inputDeviceName = config.audio?.device?.inputDeviceId;
+ const outputDeviceName = config.audio?.device?.outputDeviceId;
+
+ let inputDevice = { name: inputDeviceName || 'Non configuré', channels: 0 };
+ let outputDevice = { name: outputDeviceName || 'Non configuré', channels: 0 };
+
+ if (process.platform === 'darwin') {
+ try {
+ const { execSync } = require('child_process');
+ const raw = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8', timeout: 5000 });
+ const data = JSON.parse(raw);
+
+ if (data.SPAudioDataType) {
+ data.SPAudioDataType.forEach(item => {
+ (item._items || []).forEach(dev => {
+ const name = dev._name || '';
+ const inCh = parseInt(dev.coreaudio_device_input) || 0;
+ const outCh = parseInt(dev.coreaudio_device_output) || 0;
+ if (inputDeviceName && name === inputDeviceName && inCh > 0) {
+ inputDevice = { name, channels: inCh };
+ }
+ if (outputDeviceName && name === outputDeviceName && outCh > 0) {
+ outputDevice = { name, channels: outCh };
+ }
+ });
+ });
+ }
+ } catch (_) { /* detection failed, keep defaults */ }
+ }
+
+ return { inputDevice, outputDevice };
+ } catch (error) {
+ return { error: error.message, inputDevice: { name: 'Inconnu', channels: 0 }, outputDevice: { name: 'Inconnu', channels: 0 } };
+ }
+ });
+
+ ipcMain.handle('routing:save', (event, { channelNames }) => {
+ try {
+ const config = readConfig();
+ if (!config.audio) config.audio = {};
+ config.audio.channelNames = channelNames;
+ writeConfig(config);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
ipcMain.handle('config:export', async () => {
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
diff --git a/electron/preload.js b/electron/preload.js
index a6d2f2f..4900864 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -51,6 +51,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
delete: (data) => ipcRenderer.invoke('groups:delete', data)
},
+ // Utilisateurs audio serveur : lecture/écriture YAML directe (fonctionne sans serveur)
+ serverAudioUsers: {
+ list: () => ipcRenderer.invoke('server-audio-users:list'),
+ create: (data) => ipcRenderer.invoke('server-audio-users:create', data),
+ update: (data) => ipcRenderer.invoke('server-audio-users:update', data),
+ delete: (data) => ipcRenderer.invoke('server-audio-users:delete', data)
+ },
+
+ // Routing audio : lecture/écriture YAML directe (fonctionne sans serveur)
+ routing: {
+ get: () => ipcRenderer.invoke('routing:get'),
+ save: (data) => ipcRenderer.invoke('routing:save', data)
+ },
+
+ // Découverte canaux physiques de la carte son sélectionnée
+ devices: {
+ getChannels: () => ipcRenderer.invoke('devices:getChannels')
+ },
+
// Helpers
platform: process.platform,
version: process.env.npm_package_version || '0.3.0'
diff --git a/electron/ui/app.js b/electron/ui/app.js
index 009fc3f..49c96a5 100644
--- a/electron/ui/app.js
+++ b/electron/ui/app.js
@@ -9,6 +9,8 @@ let serverRunning = false;
let statsInterval = null;
let logsBuffer = [];
let audioLevelsWS = null;
+let routingData = null;
+let deviceChannels = null;
let audioLevelsData = {
inputs: {},
groups: {},
@@ -572,12 +574,25 @@ async function loadInitialData() {
}
async function loadViewData(view) {
- // Les groupes sont lisibles même sans serveur (config.yaml direct)
+ // Ces vues lisent config.yaml directement (fonctionne sans serveur)
if (view === 'groups') {
await fetchGroups();
return;
}
+ if (view === 'routing') {
+ await fetchRouting();
+ return;
+ }
+
+ if (view === 'config') {
+ if (serverRunning) {
+ await fetchDevices();
+ await fetchConfig();
+ }
+ return;
+ }
+
if (!serverRunning) return;
switch (view) {
@@ -586,10 +601,6 @@ async function loadViewData(view) {
await fetchUsers();
await generateQRCode();
break;
- case 'config':
- await fetchDevices();
- await fetchConfig();
- break;
case 'monitoring':
renderVUMeters();
break;
@@ -778,8 +789,302 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
+
+ // Boutons routing
+ document.getElementById('btn-save-routing')?.addEventListener('click', saveRouting);
+ document.getElementById('btn-reload-routing')?.addEventListener('click', fetchRouting);
+ document.getElementById('btn-refresh-channels')?.addEventListener('click', fetchRouting);
+ document.getElementById('btn-add-server-audio-user')?.addEventListener('click', addServerAudioUser);
+
+ // Délégation modifier/supprimer participant serveur
+ document.getElementById('server-audio-users-list')?.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);
+ }
+ });
});
+// ========== Server Audio Users (dans la vue Routing) ==========
+
+function renderServerAudioUsers() {
+ const container = document.getElementById('server-audio-users-list');
+ if (!container) return;
+
+ const users = routingData?.serverAudioUsers || [];
+ const inputs = routingData?.channelNames?.inputs || {};
+ const outputs = routingData?.channelNames?.outputs || {};
+
+ const chLabel = (ch, dir) => {
+ if (ch === null || ch === undefined) return 'Aucune';
+ const name = dir === 'input' ? inputs[ch] : outputs[ch];
+ return name ? `Ch ${ch} · ${name}` : `Ch ${ch}`;
+ };
+
+ if (users.length === 0) {
+ container.innerHTML = '
Aucun participant serveur configuré
';
+ return;
+ }
+
+ container.innerHTML = `
+
+
+ | Nom | Groupe | Entrée | Sortie | |
+
+
+ ${users.map(u => `
+
+ | ${escapeHtml(u.name)} |
+ ${escapeHtml(u.group)} |
+ ${chLabel(u.input_channel, 'input')} |
+ ${chLabel(u.output_channel, 'output')} |
+
+
+
+ |
+
`).join('')}
+
+
`;
+}
+
+function buildChannelOptions(dir) {
+ const channels = dir === 'input'
+ ? routingData?.deviceChannels?.inputDevice?.channels || 0
+ : routingData?.deviceChannels?.outputDevice?.channels || 0;
+ const names = dir === 'input'
+ ? routingData?.channelNames?.inputs || {}
+ : routingData?.channelNames?.outputs || {};
+
+ if (channels === 0) return null; // fallback to number input
+
+ const opts = Array.from({ length: channels }, (_, i) => ({
+ value: String(i),
+ label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
+ }));
+
+ if (dir === 'output') {
+ opts.unshift({ value: '', label: 'Aucune sortie' });
+ }
+
+ return opts;
+}
+
+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 inOpts = buildChannelOptions('input');
+ const outOpts = buildChannelOptions('output');
+
+ const inputField = inOpts
+ ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '0' }
+ : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: 0, min: 0, max: 63 };
+ const outputField = outOpts
+ ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' }
+ : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
+
+ const result = await showModal({
+ title: 'Nouveau participant serveur',
+ fields: [
+ { name: 'name', label: 'Nom (identifiant unique, ex: foh)' },
+ { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup },
+ inputField,
+ outputField
+ ],
+ 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: result.output_channel !== '' ? parseInt(result.output_channel) : null
+ });
+
+ if (res.success) {
+ showNotification('Participant serveur ajouté', 'success');
+ await fetchRouting();
+ } 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 inOpts = buildChannelOptions('input');
+ const outOpts = buildChannelOptions('output');
+
+ const inputField = inOpts
+ ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: String(input_channel) }
+ : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: input_channel, min: 0, max: 63 };
+ const outputDefault = output_channel !== null && output_channel !== undefined ? String(output_channel) : '';
+ const outputField = outOpts
+ ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault }
+ : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 };
+
+ const result = await showModal({
+ title: `Modifier "${name}"`,
+ fields: [
+ { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group },
+ inputField,
+ outputField
+ ],
+ confirmLabel: 'Modifier'
+ });
+
+ if (!result) return;
+
+ const res = await window.electronAPI.serverAudioUsers.update({
+ name,
+ group: result.group,
+ input_channel: parseInt(result.input_channel),
+ output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
+ });
+
+ if (res.success) {
+ showNotification('Participant serveur modifié', 'success');
+ await fetchRouting();
+ } else {
+ showNotification('Erreur: ' + (res.error || 'Modification échouée'), 'error');
+ }
+}
+
+async function deleteServerAudioUser(name) {
+ const confirmed = await showModal({
+ title: 'Supprimer le participant 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('Participant serveur supprimé', 'success');
+ await fetchRouting();
+ } else {
+ showNotification('Erreur: ' + (res.error || 'Suppression échouée'), 'error');
+ }
+}
+
+// ========== Routing ==========
+
+async function fetchRouting() {
+ const [routingResult, devResult] = await Promise.all([
+ window.electronAPI.routing.get(),
+ window.electronAPI.devices.getChannels()
+ ]);
+
+ if (routingResult.error) {
+ showNotification('Erreur chargement routing: ' + routingResult.error, 'error');
+ return;
+ }
+
+ deviceChannels = devResult;
+ routingData = { ...routingResult, deviceChannels: devResult };
+ renderRoutingView();
+}
+
+function renderRoutingView() {
+ if (!routingData) return;
+ renderDeviceBanner();
+ renderChannelLabels();
+ renderServerAudioUsers();
+}
+
+function renderDeviceBanner() {
+ const el = document.getElementById('routing-device-info');
+ if (!el) return;
+
+ const dc = routingData.deviceChannels;
+ if (!dc || dc.error || (dc.inputDevice?.channels === 0 && dc.outputDevice?.channels === 0)) {
+ el.innerHTML = `
Aucun device configuré — sélectionnez une carte son dans Configuration`;
+ return;
+ }
+
+ const { inputDevice, outputDevice } = dc;
+ el.innerHTML = `
+
Entrée : ${escapeHtml(inputDevice.name)}
+ ${inputDevice.channels} ch
+
·
+
Sortie : ${escapeHtml(outputDevice.name)}
+ ${outputDevice.channels} ch`;
+}
+
+function renderChannelLabels() {
+ const { channelNames, deviceChannels: dc } = routingData;
+ const inputs = channelNames?.inputs || {};
+ const outputs = channelNames?.outputs || {};
+ const inputCount = dc?.inputDevice?.channels || 0;
+ const outputCount = dc?.outputDevice?.channels || 0;
+
+ const inputsEl = document.getElementById('channel-names-inputs');
+ const outputsEl = document.getElementById('channel-names-outputs');
+
+ if (inputsEl) {
+ inputsEl.innerHTML = inputCount > 0
+ ? Array.from({ length: inputCount }, (_, i) => channelLabelRow('input', i, inputs[i] || '')).join('')
+ : '
Aucun device d\'entrée détecté.
';
+ }
+
+ if (outputsEl) {
+ outputsEl.innerHTML = outputCount > 0
+ ? Array.from({ length: outputCount }, (_, i) => channelLabelRow('output', i, outputs[i] || '')).join('')
+ : '
Aucun device de sortie détecté.
';
+ }
+}
+
+function channelLabelRow(dir, ch, name) {
+ return `
+
+ Ch ${ch}
+
+
`;
+}
+
+async function saveRouting() {
+ if (!routingData) return;
+
+ 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;
+ });
+
+ const result = await window.electronAPI.routing.save({ channelNames: newChannelNames });
+
+ if (result.success) {
+ routingData.channelNames = newChannelNames;
+ renderRoutingView();
+ showNotification('Noms de canaux sauvegardés', 'success');
+ } else {
+ showNotification('Erreur: ' + (result.error || 'Sauvegarde échouée'), 'error');
+ }
+}
+
// ========== Helpers ==========
/**
@@ -802,19 +1107,30 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
if (message) {
bodyEl.innerHTML = `
${escapeHtml(message)}
`;
} else {
- bodyEl.innerHTML = fields.map(field => `
-
-
-
-
- `).join('');
+ bodyEl.innerHTML = fields.map(field => {
+ if (field.type === 'select') {
+ const optionsHtml = (field.options || []).map(opt =>
+ `
`
+ ).join('');
+ return `
+
+
+
+
`;
+ }
+ return `
+
+
+
+
`;
+ }).join('');
}
overlay.classList.remove('hidden');
diff --git a/electron/ui/index.html b/electron/ui/index.html
index 83050fd..c1c5326 100644
--- a/electron/ui/index.html
+++ b/electron/ui/index.html
@@ -40,6 +40,9 @@
+
@@ -163,6 +166,53 @@
+
+