diff --git a/TODO.md b/TODO.md index 6b44578..07b3056 100644 --- a/TODO.md +++ b/TODO.md @@ -154,7 +154,6 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m - [ ] Client : affichage canaux groupe actif ### 2.2 Modes PTT avancés -- [ ] PTT lock : appui long 3s → lock/unlock - [ ] Mode continu : toggle ON/OFF - [ ] Vibration + indicateur visuel rouge (lock actif) - [ ] Préférences utilisateur (mode par défaut) diff --git a/client/src/App.jsx b/client/src/App.jsx index db73261..54be3a2 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import useLiveKit from './hooks/useLiveKit'; import PTTButton from './components/PTTButton'; import UserList from './components/UserList'; import AudioIndicator from './components/AudioIndicator'; +import GroupSelector from './components/GroupSelector'; import './App.css'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -21,6 +22,7 @@ function App() { audioLevel, connect, disconnect, + switchGroup, startTalking, stopTalking } = useLiveKit(); @@ -112,6 +114,42 @@ function App() { 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 if (!isConnected) { return ( @@ -193,6 +231,13 @@ function App() {
+ {/* Sélecteur de groupe */} + + {/* Liste des participants */} diff --git a/client/src/components/GroupSelector.css b/client/src/components/GroupSelector.css new file mode 100644 index 0000000..cd8b5ed --- /dev/null +++ b/client/src/components/GroupSelector.css @@ -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; + } +} diff --git a/client/src/components/GroupSelector.jsx b/client/src/components/GroupSelector.jsx new file mode 100644 index 0000000..750579d --- /dev/null +++ b/client/src/components/GroupSelector.jsx @@ -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 ( +
+
+ Chargement... +
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + const currentGroup = groups.find(g => g.id === currentGroupId); + + return ( +
+ + + + {currentGroup && ( +

+ {currentGroup.description} +

+ )} + + {isChanging && ( +
+ Changement de groupe... +
+ )} +
+ ); +} + +export default GroupSelector; diff --git a/client/src/hooks/useLiveKit.js b/client/src/hooks/useLiveKit.js index 5c1abcc..5563ccc 100644 --- a/client/src/hooks/useLiveKit.js +++ b/client/src/hooks/useLiveKit.js @@ -151,6 +151,26 @@ export default function useLiveKit() { 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) * Doit être appelé dans un gestionnaire d'événement utilisateur @@ -390,6 +410,7 @@ export default function useLiveKit() { audioLevel, connect, disconnect, + switchGroup, startTalking, stopTalking }; diff --git a/server/config/config.yaml b/server/config/config.yaml index 88875ec..519a00a 100644 --- a/server/config/config.yaml +++ b/server/config/config.yaml @@ -37,6 +37,30 @@ groups: audioInput: 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 server: host: "0.0.0.0" diff --git a/server/index.js b/server/index.js index 52a0a4e..9641a28 100644 --- a/server/index.js +++ b/server/index.js @@ -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 * Génère un token LiveKit pour un client @@ -287,6 +306,7 @@ app.get('/', (req, res) => { phase: 'Phase 1 - MVP', endpoints: [ 'GET /config - Configuration groupes', + 'GET /groups - Liste des groupes', 'POST /token - Générer token client', 'GET /health - Health check' ]