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:
@@ -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)
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user