9 Commits

Author SHA1 Message Date
47a464f08f screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 48s
2025-12-26 18:22:22 +06:00
4d5db12e1b screen sharing
Some checks failed
Deploy / deploy (push) Successful in 35s
Deploy / publish-web (push) Failing after 22s
2025-12-26 17:36:30 +06:00
4f59cbcf65 screen sharing
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 17:21:59 +06:00
3b3f6b6e40 update 2025-12-26 01:44:16 +06:00
461cbc6f83 profile rest
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-26 01:25:14 +06:00
a5cda8828f refactor
All checks were successful
Deploy / deploy (push) Successful in 34s
2025-12-26 01:22:34 +06:00
778f0a5687 refactor
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 01:18:54 +06:00
2aca9bca08 refactor
All checks were successful
Deploy / deploy (push) Successful in 40s
2025-12-26 01:13:21 +06:00
7ed23df3e9 refactor
All checks were successful
Deploy / deploy (push) Successful in 39s
2025-12-26 01:08:44 +06:00
16 changed files with 568 additions and 370 deletions

View File

@@ -7,17 +7,17 @@
</template>
</PrimeAvatar>
<div class="flex-1">
<div class="text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
<div class="flex-1 overflow-hidden">
<div class="overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
{{ client.displayName }}
</div>
<div v-if="client.username !== client.displayName" class="mt-1 text-xs leading-5 text-muted-color">
<div v-if="client.username !== client.displayName" class="overflow-hidden mt-1 text-xs leading-5 text-muted-color">
{{ client.username }}
</div>
</div>
<PrimeBadge v-if="inputMuted" severity="info" value="Muted" />
<!-- <PrimeBadge v-if="outputMuted" severity="info" value="No sound" /> -->
<PrimeBadge v-if="client.outputMuted" severity="info" value="No sound" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" />
<PrimeBadge v-if="isMe" severity="secondary" value="You" />
<template v-if="!isMe">
@@ -58,15 +58,15 @@ const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.cli
const menuRef = useTemplateRef<HTMLAudioElement>('menu')
const menuItems: MenuItem[] = [
{
label: 'Mute',
icon: 'pi pi-headphones',
},
{
label: 'DM',
icon: 'pi pi-comment',
disabled: true,
},
// {
// label: 'Mute',
// icon: 'pi pi-headphones',
// },
// {
// label: 'DM',
// icon: 'pi pi-comment',
// disabled: true,
// },
]
const isMe = computed(() => {
@@ -98,13 +98,10 @@ const audioTrack = computed(() => {
const { setGain } = useAudioContext(audioTrack)
watch(volume, (volume) => {
// if (outputMuted.value)
// return
setGain(volume * 0.01)
setGain(outputMuted.value ? 0 : (volume * 0.01))
}, { immediate: true })
// watch(outputMuted, (outputMuted) => {
// setGain(outputMuted ? 0 : (volume.value * 0.01))
// })
watch(outputMuted, (outputMuted) => {
setGain(outputMuted ? 0 : (volume.value * 0.01))
})
</script>

View File

@@ -6,77 +6,98 @@ import { useClients } from '~/composables/use-clients'
export const useApp = createGlobalState(() => {
const { clients } = useClients()
const mediasoup = useMediasoup()
const signaling = useSignaling()
const toast = useToast()
const ready = ref(false)
const inputMuted = ref(false)
const outputMuted = ref(false)
const previousInputMuted = ref(inputMuted.value)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-')
watch(inputMuted, async (inputMuted) => {
if (inputMuted) {
await mediasoup.pauseProducer('microphone')
}
else {
if (outputMuted.value) {
outputMuted.value = false
}
await mediasoup.resumeProducer('microphone')
}
const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused
})
const previousInputMuted = ref(inputMuted.value)
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated'
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
const outputMuted = ref(false)
const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value
})
watch(outputMuted, (outputMuted) => {
if (outputMuted) {
previousInputMuted.value = inputMuted.value
muteInput()
}
else {
inputMuted.value = previousInputMuted.value
}
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed'
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
})
function muteInput() {
inputMuted.value = true
}
function unmuteInput() {
inputMuted.value = false
}
function toggleInput() {
async function muteInput() {
if (inputMuted.value)
unmuteInput()
return
await mediasoup.pauseProducer('microphone')
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
}
async function unmuteInput() {
if (!inputMuted.value)
return
if (outputMuted.value) {
await unmuteOutput()
}
await mediasoup.resumeProducer('microphone')
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
}
async function toggleInput() {
if (inputMuted.value)
await unmuteInput()
else
muteInput()
await muteInput()
}
function muteOutput() {
outputMuted.value = true
}
function unmuteOutput() {
outputMuted.value = false
}
function toggleOutput() {
async function muteOutput() {
if (outputMuted.value)
unmuteOutput()
return
outputMuted.value = true
previousInputMuted.value = inputMuted.value
await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
}
async function unmuteOutput() {
outputMuted.value = false
if (!previousInputMuted.value)
await unmuteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
}
async function toggleOutput() {
if (outputMuted.value)
await unmuteOutput()
else
muteOutput()
await muteOutput()
}
async function toggleShare() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
}
else {
await mediasoup.disableProducer('share')
}
}
return {
@@ -93,5 +114,7 @@ export const useApp = createGlobalState(() => {
version,
isTauri,
commitSha,
toggleShare,
sharingEnabled,
}
})

View File

@@ -0,0 +1,28 @@
import { createGlobalState, useDevicesList } from '@vueuse/core'
export const useDevices = createGlobalState(() => {
const {
ensurePermissions,
permissionGranted,
videoInputs,
audioInputs,
audioOutputs,
} = useDevicesList()
async function getShareStream(fps = 30) {
return navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'monitor',
frameRate: { max: fps },
},
})
}
return {
videoInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioOutputs.value))),
getShareStream,
}
})

