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