Files
chad/client/app/composables/use-mediasoup.ts
2026-05-09 03:54:08 +06:00

553 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.l.google.com:5349' },
{ urls: 'stun:stun1.l.google.com:3478' },
{ urls: 'stun:stun1.l.google.com:5349' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:5349' },
{ urls: 'stun:stun3.l.google.com:3478' },
{ urls: 'stun:stun3.l.google.com:5349' },
{ urls: 'stun:stun4.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:5349' },
]
export const useMediasoup = createSharedComposable(() => {
const { emit } = useEventBus()
const signaling = useSignaling()
const { addClient, removeClient, me } = useClients()
const preferences = usePreferences()
const { getShareStream } = useDevices()
const device = shallowRef<mediasoupClient.Device>()
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const consumers = ref<Record<Consumer['id'], Consumer>>({})
const producers = ref<Record<Producer['id'], Producer>>({})
const consumersArray = computed(() => {
return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => {
if (!socket)
return
socket.on('authenticated', async () => {
if (!signaling.socket.value)
return
device.value = new mediasoupClient.Device()
rtpCapabilities.value = await signaling.socket.value.emitWithAck('getRtpCapabilities')
await device.value.load({ routerRtpCapabilities: rtpCapabilities.value! })
// Send transport
{
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: true, consuming: false })
sendTransport.value = device.value.createSendTransport({
...transportInfo,
iceServers: [
...ICE_SERVERS,
...(transportInfo.iceServers ?? []),
],
})
sendTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await signaling.socket.value!.emitWithAck('connectTransport', {
transportId: sendTransport.value!.id,
dtlsParameters,
})
callback()
}
catch (error) {
if (error instanceof Error) {
errback(error)
}
}
})
sendTransport.value.on('produce', async ({ kind, rtpParameters, appData }, callback, errback) => {
try {
const { id } = await signaling.socket.value!.emitWithAck('produce', {
transportId: sendTransport.value!.id,
kind,
rtpParameters,
appData,
})
callback({ id })
}
catch (error) {
if (error instanceof Error) {
errback(error)
}
}
})
}
// Recv Transport
{
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: false, consuming: true })
recvTransport.value = device.value.createRecvTransport({
...transportInfo,
iceServers: [
...ICE_SERVERS,
...(transportInfo.iceServers ?? []),
],
})
recvTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await signaling.socket.value!.emitWithAck('connectTransport', {
transportId: recvTransport.value!.id,
dtlsParameters,
})
callback()
}
catch (error) {
if (error instanceof Error) {
errback(error)
}
}
})
}
const joinedClients = (await signaling.socket.value.emitWithAck('join', {
rtpCapabilities: rtpCapabilities.value,
}))
addClient(...joinedClients)
if (me.value)
emit('socket:authenticated', { socketId: me.value.socketId })
await enableMic()
})
socket.on('newPeer', (client) => {
addClient(client)
emit('client:added', client)
})
socket.on('peerClosed', (id) => {
const { getClient } = useClients()
const client = getClient(id)
removeClient(id)
if (client)
emit('client:removed', client)
})
socket.on(
'newConsumer',
async (
{ id, producerId, kind, rtpParameters, socketId, appData, producerPaused },
cb,
) => {
if (!recvTransport.value)
return
const consumer = await recvTransport.value.consume({
id,
producerId,
kind,
rtpParameters,
streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId },
})
if (producerPaused)
consumer.pause()
consumers.value[consumer.id] = {
id: consumer.id,
paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
emit('consumer:added', consumers.value[consumer.id]!)
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
emit('consumer:resumed', consumers.value[consumer.id]!)
})
consumer.observer.on('pause', () => {
consumers.value[consumer.id]!.paused = true
emit('consumer:paused', consumers.value[consumer.id]!)
})
consumer.observer.on('close', () => {
const consumerData = consumers.value[consumer.id]
delete consumers.value[consumer.id]
if (consumerData)
emit('consumer:removed', consumerData)
})
consumer.on('trackended', () => {
consumer.close()
})
cb()
},
)
socket.on(
'consumerClosed',
async (
{ consumerId },
) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.close()
},
)
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => {
device.value = undefined
rtpCapabilities.value = undefined
sendTransport.value?.close()
sendTransport.value = undefined
recvTransport.value?.close()
recvTransport.value = undefined
consumers.value = {}
producers.value = {}
})
}, { immediate: true, flush: 'sync' })
async function createProducer(options: ProducerOptions) {
if (!device.value || !sendTransport.value)
return
if (!options.track)
return
if (!device.value.canProduce(options.track.kind as MediaKind))
return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
emit('producer:added', producers.value[producer.id]!)
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
emit('producer:paused', producers.value[producer.id]!)
})
producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false
emit('producer:resumed', producers.value[producer.id]!)
})
producer.observer.on('close', () => {
const producerData = producers.value[producer.id]
delete producers.value[producer.id]
if (producerData)
emit('producer:removed', producerData)
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
})
}
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
return
try {
producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id,
})
}
catch {
}
finally {
delete producers.value[producer.id]
}
}
async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: preferences.inputDeviceId.value },
autoGainControl: { exact: preferences.autoGainControl.value },
echoCancellation: { exact: preferences.echoCancellation.value },
noiseSuppression: { exact: preferences.noiseSuppression.value },
},
})
const track = stream.getAudioTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
codecOptions: {
opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос
},
appData: {
source: 'mic-video',
},
})
}
async function disableMic() {
if (!micProducer.value)
return
await disableProducer(micProducer.value)
}
async function enableVideo() {
if (videoProducer.value)
return
if (!device.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
const stream = await getShareStream(preferences.shareFps.value)
const track = stream.getVideoTracks()[0]
if (!track)
return
console.log('codec', device.value.sendRtpCapabilities.codecs)
await createProducer({
track,
streamId: 'share',
codec: device.value.sendRtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/vp9' && c.parameters?.['profile-id'] === 0,
),
encodings: [
{
maxBitrate: 12_000_000, // 8 Mbps — для 1080p60 достаточно
maxFramerate: 60,
scalabilityMode: 'L1T1', // Без SVC слоёв (стабильнее)
networkPriority: 'high',
},
],
codecOptions: {
videoGoogleStartBitrate: 2000, // Стартуем с 2 Mbps сразу
videoGoogleMaxBitrate: 12000,
videoGoogleMinBitrate: 500,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(producer: Producer) {
if (!signaling.socket.value)
return
if (producer.paused)
return
try {
producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id,
})
}
catch {
producer.raw.resume()
}
}
async function resumeProducer(producer: Producer) {
if (!signaling.socket.value)
return
try {
producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id,
})
}
catch {
producer.raw.pause()
}
}
watch([
preferences.inputDeviceId,
preferences.echoCancellation,
preferences.autoGainControl,
preferences.noiseSuppression,
], async ([inputDeviceId]) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
})
return {
consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers,
speakingClients,
sendTransport,
recvTransport,
rtpCapabilities,
device,
micProducer,
videoProducer,
shareProducer,
pauseProducer,
resumeProducer,
enableVideo,
enableShare,
disableProducer,
}
})