View File

@@ -1,4 +1,5 @@
import type { ChadClient } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client'
import { usePreferences } from '~/composables/use-preferences'
@@ -25,7 +26,6 @@ export const useMediasoup = createSharedComposable(() => {
const signaling = useSignaling()
const { addClient, removeClient } = useClients()
const preferences = usePreferences()
const { me } = useAuth()
const device = shallowRef<mediasoupClient.Device>()
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
@@ -158,7 +158,7 @@ export const useMediasoup = createSharedComposable(() => {
producerId,
kind,
rtpParameters,
streamId: `${socketId}-${appData.share ? 'share' : 'mic-webcam'}`,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId },
})
@@ -234,16 +234,62 @@ export const useMediasoup = createSharedComposable(() => {
return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId)
}
async function enableMic() {
if (micProducer.value)
async function enableProducer(type: ProducerType, options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value)
return
if (!device.value.canProduce('audio'))
if (!options.track)
return
if (!device.value.canProduce(options.track.kind as MediaKind))
return
producer.value = await sendTransport.value.produce(options)
producers.value.set(producer.value.id, producer.value)
triggerRef(producers)
triggerRef(producer)
producer.value.on('transportclose', () => {
micProducer.value = undefined
})
producer.value.on('trackended', () => {
disableProducer(type)
})
}
async function disableProducer(type: ProducerType) {
const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return
producers.value.delete(producer.value.id)
try {
producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.value.id,
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(producer)
}
producer.value = undefined
}
async function enableMic() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: preferences.inputDeviceId.value },
@@ -258,7 +304,7 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
micProducer.value = await sendTransport.value.produce({
await enableProducer('microphone', {
track,
codecOptions: {
opusStereo: true,
@@ -266,41 +312,41 @@ export const useMediasoup = createSharedComposable(() => {
opusFec: false, // Фиксит пакет лос
},
})
producers.value.set(micProducer.value.id, micProducer.value)
triggerRef(producers)
triggerRef(micProducer)
micProducer.value.on('transportclose', () => {
micProducer.value = undefined
})
micProducer.value.on('trackended', () => {
disableMic()
})
}
async function disableMic() {
if (!signaling.socket.value || !micProducer.value)
await disableProducer('microphone')
}
async function enableShare() {
if (!device.value)
return
producers.value.delete(micProducer.value.id)
try {
micProducer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: micProducer.value.id,
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'monitor',
frameRate: { max: 30 },
},
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(micProducer)
}
micProducer.value = undefined
const track = stream.getVideoTracks()[0]
if (!track)
return
await enableProducer('share', {
track,
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/h264',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
appData: {
source: 'share',
},
})
}
async function pauseProducer(type: ProducerType) {
@@ -371,18 +417,6 @@ export const useMediasoup = createSharedComposable(() => {
}
}
watch(
preferences.inputDeviceId,
async (inputDeviceId) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
},
)
watch([
preferences.inputDeviceId,
preferences.echoCancellation,
@@ -411,5 +445,7 @@ export const useMediasoup = createSharedComposable(() => {
getClientConsumers,
pauseProducer,
resumeProducer,
enableShare,
disableProducer,
}
})

View File

@@ -1,5 +1,5 @@
import chadApi from '#shared/chad-api'
import { createGlobalState, useDevicesList, useLocalStorage, watchDebounced } from '@vueuse/core'
import { createGlobalState, useLocalStorage, watchDebounced } from '@vueuse/core'
export interface SyncedPreferences {
toggleInputHotkey: string
@@ -8,6 +8,8 @@ export interface SyncedPreferences {
}
export const usePreferences = createGlobalState(() => {
const { videoInputs, audioInputs, audioOutputs } = useDevices()
const synced = ref(false)
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
@@ -20,14 +22,6 @@ export const usePreferences = createGlobalState(() => {
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
const {
ensurePermissions,
permissionGranted,
videoInputs,
audioInputs,
audioOutputs,
} = useDevicesList()
const inputDeviceExist = computed(() => {
return audioInputs.value.some(device => device.deviceId === inputDeviceId.value)
})
@@ -67,8 +61,5 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))),
}
})

View File

@@ -20,6 +20,12 @@
</template>
</PrimeButton>
</PrimeButtonGroup>
<PrimeButton outlined @click="toggleShare">
<template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
</template>
</PrimeButton>
</div>
<div
@@ -53,9 +59,19 @@
</template>
<script setup lang="ts">
import { MessageCircle, Mic, MicOff, Settings, UserPen, Volume2, VolumeOff } from 'lucide-vue-next'
import {
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp()
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri, sharingEnabled, toggleShare } = useApp()
const { connect, connected } = useSignaling()
interface Tab {

View File

@@ -6,6 +6,8 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!me.value) {
try {
setMe(await chadApi('/me', { method: 'GET' }))
if (to.meta.auth !== false)
return navigateTo({ name: 'Index' })
}
catch {

View File

@@ -1,4 +1,5 @@
<template>
<div>
<div class="flex items-center justify-center">
<PrimeCard>
<template #content>
@@ -6,10 +7,43 @@
</template>
</PrimeCard>
</div>
<video
v-if="!!shareConsumer"
ref="shareVideo"
class="w-full aspect-video border border-surface-700 rounded mt-6 cursor-pointer"
autoplay
playsinline
@click="toFullscreen"
/>
</div>
</template>
<script setup lang="ts">
import { unrefElement } from '@vueuse/core'
definePageMeta({
name: 'Index',
})
const shareVideoRef = useTemplateRef('shareVideo')
const { consumers } = useMediasoup()
const shareConsumer = computed(() => {
return consumers.value.values().find(consumer => consumer.kind === 'video' && consumer.appData?.source === 'share')
})
watchEffect(() => {
if (!shareVideoRef.value || !shareConsumer.value)
return
const stream = new MediaStream([shareConsumer.value.track])
unrefElement(shareVideoRef)!.srcObject = stream
})
function toFullscreen() {
unrefElement(shareVideoRef)?.requestFullscreen()
}
</script>

View File

@@ -47,6 +47,7 @@
<!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> -->
<template v-if="isTauri">
<PrimeDivider align="left">
Hotkeys
</PrimeDivider>
@@ -60,6 +61,7 @@
<PrimeInputText id="soundToggle" :model-value="toggleOutputHotkey" fluid @keydown="setupToggleOutputHotkey" />
<label for="soundToggle">Toggle sound</label>
</PrimeFloatLabel>
</template>
<PrimeDivider align="left">
About
@@ -72,31 +74,28 @@
COMMIT_SHA: {{ commitSha }}
</p>
<template v-if="isTauri">
<PrimeButton
v-if="isTauri"
v-if="lastUpdate"
class="mt-3"
size="small"
label="Install new version"
fluid
severity="success"
@click="navigateTo({ name: 'Updater' })"
/>
<PrimeButton
v-else
class="mt-3"
size="small"
label="Check for Updates"
fluid
severity="info"
:loading="checking"
@click="onCheckForUpdates"
@click="checkForUpdates"
/>
</div>
<PrimeToast position="bottom-center" group="updater">
<template #container="slotProps">
<div class="p-3">
<div class="font-medium text-lg mb-4">
{{ slotProps.message.detail }}
</div>
<div class="flex gap-3">
<PrimeButton size="small" label="Update now" @click="() => {}" />
<PrimeButton size="small" label="Later" severity="secondary" outlined @click="slotProps.closeCallback()" />
</div>
</div>
</template>
</PrimeToast>
</div>
</template>
<script setup lang="ts">
@@ -106,7 +105,8 @@ definePageMeta({
name: 'Preferences',
})
const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates } = useUpdater()
const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs } = useDevices()
const {
inputDeviceId,
outputDeviceId,
@@ -117,12 +117,8 @@ const {
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
audioInputs,
audioOutputs,
} = usePreferences()
const toast = useToast()
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
@@ -162,23 +158,4 @@ function setupHotkey(event: KeyboardEvent, model: RemovableRef<string>) {
model.value = hotkey.join('+')
}
async function onCheckForUpdates() {
const update = await checkForUpdates()
toast.removeGroup('updater')
if (!update) {
toast.add({ severity: 'success', summary: 'You are up to date', closable: false, life: 1000 })
return
}
toast.add({
group: 'updater',
severity: 'info',
detail: `Version ${update?.version ?? '1.0.1'} is available!`,
closable: false,
})
}
</script>

View File

@@ -21,6 +21,7 @@
</template>
<script setup lang="ts">
import chadApi from '#shared/chad-api'
import { LogOut } from 'lucide-vue-next'
definePageMeta({
@@ -51,8 +52,11 @@ async function save() {
saving.value = true
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', {
const updatedMe = await chadApi('/profile', {
method: 'PATCH',
body: {
displayName: displayName.value,
},
})
setMe({ ...me.value!, displayName: (updatedMe.displayName as string) })

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "chad",
"version": "0.2.13",
"version": "0.2.16",
"identifier": "xyz.koptilnya.chad",
"build": {
"frontendDist": "../.output/public",

View File

@@ -25,5 +25,50 @@ export const autoConfig: mediasoup.types.RouterOptions = {
channels: 2,
parameters: { useinbandfec: 1, stereo: 1 },
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: {
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '4d0032',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/AV1',
clockRate: 90000,
parameters: {},
},
],
}

View File

@@ -1,6 +1,8 @@
import type { FastifyInstance } from 'fastify'
import type { Namespace } from '../types/webrtc.ts'
import { z } from 'zod'
import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts'
export default function (fastify: FastifyInstance) {
fastify.get('/preferences', async (req, reply) => {
@@ -47,4 +49,49 @@ export default function (fastify: FastifyInstance) {
}
}
})
fastify.patch('/profile', async (req, reply) => {
if (!req.user) {
reply.code(401).send(false)
return
}
try {
const schema = z.object({
displayName: z.string().optional(),
})
const input = schema.parse(req.body)
const updatedUser = prisma.user.update({
where: { id: req.user.id },
data: {
displayName: input.displayName,
},
})
const namespace: Namespace = fastify.io.of('/webrtc')
const sockets = await namespace.fetchSockets()
const found = sockets.find(socket => socket.data.joined && socket.data.userId === req.user!.id)
if (found) {
found.data.displayName = input.displayName
namespace.emit('clientChanged', found.id, socketToClient(found))
}
return updatedUser
}
catch (err) {
fastify.log.error(err)
reply.code(400)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
}

View File

@@ -1,145 +1,15 @@
import type { User } from '@prisma/client'
import type { types } from 'mediasoup'
import type { Namespace, RemoteSocket, Socket, Server as SocketServer } from 'socket.io'
import type { Server as SocketServer } from 'socket.io'
import type {
Namespace,
SomeSocket,
} from '../types/webrtc.ts'
import { consola } from 'consola'
import prisma from '../prisma/client.ts'
interface ChadClient {
socketId: string
userId: User['id']
username: User['username']
displayName: User['displayName']
inputMuted: boolean
outputMuted: boolean
}
interface ProducerShort {
producerId: types.Producer['id']
kind: types.MediaKind
}
interface ErrorCallbackResult {
error: string
}
interface SuccessCallbackResult {
ok: true
}
type EventCallback<T = SuccessCallbackResult> = (result: T | ErrorCallbackResult) => void
interface ClientToServerEvents {
join: (
options: {
rtpCapabilities: types.RtpCapabilities
},
cb: EventCallback<ChadClient[]>
) => void
getRtpCapabilities: (
cb: EventCallback<types.RtpCapabilities>
) => void
createTransport: (
options: {
producing: boolean
consuming: boolean
},
cb: EventCallback<Pick<types.WebRtcTransport, 'id' | 'iceParameters' | 'iceCandidates' | 'dtlsParameters'>>
) => void
connectTransport: (
options: {
transportId: types.WebRtcTransport['id']
dtlsParameters: types.WebRtcTransport['dtlsParameters']
},
cb: EventCallback
) => void
produce: (
options: {
transportId: types.WebRtcTransport['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
},
cb: EventCallback<{ id: types.Producer['id'] }>
) => void
closeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
resumeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
resumeConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
updateClient: (
options: Partial<Omit<ChadClient, 'socketId' | 'userId'>>,
cb: EventCallback<ChadClient>
) => void
}
interface ServerToClientEvents {
authenticated: () => void
newPeer: (arg: ChadClient) => void
producers: (arg: ProducerShort[]) => void
newConsumer: (
arg: {
socketId: string
producerId: types.Producer['id']
id: types.Consumer['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
type: types.ConsumerType
appData: types.Producer['appData']
producerPaused: types.Consumer['producerPaused']
},
cb: EventCallback
) => void
peerClosed: (arg: string) => void
consumerClosed: (arg: { consumerId: string }) => void
consumerPaused: (arg: { consumerId: string }) => void
consumerResumed: (arg: { consumerId: string }) => void
consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void
clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void
}
interface InterServerEvent {}
interface SocketData {
joined: boolean
userId: User['id']
username: User['username']
displayName: User['displayName']
inputMuted: boolean
outputMuted: boolean
rtpCapabilities: types.RtpCapabilities
transports: Map<types.WebRtcTransport['id'], types.WebRtcTransport>
producers: Map<types.Producer['id'], types.Producer>
consumers: Map<types.Consumer['id'], types.Consumer>
}
type SomeSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData> | RemoteSocket<ServerToClientEvents, SocketData>
import { socketToClient } from '../utils/socket-to-client.ts'
export default function (io: SocketServer, router: types.Router) {
const namespace: Namespace<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData> = io.of('/webrtc')
const namespace: Namespace = io.of('/webrtc')
namespace.on('connection', async (socket) => {
consola.info('[WebRtc]', 'Client connected', socket.id)
@@ -278,7 +148,7 @@ export default function (io: SocketServer, router: types.Router) {
}
})
socket.on('produce', async ({ transportId, kind, rtpParameters }, cb) => {
socket.on('produce', async ({ transportId, kind, rtpParameters, appData }, cb) => {
if (!socket.data.joined) {
consola.error('Peer not joined yet')
cb({ error: 'Peer not joined yet' })
@@ -296,7 +166,7 @@ export default function (io: SocketServer, router: types.Router) {
}
try {
const producer = await transport.produce({ kind, rtpParameters, appData: { socketId: socket.id } })
const producer = await transport.produce({ kind, rtpParameters, appData: { ...appData, socketId: socket.id } })
socket.data.producers.set(producer.id, producer)
@@ -439,24 +309,11 @@ export default function (io: SocketServer, router: types.Router) {
})
socket.on('updateClient', async (updatedClient, cb) => {
if (updatedClient.displayName) {
await prisma.user.update({
where: {
id: socket.data.userId,
},
data: {
displayName: updatedClient.displayName,
},
})
socket.data.displayName = updatedClient.displayName
}
if (updatedClient.inputMuted) {
if (typeof updatedClient.inputMuted === 'boolean') {
socket.data.inputMuted = updatedClient.inputMuted
}
if (updatedClient.outputMuted) {
if (typeof updatedClient.outputMuted === 'boolean') {
socket.data.outputMuted = updatedClient.outputMuted
}
@@ -583,15 +440,4 @@ export default function (io: SocketServer, router: types.Router) {
consola.error('_createConsumer() | failed:%o', error)
}
}
function socketToClient(socket: SomeSocket): ChadClient {
return {
socketId: socket.id,
userId: socket.data.userId,
username: socket.data.username,
displayName: socket.data.displayName,
inputMuted: socket.data.inputMuted,
outputMuted: socket.data.outputMuted,
}
}
}

140
server/types/webrtc.ts Normal file
View File

@@ -0,0 +1,140 @@
import type { types } from 'mediasoup'
import type { RemoteSocket, Socket, Namespace as SocketNamespace } from 'socket.io'
import type { User } from '../prisma/client'
export interface ChadClient {
socketId: string
userId: User['id']
username: User['username']
displayName: User['displayName']
inputMuted: boolean
outputMuted: boolean
}
export interface ProducerShort {
producerId: types.Producer['id']
kind: types.MediaKind
}
export interface ErrorCallbackResult {
error: string
}
export interface SuccessCallbackResult {
ok: true
}
export type EventCallback<T = SuccessCallbackResult> = (result: T | ErrorCallbackResult) => void
export interface ClientToServerEvents {
join: (
options: {
rtpCapabilities: types.RtpCapabilities
},
cb: EventCallback<ChadClient[]>
) => void
getRtpCapabilities: (
cb: EventCallback<types.RtpCapabilities>
) => void
createTransport: (
options: {
producing: boolean
consuming: boolean
},
cb: EventCallback<Pick<types.WebRtcTransport, 'id' | 'iceParameters' | 'iceCandidates' | 'dtlsParameters'>>
) => void
connectTransport: (
options: {
transportId: types.WebRtcTransport['id']
dtlsParameters: types.WebRtcTransport['dtlsParameters']
},
cb: EventCallback
) => void
produce: (
options: {
transportId: types.WebRtcTransport['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
appData: { source: 'share' | string }
},
cb: EventCallback<{ id: types.Producer['id'] }>
) => void
closeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
resumeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
resumeConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
updateClient: (
options: Partial<Pick<ChadClient, 'inputMuted' | 'outputMuted'>>,
cb: EventCallback<ChadClient>
) => void
}
export interface ServerToClientEvents {
authenticated: () => void
newPeer: (arg: ChadClient) => void
producers: (arg: ProducerShort[]) => void
newConsumer: (
arg: {
socketId: string
producerId: types.Producer['id']
id: types.Consumer['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
type: types.ConsumerType
appData: types.Producer['appData']
producerPaused: types.Consumer['producerPaused']
},
cb: EventCallback
) => void
peerClosed: (arg: string) => void
consumerClosed: (arg: { consumerId: string }) => void
consumerPaused: (arg: { consumerId: string }) => void
consumerResumed: (arg: { consumerId: string }) => void
consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void
clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void
}
export interface InterServerEvent {}
export interface SocketData {
joined: boolean
userId: User['id']
username: User['username']
displayName: User['displayName']
inputMuted: boolean
outputMuted: boolean
rtpCapabilities: types.RtpCapabilities
transports: Map<types.WebRtcTransport['id'], types.WebRtcTransport>
producers: Map<types.Producer['id'], types.Producer>
consumers: Map<types.Consumer['id'], types.Consumer>
}
export type SomeSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData> | RemoteSocket<ServerToClientEvents, SocketData>
export type Namespace = SocketNamespace<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData>

View File

@@ -0,0 +1,12 @@
import type { ChadClient, SomeSocket } from '../types/webrtc.ts'
export function socketToClient(socket: SomeSocket): ChadClient {
return {
socketId: socket.id,
userId: socket.data.userId,
username: socket.data.username,
displayName: socket.data.displayName,
inputMuted: socket.data.inputMuted,
outputMuted: socket.data.outputMuted,
}
}