import {defineStore} from 'pinia' export const useVoiceStore = defineStore('voice', { state: () => ({ channelId: null as string | null, serverId: null as string | null, connecting: false, error: null as string | null, }), actions: { async join(serverId: string, channelId: string) { if (this.channelId === channelId) return this.leave() this.connecting = true this.error = null const generation = ++voiceGeneration try { const stream = await navigator.mediaDevices.getUserMedia({audio: true}) if (generation !== voiceGeneration) { stream.getTracks().forEach(track => track.stop()) return } microphone = stream const connection = new RTCPeerConnection() peer = connection stream.getAudioTracks().forEach(track => connection.addTrack(track, stream)) const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(url) socket = ws this.channelId = channelId this.serverId = serverId const pendingCandidates: string[] = [] const tracks = new Map() connection.ontrack = ({track, streams}) => { if (peer !== connection) return const audio = new Audio() audio.autoplay = true audio.srcObject = new MediaStream([track]) audioElements.set(track, audio) if (streams[0]) tracks.set(streams[0].id, track) track.onended = () => removeAudio(track) void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' }) } connection.onicecandidate = ({candidate}) => { if (candidate && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate})) } } ws.onopen = async () => { try { const offer = await connection.createOffer() await connection.setLocalDescription(offer) if (socket === ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp})) } } catch (error) { if (socket === ws) { this.error = String(error); this.leave() } } } let queue = Promise.resolve() ws.onmessage = ({data}) => { queue = queue.then(async () => { if (socket !== ws) return const message = JSON.parse(data) if (message.action === 'answer') { await connection.setRemoteDescription({type: 'answer', sdp: message.sdp}) for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0}) pendingCandidates.length = 0 } else if (message.action === 'sdp-offer') { await connection.setRemoteDescription({type: 'offer', sdp: message.sdp}) const answer = await connection.createAnswer() await connection.setLocalDescription(answer) ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp})) } else if (message.action === 'ice-candidate') { if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0}) else pendingCandidates.push(message.candidate) } else if (message.action === 'source-left') { const track = tracks.get(message.id) if (track) removeAudio(track) tracks.delete(message.id) } else if (message.action === 'error') { this.error = message.message this.leave() } }).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } }) } ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' } ws.onclose = () => { if (socket === ws) this.leave() } } catch (error) { if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}` } finally { if (generation === voiceGeneration) this.connecting = false } }, leave() { voiceGeneration++ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'})) socket?.close() socket = null peer?.close() peer = null microphone?.getTracks().forEach(track => track.stop()) microphone = null for (const track of audioElements.keys()) removeAudio(track) this.channelId = null this.serverId = null this.connecting = false }, }, }) let voiceGeneration = 0 let socket: WebSocket | null = null let peer: RTCPeerConnection | null = null let microphone: MediaStream | null = null const audioElements = new Map() function removeAudio(track: MediaStreamTrack) { const audio = audioElements.get(track) if (!audio) return audio.pause() audio.srcObject = null audioElements.delete(track) }