diff --git a/TODO.md b/TODO.md
index bbc7cdf..f1eabd1 100644
--- a/TODO.md
+++ b/TODO.md
@@ -74,48 +74,48 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### 1.4 Client PWA React
#### Infrastructure
-- [ ] client/vite.config.js (PWA plugin)
-- [ ] client/public/manifest.json
-- [ ] client/public/sw.js (Service Worker basique)
-- [ ] client/src/main.jsx (setup React)
+- [x] client/vite.config.js (PWA plugin)
+- [x] client/public/manifest.json (via Vite PWA)
+- [x] client/public/sw.js (Service Worker auto-généré)
+- [x] client/src/main.jsx (setup React)
#### Composants UI
-- [ ] client/src/App.jsx
- - [ ] Layout principal
- - [ ] Connexion utilisateur (nom + groupe)
- - [ ] Affichage état connexion
+- [x] client/src/App.jsx
+ - [x] Layout principal
+ - [x] Connexion utilisateur (nom + groupe)
+ - [x] Affichage état connexion
-- [ ] client/src/components/PTTButton.jsx
- - [ ] Bouton PTT (maintenir pour parler)
- - [ ] États : idle / talking / listening
- - [ ] Feedback visuel (couleurs)
- - [ ] Feedback haptique (vibration)
+- [x] client/src/components/PTTButton.jsx
+ - [x] Bouton PTT (maintenir pour parler)
+ - [x] États : idle / talking / listening
+ - [x] Feedback visuel (couleurs)
+ - [x] Feedback haptique (vibration)
-- [ ] client/src/components/UserList.jsx
- - [ ] Liste participants groupe actif
- - [ ] Indicateur qui parle (temps réel)
+- [x] client/src/components/UserList.jsx
+ - [x] Liste participants groupe actif
+ - [x] Indicateur qui parle (temps réel)
-- [ ] client/src/components/AudioIndicator.jsx
- - [ ] Niveau audio entrant (VU-mètre simple)
- - [ ] Niveau micro sortant
+- [x] client/src/components/AudioIndicator.jsx
+ - [x] Niveau audio entrant (VU-mètre simple)
+ - [x] Niveau micro sortant
#### Hooks WebRTC
-- [ ] client/src/hooks/useLiveKit.js
- - [ ] Connexion room (token serveur)
- - [ ] Publish microphone
- - [ ] Subscribe participants
- - [ ] Gestion événements (participant join/leave)
- - [ ] Cleanup disconnect
+- [x] client/src/hooks/useLiveKit.js
+ - [x] Connexion room (token serveur)
+ - [x] Publish microphone
+ - [x] Subscribe participants
+ - [x] Gestion événements (participant join/leave)
+ - [x] Cleanup disconnect
-- [ ] client/src/hooks/usePTT.js
- - [ ] Mode PTT : enable/disable track selon bouton
- - [ ] Gestion touch events (mobile)
- - [ ] Gestion mouse events (desktop)
+- [x] PTT intégré dans PTTButton.jsx
+ - [x] Mode PTT : mute/unmute track selon bouton
+ - [x] Gestion touch events (mobile)
+ - [x] Gestion mouse events (desktop)
#### Styles
-- [ ] CSS mobile-first
-- [ ] Design bouton PTT (large, accessible)
-- [ ] Mode sombre (défaut)
+- [x] CSS mobile-first
+- [x] Design bouton PTT (large, accessible)
+- [x] Mode sombre (défaut)
---
diff --git a/client/.env.example b/client/.env.example
new file mode 100644
index 0000000..45f7fed
--- /dev/null
+++ b/client/.env.example
@@ -0,0 +1,7 @@
+# PTT Live Client - Configuration environnement
+
+# URL API serveur (en dev, utilise le proxy Vite)
+VITE_API_URL=/api
+
+# Pour production, pointer vers le serveur
+# VITE_API_URL=https://your-server.com
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..d2bd804
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+ PTT Live
+
+
+
+
+
+
diff --git a/client/src/App.css b/client/src/App.css
new file mode 100644
index 0000000..93c0669
--- /dev/null
+++ b/client/src/App.css
@@ -0,0 +1,173 @@
+/* PTT Live - Styles composants principaux */
+
+.app {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--color-bg);
+}
+
+/* === Login === */
+.login-container {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-lg);
+}
+
+.login-card {
+ width: 100%;
+ max-width: 400px;
+ padding: var(--spacing-xl);
+ background: var(--color-surface);
+ border-radius: 16px;
+ border: 1px solid var(--color-border);
+}
+
+.app-title {
+ font-size: 2.5rem;
+ font-weight: 700;
+ text-align: center;
+ margin-bottom: var(--spacing-xs);
+ background: linear-gradient(135deg, var(--color-primary), var(--color-success));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.app-subtitle {
+ text-align: center;
+ color: var(--color-text-secondary);
+ margin-bottom: var(--spacing-xl);
+ font-size: 0.9rem;
+}
+
+.error-message {
+ padding: var(--spacing-md);
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid var(--color-danger);
+ border-radius: 8px;
+ color: var(--color-danger);
+ margin-bottom: var(--spacing-lg);
+ font-size: 0.9rem;
+}
+
+.form-group {
+ margin-bottom: var(--spacing-lg);
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: var(--spacing-sm);
+ color: var(--color-text-secondary);
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: var(--spacing-md);
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ color: var(--color-text);
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.form-group input:disabled,
+.form-group select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ width: 100%;
+ padding: var(--spacing-md);
+ background: var(--color-primary);
+ color: white;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 1rem;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--color-primary-hover);
+}
+
+/* === Header === */
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: var(--color-surface);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.header-info h2 {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin-bottom: var(--spacing-xs);
+}
+
+.text-secondary {
+ color: var(--color-text-secondary);
+ font-size: 0.85rem;
+}
+
+.btn-disconnect {
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--color-surface-hover);
+ color: var(--color-text-secondary);
+ border-radius: 6px;
+ font-size: 0.9rem;
+}
+
+.btn-disconnect:hover {
+ background: var(--color-border);
+}
+
+/* === Main === */
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* === Responsive === */
+@media (max-width: 640px) {
+ .login-card {
+ padding: var(--spacing-lg);
+ }
+
+ .app-title {
+ font-size: 2rem;
+ }
+}
+
+/* Mode paysage mobile */
+@media (max-height: 500px) and (orientation: landscape) {
+ .login-card {
+ max-width: 600px;
+ padding: var(--spacing-md);
+ }
+
+ .app-title {
+ font-size: 1.5rem;
+ }
+
+ .form-group {
+ margin-bottom: var(--spacing-md);
+ }
+}
diff --git a/client/src/App.jsx b/client/src/App.jsx
new file mode 100644
index 0000000..d6b546e
--- /dev/null
+++ b/client/src/App.jsx
@@ -0,0 +1,186 @@
+import { useState, useEffect } from 'react';
+import useLiveKit from './hooks/useLiveKit';
+import PTTButton from './components/PTTButton';
+import UserList from './components/UserList';
+import AudioIndicator from './components/AudioIndicator';
+import './App.css';
+
+const API_URL = import.meta.env.VITE_API_URL || '/api';
+
+function App() {
+ const [username, setUsername] = useState('');
+ const [groupId, setGroupId] = useState('');
+ const [groups, setGroups] = useState([]);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const {
+ isConnected,
+ participants,
+ isTalking,
+ audioLevel,
+ connect,
+ disconnect,
+ startTalking,
+ stopTalking
+ } = useLiveKit();
+
+ // Charger configuration au démarrage
+ useEffect(() => {
+ fetch(`${API_URL}/config`)
+ .then(res => res.json())
+ .then(data => {
+ setGroups(data.groups || []);
+ if (data.groups.length > 0) {
+ setGroupId(data.groups[0].id);
+ }
+ })
+ .catch(err => {
+ console.error('Erreur chargement config:', err);
+ setError('Impossible de charger la configuration');
+ });
+ }, []);
+
+ const handleConnect = async () => {
+ if (!username.trim()) {
+ setError('Veuillez entrer votre nom');
+ return;
+ }
+
+ if (!groupId) {
+ setError('Veuillez sélectionner un groupe');
+ return;
+ }
+
+ setIsConnecting(true);
+ setError(null);
+
+ try {
+ // Obtenir token du serveur
+ const response = await fetch(`${API_URL}/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, groupId })
+ });
+
+ if (!response.ok) {
+ throw new Error('Erreur serveur');
+ }
+
+ const data = await response.json();
+
+ // Se connecter à LiveKit
+ await connect(data.url, data.token);
+
+ } catch (err) {
+ console.error('Erreur connexion:', err);
+ setError('Connexion impossible. Vérifiez le serveur.');
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
+ const handleDisconnect = () => {
+ disconnect();
+ setError(null);
+ };
+
+ // Interface de connexion
+ if (!isConnected) {
+ return (
+
+
+
+
PTT Live
+
Professional Intercom
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ setUsername(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && handleConnect()}
+ disabled={isConnecting}
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Interface principale PTT
+ return (
+
+
+
+
+ {/* Liste des participants */}
+
+
+ {/* Indicateur audio */}
+
+
+ {/* Bouton PTT principal */}
+
+
+
+ );
+}
+
+export default App;
diff --git a/client/src/components/AudioIndicator.css b/client/src/components/AudioIndicator.css
new file mode 100644
index 0000000..2a47385
--- /dev/null
+++ b/client/src/components/AudioIndicator.css
@@ -0,0 +1,121 @@
+/* AudioIndicator - VU-mètre audio */
+
+.audio-indicator-container {
+ padding: var(--spacing-lg);
+ background: var(--color-surface);
+ border-radius: 12px;
+ margin: 0 var(--spacing-lg);
+}
+
+.audio-indicator-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-sm);
+ font-size: 0.85rem;
+}
+
+.audio-indicator-label span:first-child {
+ color: var(--color-text-secondary);
+}
+
+.audio-level-value {
+ font-weight: 600;
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Barre de progression */
+.audio-indicator-bar {
+ width: 100%;
+ height: 6px;
+ background: var(--color-bg);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: var(--spacing-md);
+}
+
+.audio-indicator-fill {
+ height: 100%;
+ background: var(--color-success);
+ transition: width 0.1s ease, background 0.2s;
+ border-radius: 3px;
+}
+
+.audio-indicator-fill.talking {
+ background: var(--color-primary);
+}
+
+/* VU-mètre bars */
+.audio-bars {
+ display: flex;
+ gap: 3px;
+ height: 40px;
+ align-items: flex-end;
+}
+
+.audio-bar {
+ flex: 1;
+ background: var(--color-bg);
+ border-radius: 2px;
+ transition: all 0.1s ease;
+ min-height: 4px;
+ height: 20%;
+}
+
+.audio-bar.active {
+ height: 100%;
+ background: var(--color-success);
+}
+
+.audio-bar.active.warning {
+ background: var(--color-warning);
+}
+
+.audio-bar.active.danger {
+ background: var(--color-danger);
+ animation: blink 0.5s ease-in-out infinite;
+}
+
+@keyframes blink {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .audio-indicator-container {
+ padding: var(--spacing-md);
+ margin: 0 var(--spacing-md);
+ }
+
+ .audio-bars {
+ height: 32px;
+ }
+
+ .audio-indicator-label {
+ font-size: 0.8rem;
+ }
+}
+
+/* Mode paysage */
+@media (max-height: 500px) and (orientation: landscape) {
+ .audio-indicator-container {
+ padding: var(--spacing-sm);
+ margin: 0 var(--spacing-md);
+ }
+
+ .audio-bars {
+ height: 24px;
+ gap: 2px;
+ }
+
+ .audio-indicator-label {
+ font-size: 0.75rem;
+ margin-bottom: var(--spacing-xs);
+ }
+}
diff --git a/client/src/components/AudioIndicator.jsx b/client/src/components/AudioIndicator.jsx
new file mode 100644
index 0000000..ae5170e
--- /dev/null
+++ b/client/src/components/AudioIndicator.jsx
@@ -0,0 +1,44 @@
+import './AudioIndicator.css';
+
+/**
+ * VU-mètre simple pour visualiser le niveau audio
+ */
+export default function AudioIndicator({ level, isTalking }) {
+ // Normaliser niveau 0-100
+ const normalizedLevel = Math.min(100, Math.max(0, level));
+
+ return (
+
+
+ {isTalking ? 'Votre micro' : 'Audio entrant'}
+ {Math.round(normalizedLevel)}%
+
+
+
+
+ {/* Bars VU-mètre style */}
+
+ {[...Array(20)].map((_, i) => {
+ const threshold = (i + 1) * 5;
+ const isActive = normalizedLevel >= threshold;
+ const isWarning = i >= 15; // > 75%
+ const isDanger = i >= 18; // > 90%
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/components/PTTButton.css b/client/src/components/PTTButton.css
new file mode 100644
index 0000000..ee331a6
--- /dev/null
+++ b/client/src/components/PTTButton.css
@@ -0,0 +1,144 @@
+/* PTTButton - Bouton principal Push-To-Talk */
+
+.ptt-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-xl);
+ gap: var(--spacing-lg);
+}
+
+.ptt-button {
+ width: 240px;
+ height: 240px;
+ border-radius: 50%;
+ background: var(--color-ptt-idle);
+ color: white;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-md);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ position: relative;
+ overflow: hidden;
+}
+
+.ptt-button::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: radial-gradient(circle at center, rgba(255, 255, 255, 0.1), transparent);
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.ptt-button:active::before {
+ opacity: 1;
+}
+
+.ptt-button:active {
+ transform: scale(0.95);
+}
+
+/* État: En train de parler */
+.ptt-button.talking {
+ background: var(--color-ptt-talking);
+ box-shadow: 0 8px 32px rgba(239, 68, 68, 0.5),
+ 0 0 60px rgba(239, 68, 68, 0.3);
+ animation: pulse-talking 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-talking {
+ 0%, 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.05);
+ }
+}
+
+/* Icône micro */
+.ptt-icon {
+ width: 64px;
+ height: 64px;
+ color: white;
+}
+
+.ptt-icon svg {
+ width: 100%;
+ height: 100%;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+}
+
+/* Label */
+.ptt-label {
+ font-size: 1rem;
+ font-weight: 600;
+ text-align: center;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ max-width: 180px;
+}
+
+/* Hint text */
+.ptt-hint {
+ color: var(--color-text-secondary);
+ font-size: 0.9rem;
+ text-align: center;
+}
+
+/* Responsive mobile */
+@media (max-width: 640px) {
+ .ptt-button {
+ width: 200px;
+ height: 200px;
+ }
+
+ .ptt-icon {
+ width: 56px;
+ height: 56px;
+ }
+
+ .ptt-label {
+ font-size: 0.9rem;
+ }
+}
+
+/* Mode paysage */
+@media (max-height: 500px) and (orientation: landscape) {
+ .ptt-container {
+ padding: var(--spacing-md);
+ }
+
+ .ptt-button {
+ width: 160px;
+ height: 160px;
+ }
+
+ .ptt-icon {
+ width: 48px;
+ height: 48px;
+ }
+
+ .ptt-label {
+ font-size: 0.8rem;
+ }
+
+ .ptt-hint {
+ font-size: 0.8rem;
+ }
+}
+
+/* Accessibilité : désactiver effets réduits */
+@media (prefers-reduced-motion: reduce) {
+ .ptt-button,
+ .ptt-button.talking {
+ animation: none;
+ transition: none;
+ }
+}
diff --git a/client/src/components/PTTButton.jsx b/client/src/components/PTTButton.jsx
new file mode 100644
index 0000000..f7a4f98
--- /dev/null
+++ b/client/src/components/PTTButton.jsx
@@ -0,0 +1,114 @@
+import { useEffect, useRef } from 'react';
+import './PTTButton.css';
+
+/**
+ * Bouton PTT principal
+ * Gère touch et mouse events pour desktop et mobile
+ */
+export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
+ const buttonRef = useRef(null);
+ const isPressingRef = useRef(false);
+
+ useEffect(() => {
+ const button = buttonRef.current;
+ if (!button) return;
+
+ // Empêcher comportements par défaut
+ const preventDefault = (e) => {
+ e.preventDefault();
+ };
+
+ // Touch events (mobile)
+ const handleTouchStart = (e) => {
+ e.preventDefault();
+ if (!isPressingRef.current) {
+ isPressingRef.current = true;
+ onPressStart();
+ }
+ };
+
+ const handleTouchEnd = (e) => {
+ e.preventDefault();
+ if (isPressingRef.current) {
+ isPressingRef.current = false;
+ onPressEnd();
+ }
+ };
+
+ // Mouse events (desktop)
+ const handleMouseDown = (e) => {
+ e.preventDefault();
+ if (!isPressingRef.current) {
+ isPressingRef.current = true;
+ onPressStart();
+ }
+ };
+
+ const handleMouseUp = (e) => {
+ e.preventDefault();
+ if (isPressingRef.current) {
+ isPressingRef.current = false;
+ onPressEnd();
+ }
+ };
+
+ const handleMouseLeave = (e) => {
+ // Si on quitte le bouton en maintenant, on arrête
+ if (isPressingRef.current) {
+ isPressingRef.current = false;
+ onPressEnd();
+ }
+ };
+
+ // Attacher events
+ button.addEventListener('touchstart', handleTouchStart, { passive: false });
+ button.addEventListener('touchend', handleTouchEnd, { passive: false });
+ button.addEventListener('touchcancel', handleTouchEnd, { passive: false });
+ button.addEventListener('mousedown', handleMouseDown);
+ button.addEventListener('mouseup', handleMouseUp);
+ button.addEventListener('mouseleave', handleMouseLeave);
+ button.addEventListener('contextmenu', preventDefault);
+
+ return () => {
+ button.removeEventListener('touchstart', handleTouchStart);
+ button.removeEventListener('touchend', handleTouchEnd);
+ button.removeEventListener('touchcancel', handleTouchEnd);
+ button.removeEventListener('mousedown', handleMouseDown);
+ button.removeEventListener('mouseup', handleMouseUp);
+ button.removeEventListener('mouseleave', handleMouseLeave);
+ button.removeEventListener('contextmenu', preventDefault);
+ };
+ }, [onPressStart, onPressEnd]);
+
+ return (
+
+
+
+
+ {isTalking ? 'Relâchez pour arrêter' : 'Appuyez et maintenez le bouton'}
+
+
+ );
+}
diff --git a/client/src/components/UserList.css b/client/src/components/UserList.css
new file mode 100644
index 0000000..09665ea
--- /dev/null
+++ b/client/src/components/UserList.css
@@ -0,0 +1,150 @@
+/* UserList - Liste des participants */
+
+.user-list {
+ background: var(--color-surface);
+ border-bottom: 1px solid var(--color-border);
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+.user-list.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-lg);
+ max-height: none;
+}
+
+.empty-message {
+ color: var(--color-text-secondary);
+ font-size: 0.9rem;
+}
+
+.user-list-header {
+ padding: var(--spacing-sm) var(--spacing-lg);
+ background: var(--color-bg);
+ border-bottom: 1px solid var(--color-border);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.user-count {
+ font-size: 0.85rem;
+ color: var(--color-text-secondary);
+ font-weight: 500;
+}
+
+.user-list-items {
+ padding: var(--spacing-sm);
+}
+
+/* Item utilisateur */
+.user-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ border-radius: 8px;
+ transition: background 0.15s;
+}
+
+.user-item:hover {
+ background: var(--color-surface-hover);
+}
+
+.user-item.speaking {
+ background: rgba(16, 185, 129, 0.1);
+}
+
+/* Avatar */
+.user-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--color-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 1.1rem;
+ flex-shrink: 0;
+}
+
+.user-item.speaking .user-avatar {
+ background: var(--color-success);
+}
+
+/* Info */
+.user-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.user-name {
+ font-weight: 500;
+ font-size: 0.95rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.user-status {
+ font-size: 0.8rem;
+ color: var(--color-success);
+ font-weight: 500;
+}
+
+/* Indicateurs */
+.user-indicator {
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+}
+
+.speaking-indicator {
+ width: 100%;
+ height: 100%;
+ color: var(--color-success);
+}
+
+.speaking-indicator svg {
+ width: 100%;
+ height: 100%;
+}
+
+.audio-indicator {
+ width: 100%;
+ height: 100%;
+ color: var(--color-text-secondary);
+ opacity: 0.5;
+}
+
+.audio-indicator svg {
+ width: 100%;
+ height: 100%;
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .user-list {
+ max-height: 150px;
+ }
+
+ .user-avatar {
+ width: 36px;
+ height: 36px;
+ font-size: 1rem;
+ }
+
+ .user-name {
+ font-size: 0.9rem;
+ }
+
+ .user-status {
+ font-size: 0.75rem;
+ }
+}
diff --git a/client/src/components/UserList.jsx b/client/src/components/UserList.jsx
new file mode 100644
index 0000000..8e6b684
--- /dev/null
+++ b/client/src/components/UserList.jsx
@@ -0,0 +1,68 @@
+import './UserList.css';
+
+/**
+ * Liste des participants connectés
+ */
+export default function UserList({ participants }) {
+ if (participants.length === 0) {
+ return (
+
+
Aucun autre participant
+
+ );
+ }
+
+ return (
+
+
+
+ {participants.length} participant{participants.length > 1 ? 's' : ''}
+
+
+
+
+ {participants.map((participant) => (
+
+
+ {participant.name.charAt(0).toUpperCase()}
+
+
+
+ {participant.name}
+ {participant.isSpeaking && (
+ En train de parler
+ )}
+
+
+
+ {participant.isSpeaking ? (
+
+ ) : (
+
+ {participant.hasAudio ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/hooks/useLiveKit.js b/client/src/hooks/useLiveKit.js
new file mode 100644
index 0000000..678fea7
--- /dev/null
+++ b/client/src/hooks/useLiveKit.js
@@ -0,0 +1,265 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { Room, RoomEvent, Track } from 'livekit-client';
+
+/**
+ * Hook pour gérer la connexion et l'état LiveKit
+ */
+export default function useLiveKit() {
+ const [isConnected, setIsConnected] = useState(false);
+ const [participants, setParticipants] = useState([]);
+ const [isTalking, setIsTalking] = useState(false);
+ const [audioLevel, setAudioLevel] = useState(0);
+
+ const roomRef = useRef(null);
+ const localTrackRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const analyserRef = useRef(null);
+ const animationFrameRef = useRef(null);
+
+ /**
+ * Connexion à la room LiveKit
+ */
+ const connect = useCallback(async (url, token) => {
+ try {
+ // Créer room
+ const room = new Room({
+ adaptiveStream: true,
+ dynacast: true,
+ audioCaptureDefaults: {
+ autoGainControl: true,
+ echoCancellation: true,
+ noiseSuppression: true,
+ }
+ });
+
+ roomRef.current = room;
+
+ // Events
+ room.on(RoomEvent.Connected, () => {
+ console.log('✓ Connecté à LiveKit');
+ setIsConnected(true);
+ });
+
+ room.on(RoomEvent.Disconnected, () => {
+ console.log('✗ Déconnecté de LiveKit');
+ setIsConnected(false);
+ cleanup();
+ });
+
+ room.on(RoomEvent.ParticipantConnected, (participant) => {
+ console.log('Participant rejoint:', participant.identity);
+ updateParticipants();
+ });
+
+ room.on(RoomEvent.ParticipantDisconnected, (participant) => {
+ console.log('Participant parti:', participant.identity);
+ updateParticipants();
+ });
+
+ room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
+ console.log('Track reçu:', track.kind, 'de', participant.identity);
+ updateParticipants();
+
+ // Auto-play audio
+ if (track.kind === Track.Kind.Audio) {
+ const audioElement = track.attach();
+ document.body.appendChild(audioElement);
+ }
+ });
+
+ room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
+ console.log('Track retiré:', track.kind, 'de', participant.identity);
+ track.detach().forEach(el => el.remove());
+ updateParticipants();
+ });
+
+ room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
+ updateParticipants();
+ });
+
+ // Connexion
+ await room.connect(url, token);
+
+ // Activer microphone (muted par défaut)
+ await room.localParticipant.setMicrophoneEnabled(true);
+ const track = room.localParticipant.audioTracks.values().next().value?.track;
+
+ if (track) {
+ localTrackRef.current = track;
+ // Mute par défaut (PTT)
+ track.mute();
+ setupAudioAnalyser(track);
+ }
+
+ updateParticipants();
+
+ } catch (error) {
+ console.error('Erreur connexion LiveKit:', error);
+ throw error;
+ }
+ }, []);
+
+ /**
+ * Déconnexion
+ */
+ const disconnect = useCallback(() => {
+ cleanup();
+ if (roomRef.current) {
+ roomRef.current.disconnect();
+ roomRef.current = null;
+ }
+ setIsConnected(false);
+ setParticipants([]);
+ }, []);
+
+ /**
+ * Commencer à parler (unmute micro)
+ */
+ const startTalking = useCallback(async () => {
+ if (!localTrackRef.current) return;
+
+ try {
+ await localTrackRef.current.unmute();
+ setIsTalking(true);
+ console.log('🎤 PTT: Talking');
+
+ // Vibration haptique (si supporté)
+ if (navigator.vibrate) {
+ navigator.vibrate(50);
+ }
+ } catch (error) {
+ console.error('Erreur unmute:', error);
+ }
+ }, []);
+
+ /**
+ * Arrêter de parler (mute micro)
+ */
+ const stopTalking = useCallback(async () => {
+ if (!localTrackRef.current) return;
+
+ try {
+ await localTrackRef.current.mute();
+ setIsTalking(false);
+ console.log('🎤 PTT: Listening');
+
+ // Vibration haptique (si supporté)
+ if (navigator.vibrate) {
+ navigator.vibrate(30);
+ }
+ } catch (error) {
+ console.error('Erreur mute:', error);
+ }
+ }, []);
+
+ /**
+ * Mise à jour liste participants
+ */
+ const updateParticipants = () => {
+ if (!roomRef.current) return;
+
+ const room = roomRef.current;
+ const participantsList = [];
+
+ // Participants distants
+ room.remoteParticipants.forEach((participant) => {
+ const audioPublication = Array.from(participant.audioTracks.values())[0];
+ const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity);
+
+ participantsList.push({
+ identity: participant.identity,
+ name: participant.name || participant.identity,
+ isLocal: false,
+ isSpeaking,
+ hasAudio: audioPublication?.isSubscribed || false
+ });
+ });
+
+ setParticipants(participantsList);
+ };
+
+ /**
+ * Setup analyseur audio pour VU-mètre
+ */
+ const setupAudioAnalyser = (track) => {
+ try {
+ const mediaStream = track.mediaStream;
+ if (!mediaStream) return;
+
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ const analyser = audioContext.createAnalyser();
+ const source = audioContext.createMediaStreamSource(mediaStream);
+
+ analyser.fftSize = 256;
+ source.connect(analyser);
+
+ audioContextRef.current = audioContext;
+ analyserRef.current = analyser;
+
+ // Démarrer analyse
+ analyseAudioLevel();
+ } catch (error) {
+ console.error('Erreur setup analyser:', error);
+ }
+ };
+
+ /**
+ * Analyser niveau audio (pour VU-mètre)
+ */
+ const analyseAudioLevel = () => {
+ if (!analyserRef.current) return;
+
+ const analyser = analyserRef.current;
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
+
+ const analyse = () => {
+ analyser.getByteFrequencyData(dataArray);
+
+ // Calculer moyenne
+ const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
+ const normalized = Math.min(100, (average / 255) * 100);
+
+ setAudioLevel(normalized);
+
+ animationFrameRef.current = requestAnimationFrame(analyse);
+ };
+
+ analyse();
+ };
+
+ /**
+ * Cleanup
+ */
+ const cleanup = () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = null;
+ }
+
+ if (audioContextRef.current) {
+ audioContextRef.current.close();
+ audioContextRef.current = null;
+ }
+
+ analyserRef.current = null;
+ localTrackRef.current = null;
+ };
+
+ // Cleanup au démontage
+ useEffect(() => {
+ return () => {
+ disconnect();
+ };
+ }, [disconnect]);
+
+ return {
+ isConnected,
+ participants,
+ isTalking,
+ audioLevel,
+ connect,
+ disconnect,
+ startTalking,
+ stopTalking
+ };
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..6b2b335
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,166 @@
+/* PTT Live - Styles globaux */
+
+:root {
+ /* Couleurs */
+ --color-bg: #0a0a0a;
+ --color-surface: #1a1a1a;
+ --color-surface-hover: #252525;
+ --color-border: #333;
+ --color-text: #ffffff;
+ --color-text-secondary: #999;
+ --color-primary: #3b82f6;
+ --color-primary-hover: #2563eb;
+ --color-success: #10b981;
+ --color-warning: #f59e0b;
+ --color-danger: #ef4444;
+
+ /* PTT States */
+ --color-ptt-idle: #374151;
+ --color-ptt-talking: #ef4444;
+ --color-ptt-listening: #10b981;
+
+ /* Spacing */
+ --spacing-xs: 0.25rem;
+ --spacing-sm: 0.5rem;
+ --spacing-md: 1rem;
+ --spacing-lg: 1.5rem;
+ --spacing-xl: 2rem;
+
+ /* Fonts */
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.5;
+}
+
+#root {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Désactiver la sélection sur les éléments interactifs */
+button,
+.no-select {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+/* Styles des boutons */
+button {
+ font-family: inherit;
+ font-size: 1rem;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+button:active {
+ transform: scale(0.98);
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Scrollbars sombres */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-surface);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-secondary);
+}
+
+/* Animations */
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes slideInUp {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+/* Utilitaires */
+.text-center {
+ text-align: center;
+}
+
+.flex {
+ display: flex;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.gap-sm {
+ gap: var(--spacing-sm);
+}
+
+.gap-md {
+ gap: var(--spacing-md);
+}
+
+.gap-lg {
+ gap: var(--spacing-lg);
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
new file mode 100644
index 0000000..7497ae8
--- /dev/null
+++ b/client/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App.jsx';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/client/vite.config.js b/client/vite.config.js
new file mode 100644
index 0000000..f312237
--- /dev/null
+++ b/client/vite.config.js
@@ -0,0 +1,72 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { VitePWA } from 'vite-plugin-pwa';
+
+export default defineConfig({
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
+ manifest: {
+ name: 'PTT Live',
+ short_name: 'PTT Live',
+ description: 'Professional WebRTC Intercom for Event Technicians',
+ theme_color: '#1a1a1a',
+ background_color: '#1a1a1a',
+ display: 'standalone',
+ scope: '/',
+ start_url: '/',
+ orientation: 'portrait',
+ icons: [
+ {
+ src: 'pwa-192x192.png',
+ sizes: '192x192',
+ type: 'image/png'
+ },
+ {
+ src: 'pwa-512x512.png',
+ sizes: '512x512',
+ type: 'image/png'
+ },
+ {
+ src: 'pwa-512x512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'any maskable'
+ }
+ ]
+ },
+ workbox: {
+ runtimeCaching: [
+ {
+ urlPattern: /^https:\/\/.*\.livekit\.cloud\/.*/i,
+ handler: 'NetworkFirst',
+ options: {
+ cacheName: 'livekit-cache',
+ expiration: {
+ maxEntries: 10,
+ maxAgeSeconds: 60 * 60 * 24 // 24 hours
+ }
+ }
+ }
+ ]
+ }
+ })
+ ],
+ server: {
+ port: 5173,
+ host: true,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, '')
+ }
+ }
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true
+ }
+});