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); const isAudioUnlockedRef = useRef(false); const virtualChannelsRef = useRef([]); const mutedChannelsRef = useRef(new Set()); // IDs des canaux muted // Analyseur audio pour pistes distantes (audio entrant) const remoteAudioContextRef = useRef(null); const remoteAnalyserRef = useRef(null); const remoteAnimationFrameRef = useRef(null); /** * Connexion à la room LiveKit */ const connect = useCallback(async (url, token, virtualChannels = []) => { try { // Stocker les canaux virtuels virtualChannelsRef.current = virtualChannels; // 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'); console.log(' Room name:', room.name); console.log(' Participants distants:', room.remoteParticipants.size); 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); console.log(' Total participants distants:', room.remoteParticipants.size); 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); // Setup analyseur pour audio entrant setupRemoteAudioAnalyser(track); } }); 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(); }); // Event track local publié room.on(RoomEvent.LocalTrackPublished, (publication) => { console.log('✓ Track local publié:', publication.kind); if (publication.kind === Track.Kind.Audio) { const track = publication.track; console.log(' Track audio disponible:', track); console.log(' isMuted:', track.isMuted); localTrackRef.current = track; // Mute par défaut (PTT) track.mute(); setupAudioAnalyser(track); // Démarrer l'analyse audio analyseAudioLevel(); console.log('✓ Track audio configuré et muted pour PTT'); } }); // Connexion await room.connect(url, token); console.log('📞 Connexion établie, activation microphone...'); // Activer microphone (muted par défaut) await room.localParticipant.setMicrophoneEnabled(true); console.log('🎤 Microphone activé, attente publication track...'); // Attendre que le track soit publié (max 3s) let retries = 0; while (!localTrackRef.current && retries < 30) { await new Promise(resolve => setTimeout(resolve, 100)); retries++; } if (!localTrackRef.current) { console.error('❌ Timeout : track audio non publié après 3s'); throw new Error('Microphone non disponible. Autorisez l\'accès au micro dans les réglages iOS.'); } console.log('✓ Track audio prêt'); 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([]); }, []); /** * Changer de groupe (reconnexion à une nouvelle room) */ const switchGroup = useCallback(async (url, token, virtualChannels = []) => { console.log('🔄 Changement de groupe...'); // Déconnexion propre cleanup(); if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } setIsConnected(false); setParticipants([]); // Reset canaux muted mutedChannelsRef.current.clear(); // Reconnexion avec nouveau token await connect(url, token, virtualChannels); }, [connect]); /** * Débloque l'audio sur mobile (iOS/Android) * Doit être appelé dans un gestionnaire d'événement utilisateur */ const unlockAudio = useCallback(() => { if (isAudioUnlockedRef.current) return; try { // Créer un contexte audio silencieux pour débloquer l'API const ctx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); gainNode.gain.value = 0; // Silence oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.start(0); oscillator.stop(0.001); isAudioUnlockedRef.current = true; console.log('✓ Audio débloqué (mobile)'); } catch (error) { console.warn('Audio unlock échoué:', error); } }, []); /** * Commencer à parler (unmute micro) */ const startTalking = useCallback(async () => { console.log('🎤 startTalking appelé'); console.log(' localTrackRef.current:', localTrackRef.current); if (!localTrackRef.current) { console.warn('⚠️ Pas de track audio local disponible'); alert('Microphone non disponible. Réessayez.'); return; } try { // Débloquer audio sur mobile au premier appui unlockAudio(); // Feedback immédiat AVANT unmute setIsTalking(true); await localTrackRef.current.unmute(); console.log('🎤 PTT: Talking (unmuted)'); // Vibration haptique (si supporté) if (navigator.vibrate) { navigator.vibrate(50); } } catch (error) { console.error('❌ Erreur unmute:', error); setIsTalking(false); alert(`Erreur microphone: ${error.message}`); } }, [unlockAudio]); /** * 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 (inclut canaux virtuels) */ const updateParticipants = useCallback(() => { if (!roomRef.current) return; const room = roomRef.current; const participantsList = []; // Canaux virtuels (affichés en premier) virtualChannelsRef.current.forEach((channel) => { participantsList.push({ identity: channel.id, name: channel.name, isLocal: false, isVirtual: true, isSpeaking: false, // TODO: détection audio depuis bridge hasAudio: true, isMuted: mutedChannelsRef.current.has(channel.id), audioInput: channel.audioInput, audioOutput: channel.audioOutput }); }); // Participants distants (utilisateurs WebRTC) room.remoteParticipants.forEach((participant) => { const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; const audioPublication = audioTracks[0]; const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity); participantsList.push({ identity: participant.identity, name: participant.name || participant.identity, isLocal: false, isVirtual: false, isSpeaking, hasAudio: audioPublication?.isSubscribed || false, isMuted: false }); }); setParticipants(participantsList); }, []); /** * Toggle mute/unmute d'un participant (canal virtuel ou utilisateur) */ const toggleParticipantMute = useCallback((participantId, isVirtual) => { if (isVirtual) { // Canal virtuel : toggle dans l'état local const isMuted = mutedChannelsRef.current.has(participantId); if (isMuted) { mutedChannelsRef.current.delete(participantId); console.log('🔊 Canal virtuel unmuted:', participantId); } else { mutedChannelsRef.current.add(participantId); console.log('🔇 Canal virtuel muted:', participantId); } // TODO Phase 3: Envoyer commande au bridge audio via DataChannel // pour vraiment muter/unmuter le canal physique // Mettre à jour l'affichage updateParticipants(); } else { // Utilisateur WebRTC : muter localement la lecture audio if (!roomRef.current) return; const participant = roomRef.current.remoteParticipants.get(participantId); if (!participant) return; const audioTracks = Array.from(participant.audioTracks.values()); const audioPublication = audioTracks[0]; if (audioPublication && audioPublication.audioTrack) { const track = audioPublication.audioTrack; const newMutedState = !track.isMuted; if (newMutedState) { track.mute(); console.log('🔇 Participant muted:', participantId); } else { track.unmute(); console.log('🔊 Participant unmuted:', participantId); } updateParticipants(); } } }, [updateParticipants]); /** * Setup analyseur audio pour VU-mètre (micro local) */ 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; console.log('✓ Analyseur audio local configuré'); } catch (error) { console.error('Erreur setup analyser local:', error); } }; /** * Setup analyseur audio pour pistes distantes (audio entrant) */ const setupRemoteAudioAnalyser = (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); remoteAudioContextRef.current = audioContext; remoteAnalyserRef.current = analyser; console.log('✓ Analyseur audio distant configuré'); } catch (error) { console.error('Erreur setup analyser distant:', error); } }; /** * Analyser niveau audio (pour VU-mètre) * Alterne entre micro local (si talking) et audio entrant (si listening) */ const analyseAudioLevel = useCallback(() => { const analyse = () => { // Choisir l'analyseur selon l'état const analyser = isTalking ? analyserRef.current : remoteAnalyserRef.current; if (analyser) { const dataArray = new Uint8Array(analyser.frequencyBinCount); 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); } else { setAudioLevel(0); } animationFrameRef.current = requestAnimationFrame(analyse); }; analyse(); }, [isTalking]); /** * Cleanup */ const cleanup = () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } if (remoteAnimationFrameRef.current) { cancelAnimationFrame(remoteAnimationFrameRef.current); remoteAnimationFrameRef.current = null; } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } if (remoteAudioContextRef.current) { remoteAudioContextRef.current.close(); remoteAudioContextRef.current = null; } analyserRef.current = null; remoteAnalyserRef.current = null; localTrackRef.current = null; }; // Redémarrer l'analyse audio quand isTalking change useEffect(() => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } // Redémarrer l'analyse si on a au moins un analyseur if (analyserRef.current || remoteAnalyserRef.current) { analyseAudioLevel(); } }, [isTalking, analyseAudioLevel]); // Cleanup au démontage useEffect(() => { return () => { disconnect(); }; }, [disconnect]); return { isConnected, participants, isTalking, audioLevel, connect, disconnect, switchGroup, startTalking, stopTalking, toggleParticipantMute }; }