Files
oxspeak_server/frontend/src/stores/voice.ts
T
2026-09-24 13:55:59 +02:00

125 lines
5.1 KiB
TypeScript

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<string, MediaStreamTrack>()
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<MediaStreamTrack, HTMLAudioElement>()
function removeAudio(track: MediaStreamTrack) {
const audio = audioElements.get(track)
if (!audio) return
audio.pause()
audio.srcObject = null
audioElements.delete(track)
}