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
+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>
);
}