125 lines
5.1 KiB
TypeScript
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)
|
|
} |