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