feat: support multi-groupes avec sélection dynamique (Phase 2.1)

- Ajout de 3 groupes dans config.yaml : Production, Technique, Sonorisation
- Nouvel endpoint API GET /groups pour lister les groupes disponibles
- Composant GroupSelector.jsx pour changer de groupe pendant la session
- Hook useLiveKit étendu avec fonction switchGroup() pour reconnexion
- Intégration dans App.jsx avec gestion du changement de groupe
- Chaque groupe = 1 room LiveKit distincte
- Qualité audio configurable par groupe (96-128 kbps)
This commit is contained in:
2026-05-23 09:32:51 +02:00
parent c863f045ae
commit 3181c62e57
7 changed files with 338 additions and 1 deletions
-1
View File
@@ -154,7 +154,6 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
- [ ] Client : affichage canaux groupe actif - [ ] Client : affichage canaux groupe actif
### 2.2 Modes PTT avancés ### 2.2 Modes PTT avancés
- [ ] PTT lock : appui long 3s → lock/unlock
- [ ] Mode continu : toggle ON/OFF - [ ] Mode continu : toggle ON/OFF
- [ ] Vibration + indicateur visuel rouge (lock actif) - [ ] Vibration + indicateur visuel rouge (lock actif)
- [ ] Préférences utilisateur (mode par défaut) - [ ] Préférences utilisateur (mode par défaut)
+45
View File
@@ -3,6 +3,7 @@ import useLiveKit from './hooks/useLiveKit';
import PTTButton from './components/PTTButton'; import PTTButton from './components/PTTButton';
import UserList from './components/UserList'; import UserList from './components/UserList';
import AudioIndicator from './components/AudioIndicator'; import AudioIndicator from './components/AudioIndicator';
import GroupSelector from './components/GroupSelector';
import './App.css'; import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -21,6 +22,7 @@ function App() {
audioLevel, audioLevel,
connect, connect,
disconnect, disconnect,
switchGroup,
startTalking, startTalking,
stopTalking stopTalking
} = useLiveKit(); } = useLiveKit();
@@ -112,6 +114,42 @@ function App() {
setError(null); setError(null);
}; };
const handleGroupChange = async (newGroupId) => {
console.log('🔄 Changement de groupe:', groupId, '→', newGroupId);
try {
// Obtenir nouveau token pour le nouveau groupe
const response = await fetch(`${API_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, groupId: newGroupId })
});
if (!response.ok) {
throw new Error('Erreur serveur');
}
const data = await response.json();
// Adapter l'URL LiveKit selon le protocole de la page
let livekitUrl = data.url;
if (window.location.protocol === 'https:') {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
}
// Changer de room LiveKit
await switchGroup(livekitUrl, data.token);
// Mettre à jour l'état
setGroupId(newGroupId);
console.log('✓ Groupe changé avec succès');
} catch (err) {
console.error('Erreur changement de groupe:', err);
throw err; // Propager l'erreur au composant GroupSelector
}
};
// Interface de connexion // Interface de connexion
if (!isConnected) { if (!isConnected) {
return ( return (
@@ -193,6 +231,13 @@ function App() {
</header> </header>
<main className="app-main"> <main className="app-main">
{/* Sélecteur de groupe */}
<GroupSelector
currentGroupId={groupId}
onGroupChange={handleGroupChange}
apiUrl={API_URL}
/>
{/* Liste des participants */} {/* Liste des participants */}
<UserList participants={participants} /> <UserList participants={participants} />
+110
View File
@@ -0,0 +1,110 @@
.group-selector {
margin: 1rem 0;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.group-selector-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.group-selector-select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 3rem;
}
.group-selector-select:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.group-selector-select:focus {
outline: none;
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.group-selector-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.group-selector-select option {
background: #1a1a1a;
color: white;
padding: 0.5rem;
}
.group-selector-description {
margin-top: 0.5rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.group-selector-loading,
.group-selector-error {
padding: 0.75rem;
text-align: center;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
}
.group-selector-error {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
}
.group-selector-changing {
margin-top: 0.5rem;
padding: 0.5rem;
text-align: center;
font-size: 0.875rem;
color: var(--color-primary, #3b82f6);
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.group-selector {
margin: 0.75rem 0;
padding: 0.75rem;
}
.group-selector-select {
font-size: 1rem;
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import './GroupSelector.css';
/**
* Composant de sélection de groupe
* Permet de changer de groupe pendant une session active
*
* @param {Object} props
* @param {string} props.currentGroupId - ID du groupe actuel
* @param {Function} props.onGroupChange - Callback appelé lors du changement de groupe
* @param {string} props.apiUrl - URL de l'API
*/
function GroupSelector({ currentGroupId, onGroupChange, apiUrl }) {
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isChanging, setIsChanging] = useState(false);
const [error, setError] = useState(null);
// Charger la liste des groupes
useEffect(() => {
const fetchGroups = async () => {
try {
const response = await fetch(`${apiUrl}/groups`);
if (!response.ok) {
throw new Error('Erreur chargement groupes');
}
const data = await response.json();
setGroups(data.groups || []);
} catch (err) {
console.error('Erreur chargement groupes:', err);
setError('Impossible de charger les groupes');
} finally {
setIsLoading(false);
}
};
fetchGroups();
}, [apiUrl]);
const handleChange = async (e) => {
const newGroupId = e.target.value;
if (newGroupId === currentGroupId) {
return; // Pas de changement
}
setIsChanging(true);
setError(null);
try {
await onGroupChange(newGroupId);
} catch (err) {
console.error('Erreur changement groupe:', err);
setError('Erreur lors du changement de groupe');
// Réinitialiser la sélection à l'ancien groupe
e.target.value = currentGroupId;
} finally {
setIsChanging(false);
}
};
if (isLoading) {
return (
<div className="group-selector">
<div className="group-selector-loading">
Chargement...
</div>
</div>
);
}
if (error) {
return (
<div className="group-selector">
<div className="group-selector-error">
{error}
</div>
</div>
);
}
const currentGroup = groups.find(g => g.id === currentGroupId);
return (
<div className="group-selector">
<label htmlFor="group-select" className="group-selector-label">
Groupe
</label>
<select
id="group-select"
className="group-selector-select"
value={currentGroupId}
onChange={handleChange}
disabled={isChanging || groups.length === 0}
>
{groups.map(g => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{currentGroup && (
<p className="group-selector-description">
{currentGroup.description}
</p>
)}
{isChanging && (
<div className="group-selector-changing">
Changement de groupe...
</div>
)}
</div>
);
}
export default GroupSelector;
+21
View File
@@ -151,6 +151,26 @@ export default function useLiveKit() {
setParticipants([]); setParticipants([]);
}, []); }, []);
/**
* Changer de groupe (reconnexion à une nouvelle room)
*/
const switchGroup = useCallback(async (url, token) => {
console.log('🔄 Changement de groupe...');
// Déconnexion propre
cleanup();
if (roomRef.current) {
roomRef.current.disconnect();
roomRef.current = null;
}
setIsConnected(false);
setParticipants([]);
// Reconnexion avec nouveau token
await connect(url, token);
}, [connect]);
/** /**
* Débloque l'audio sur mobile (iOS/Android) * Débloque l'audio sur mobile (iOS/Android)
* Doit être appelé dans un gestionnaire d'événement utilisateur * Doit être appelé dans un gestionnaire d'événement utilisateur
@@ -390,6 +410,7 @@ export default function useLiveKit() {
audioLevel, audioLevel,
connect, connect,
disconnect, disconnect,
switchGroup,
startTalking, startTalking,
stopTalking stopTalking
}; };
+24
View File
@@ -37,6 +37,30 @@ groups:
audioInput: 1 audioInput: 1
audioOutput: 1 audioOutput: 1
- id: technique
name: "Équipe Technique"
description: "Techniciens, électriciens, machinistes"
audioBitrate: 96
channels:
- id: tech-main
name: "Technique général"
audioInput: 2
audioOutput: 2
- id: sonorisation
name: "Équipe Sonorisation"
description: "Ingénieurs son, retours"
audioBitrate: 128 # Qualité supérieure pour les ingénieurs son
channels:
- id: son-main
name: "Son principal"
audioInput: 3
audioOutput: 3
- id: son-retours
name: "Retours scène"
audioInput: 4
audioOutput: 4
# Configuration serveur # Configuration serveur
server: server:
host: "0.0.0.0" host: "0.0.0.0"
+20
View File
@@ -205,6 +205,25 @@ app.get('/config', (req, res) => {
} }
}); });
/**
* GET /groups
* Retourne la liste des groupes disponibles (simplifié)
*/
app.get('/groups', (req, res) => {
try {
const groups = config.groups.map(g => ({
id: g.id,
name: g.name,
description: g.description
}));
res.json({ groups });
} catch (error) {
log('error', 'Erreur GET /groups:', error);
res.status(500).json({ error: 'Groups unavailable' });
}
});
/** /**
* POST /token * POST /token
* Génère un token LiveKit pour un client * Génère un token LiveKit pour un client
@@ -287,6 +306,7 @@ app.get('/', (req, res) => {
phase: 'Phase 1 - MVP', phase: 'Phase 1 - MVP',
endpoints: [ endpoints: [
'GET /config - Configuration groupes', 'GET /config - Configuration groupes',
'GET /groups - Liste des groupes',
'POST /token - Générer token client', 'POST /token - Générer token client',
'GET /health - Health check' 'GET /health - Health check'
] ]