feat: implement complete React PWA client with LiveKit integration

Client React complet avec intégration LiveKit et interface PTT professionnelle :

Infrastructure :
- Configuration Vite avec plugin PWA (Service Worker auto-généré)
- Proxy API vers serveur backend
- Build optimisé et PWA manifest

Composants UI :
- App.jsx : écran connexion + interface principale PTT
- PTTButton : bouton push-to-talk avec gestion touch/mouse events
- UserList : liste participants temps réel avec indicateurs
- AudioIndicator : VU-mètre avec visualisation niveau audio

Fonctionnalités WebRTC :
- Hook useLiveKit : connexion room, publish/subscribe, events
- Gestion micro avec mute/unmute (mode PTT)
- Auto-play audio participants distants
- Analyseur audio pour VU-mètre
- Feedback haptique (vibrations)

Design :
- Mode sombre par défaut
- Responsive mobile-first
- Animations fluides et accessibles
- Support paysage mobile

Phase 1.4 complétée : Client PWA opérationnel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:48:18 +02:00
parent 5e74f0dcdf
commit 0640a9f0b6
15 changed files with 1568 additions and 32 deletions
+173
View File
@@ -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);
}
}
+186
View File
@@ -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 (
<div className="app">
<div className="login-container">
<div className="login-card">
<h1 className="app-title">PTT Live</h1>
<p className="app-subtitle">Professional Intercom</p>
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="username">Nom</label>
<input
id="username"
type="text"
placeholder="Votre nom"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleConnect()}
disabled={isConnecting}
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="group">Groupe</label>
<select
id="group"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
disabled={isConnecting || groups.length === 0}
>
{groups.length === 0 ? (
<option>Chargement...</option>
) : (
groups.map(g => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))
)}
</select>
</div>
<button
className="btn-primary"
onClick={handleConnect}
disabled={isConnecting || !username.trim() || !groupId}
>
{isConnecting ? 'Connexion...' : 'Se connecter'}
</button>
</div>
</div>
</div>
);
}
// Interface principale PTT
return (
<div className="app">
<header className="app-header">
<div className="header-info">
<h2>{username}</h2>
<p className="text-secondary">
{groups.find(g => g.id === groupId)?.name || groupId}
</p>
</div>
<button
className="btn-disconnect"
onClick={handleDisconnect}
>
Déconnexion
</button>
</header>
<main className="app-main">
{/* Liste des participants */}
<UserList participants={participants} />
{/* Indicateur audio */}
<AudioIndicator level={audioLevel} isTalking={isTalking} />
{/* Bouton PTT principal */}
<PTTButton
isTalking={isTalking}
onPressStart={startTalking}
onPressEnd={stopTalking}
/>
</main>
</div>
);
}
export default App;
+121
View File
@@ -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);
}
}
+44
View File
@@ -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 (
<div className="audio-indicator-container">
<div className="audio-indicator-label">
<span>{isTalking ? 'Votre micro' : 'Audio entrant'}</span>
<span className="audio-level-value">{Math.round(normalizedLevel)}%</span>
</div>
<div className="audio-indicator-bar">
<div
className={`audio-indicator-fill ${isTalking ? 'talking' : ''}`}
style={{ width: `${normalizedLevel}%` }}
/>
</div>
{/* Bars VU-mètre style */}
<div className="audio-bars">
{[...Array(20)].map((_, i) => {
const threshold = (i + 1) * 5;
const isActive = normalizedLevel >= threshold;
const isWarning = i >= 15; // > 75%
const isDanger = i >= 18; // > 90%
return (
<div
key={i}
className={`audio-bar ${isActive ? 'active' : ''} ${
isActive && isDanger ? 'danger' : isActive && isWarning ? 'warning' : ''
}`}
/>
);
})}
</div>
</div>
);
}
+144
View File
@@ -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;
}
}
+114
View File
@@ -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 (
<div className="ptt-container">
<button
ref={buttonRef}
className={`ptt-button ${isTalking ? 'talking' : ''}`}
type="button"
>
<div className="ptt-icon">
{isTalking ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
<path d="M19 11h2v2h-2zm-16 0h2v2H3z"/>
</svg>
)}
</div>
<span className="ptt-label">
{isTalking ? 'En cours...' : 'Maintenir pour parler'}
</span>
</button>
<p className="ptt-hint">
{isTalking ? 'Relâchez pour arrêter' : 'Appuyez et maintenez le bouton'}
</p>
</div>
);
}
+150
View File
@@ -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;
}
}
+68
View File
@@ -0,0 +1,68 @@
import './UserList.css';
/**
* Liste des participants connectés
*/
export default function UserList({ participants }) {
if (participants.length === 0) {
return (
<div className="user-list empty">
<p className="empty-message">Aucun autre participant</p>
</div>
);
}
return (
<div className="user-list">
<div className="user-list-header">
<span className="user-count">
{participants.length} participant{participants.length > 1 ? 's' : ''}
</span>
</div>
<div className="user-list-items">
{participants.map((participant) => (
<div
key={participant.identity}
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
>
<div className="user-avatar">
{participant.name.charAt(0).toUpperCase()}
</div>
<div className="user-info">
<span className="user-name">{participant.name}</span>
{participant.isSpeaking && (
<span className="user-status">En train de parler</span>
)}
</div>
<div className="user-indicator">
{participant.isSpeaking ? (
<div className="speaking-indicator pulse">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</div>
) : (
<div className="audio-indicator">
{participant.hasAudio ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/>
</svg>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}
+265
View File
@@ -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
};
}
+166
View File
@@ -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);
}
+10
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);