1 Commits

Author SHA1 Message Date
Ivan Grachyov
ca773a56c6 chat WIP 2025-12-26 23:36:21 +03:00
93 changed files with 535 additions and 744 deletions

Binary file not shown.

View File

@@ -31,17 +31,17 @@ body {
.p-select-overlay { .p-select-overlay {
/* Force dropdown width to match computed min-width from PrimeVue internals. */ /* Force dropdown width to match computed min-width from PrimeVue internals. */
width: 0 !important; width: 0;
} }
.p-select-label { .p-select-label {
width: 0 !important; width: 0;
overflow: hidden !important; overflow: hidden;
text-overflow: ellipsis !important; text-overflow: ellipsis;
} }
.p-select-option-label { .p-select-option-label {
min-width: 0 !important; min-width: 0;
overflow: hidden !important; overflow: hidden;
text-overflow: ellipsis !important; text-overflow: ellipsis;
} }

View File

@@ -13,18 +13,19 @@ declare module 'vue' {
PrimeButton: typeof import('primevue/button')['default'] PrimeButton: typeof import('primevue/button')['default']
PrimeButtonGroup: typeof import('primevue/buttongroup')['default'] PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
PrimeCard: typeof import('primevue/card')['default'] PrimeCard: typeof import('primevue/card')['default']
PrimeDivider: typeof import('primevue/divider')['default']
PrimeFloatLabel: typeof import('primevue/floatlabel')['default'] PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputText: typeof import('primevue/inputtext')['default'] PrimeInputText: typeof import('primevue/inputtext')['default']
PrimePassword: typeof import('primevue/password')['default'] PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default'] PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default'] PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default'] PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default'] PrimeTab: typeof import('primevue/tab')['default']
PrimeTabList: typeof import('primevue/tablist')['default']
PrimeTabPanel: typeof import('primevue/tabpanel')['default']
PrimeTabPanels: typeof import('primevue/tabpanels')['default']
PrimeTabs: typeof import('primevue/tabs')['default']
PrimeTextarea: typeof import('primevue/textarea')['default']
PrimeToast: typeof import('primevue/toast')['default'] PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

View File

@@ -6,29 +6,19 @@
'bg-surface-800': expanded, 'bg-surface-800': expanded,
}" }"
> >
<div class="p-3" @click="toggleExpand"> <div class="p-3 flex items-center gap-3" @click="toggleExpand">
<div class="flex items-center gap-3"> <PrimeAvatar size="small">
<PrimeAvatar <template #icon>
size="small" <User :size="20" />
class="shrink-0" </template>
:class="{ </PrimeAvatar>
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<template #icon>
<User :size="20" />
</template>
</PrimeAvatar>
<p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0"> <div class="flex-1 overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
{{ client.displayName || client.username }} {{ client.displayName || client.username }}
</p>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
</div> </div>
<div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2"> <div class="flex align-center gap-1">
<PrimeBadge v-if="streaming" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" /> <PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" /> <PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" /> <PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
@@ -36,6 +26,8 @@
<PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" /> <PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
</div> </div>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" />
</div> </div>
<CollapseTransition v-if="!isMe"> <CollapseTransition v-if="!isMe">
@@ -60,6 +52,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChadClient } from '#shared/types' import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
import { ChevronDown, ChevronUp, User } from 'lucide-vue-next' import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import CollapseTransition from '~/components/CollapseTransition.vue' import CollapseTransition from '~/components/CollapseTransition.vue'
@@ -74,34 +67,34 @@ const { show } = useFullscreenVideo()
const expanded = ref(false) const expanded = ref(false)
const { const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false })
volume, const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false })
premuted,
speaking,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
} = useClient(toRef(() => props.client.socketId))
const isMe = computed(() => { const isMe = computed(() => {
return me.value && props.client.userId === me.value.userId return me.value && props.client.userId === me.value.userId
}) })
const consumers = computed(() => {
return allConsumers.value.values().filter(consumer => consumer.appData.socketId === props.client.socketId).toArray()
})
const audioConsumer = computed(() => { const audioConsumer = computed(() => {
return audioConsumers.value[0] return consumers.value.find(consumer => consumer.track.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumers.value.filter(consumer => consumer.track.kind === 'video')
})
const shareConsumer = computed(() => {
return videoConsumers.value.find(consumer => consumer.appData.source === 'share')
}) })
const audioTrack = computed(() => { const audioTrack = computed(() => {
return audioConsumer.value?.raw.track return audioConsumer.value?.track
}) })
const audioConsumerPaused = computed(() => { const audioConsumerPaused = ref(false)
if (Object.keys(allConsumers.value).length === 0)
return false
return audioConsumer.value?.paused ?? false
})
const inputMuted = computed(() => { const inputMuted = computed(() => {
if (isMe.value) if (isMe.value)
@@ -110,12 +103,8 @@ const inputMuted = computed(() => {
return premuted.value || audioConsumerPaused.value return premuted.value || audioConsumerPaused.value
}) })
const hasBadges = computed(() => { watch(allConsumers, () => {
return streaming.value audioConsumerPaused.value = audioConsumer.value?.paused ?? false
|| premuted.value
|| inputMuted.value
|| props.client.outputMuted
|| isMe.value
}) })
const { setGain } = useAudioContext(audioTrack) const { setGain } = useAudioContext(audioTrack)
@@ -132,11 +121,9 @@ function toggleExpand() {
} }
function watchStream() { function watchStream() {
if (!streaming.value) if (!shareConsumer.value)
return return
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]! show(new MediaStream([shareConsumer.value.track]))
show(new MediaStream([consumer.raw.track]))
} }
</script> </script>

View File

@@ -1,25 +0,0 @@
<template>
<div class="text-sm overflow-x-auto">
<p class="text-muted-color">
{{ consumer.id }}
</p>
<p>paused: {{ consumer.paused }}</p>
<p v-for="[key, value] in appData" :key="key">
{{ key }}: {{ value }}
</p>
</div>
</template>
<script setup lang="ts">
import type { Consumer } from 'mediasoup-client/types'
const props = defineProps<{
consumer: Consumer
}>()
const appData = computed(() => {
return Object.entries(props.consumer.appData)
})
</script>

View File

@@ -1,20 +0,0 @@
<template>
<Teleport to="body">
<div ref="root" class="fullscreen-gallery">
{{ videoConsumers.length + shareConsumers.length }}
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
const rootRef = useTemplateRef('root')
const { enter } = useFullscreen(rootRef)
const { videoConsumers, shareConsumers } = useMediasoup()
onMounted(() => {
// enter()
})
</script>

View File

@@ -1,13 +0,0 @@
<template>
<div class="fullscreen-gallery-card">
sasd
</div>
</template>
<script setup lang="ts">
</script>
<style>
</style>

View File

@@ -1,40 +0,0 @@
<template>
<div
class="group cursor-pointer hover:outline outline-primary relative rounded overflow-hidden flex items-center justify-center"
@click="watch"
>
<video :srcObject="stream" muted autoplay />
<PrimeTag
severity="secondary"
class="absolute bottom-2 text-sm opacity-70 group-hover:opacity-100"
rounded
>
{{ isMe ? 'You' : client.displayName }}
</PrimeTag>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
const props = defineProps<{
client: ChadClient
stream: MediaStream
}>()
const { me } = useClients()
const fullscreenVideo = useFullscreenVideo()
const isMe = computed(() => {
return props.client.socketId === me.value?.socketId
})
function watch() {
fullscreenVideo.show(props.stream)
}
</script>
<style>
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="chat-editor">
<PrimeTextarea v-model="msg" />
<PrimeButton :disabled="!msg" @click="handleSend()">
Send
</PrimeButton>
</div>
</template>
<script lang="ts" setup>
interface Emits {
(e: 'send', msg: string): void
}
const emit = defineEmits<Emits>()
const msg = ref<string | undefined>()
function handleSend() {
emit('send', msg.value!)
msg.value = ''
}
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,27 @@
<template>
<PrimeCard>
<template #header>
<span class="font-bold">
{{ username }}
</span>
</template>
<template #content>
{{ message }}
</template>
<template #footer>
{{ createdAt }}
</template>
</PrimeCard>
</template>
<script lang="ts" setup>
interface Props {
username: string
message: string
createdAt: Date
}
defineProps<Props>()
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="chat-tabs">
<div class="chat-tabs__messages">
<ChatMessage v-for="msg in messages" :key="msg.id" :created-at="msg.createdAt" :username="msg.username" :message="msg.message" />
</div>
<PrimeTabs :value="channels[0]">
<PrimeTabList>
<PrimeTab v-for="channel in channels" :key="channel" :value="channel">
Channel: {{ channel }}
</PrimeTab>
</PrimeTabList>
<PrimeTabPanels>
<PrimeTabPanel :value="channel">
<ChatEditor />
</PrimeTabPanel>
</PrimeTabPanels>
</PrimeTabs>
</div>
</template>
<script lang="ts" setup>
const {
channel,
messages,
channels,
} = useChat()
</script>
<style lang="scss" scoped>
</style>

View File

@@ -12,17 +12,7 @@ export const useApp = createGlobalState(() => {
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__ const commitSha = __COMMIT_SHA__
const version = computedAsync(() => { const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-')
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const inputMuted = computed(() => { const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused return !!mediasoup.micProducer.value?.paused
@@ -31,39 +21,28 @@ export const useApp = createGlobalState(() => {
const outputMuted = ref(false) const outputMuted = ref(false)
const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
const sharingEnabled = computed(() => { const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value return !!mediasoup.shareProducer.value
}) })
const somebodyStreamingVideo = computed(() => {
return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() { async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value) if (inputMuted.value)
return return
await mediasoup.pauseProducer(mediasoup.micProducer.value) await mediasoup.pauseProducer('microphone')
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
async function unmuteInput() { async function unmuteInput() {
if (!inputMuted.value || !mediasoup.micProducer.value) if (!inputMuted.value)
return return
if (outputMuted.value) { if (outputMuted.value) {
await unmuteOutput() await unmuteOutput()
} }
await mediasoup.resumeProducer(mediasoup.micProducer.value) await mediasoup.resumeProducer('microphone')
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
@@ -112,21 +91,12 @@ export const useApp = createGlobalState(() => {
await muteOutput() await muteOutput()
} }
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
}
}
async function toggleShare() { async function toggleShare() {
if (!mediasoup.shareProducer.value) { if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare() await mediasoup.enableShare()
} }
else { else {
await mediasoup.disableProducer(mediasoup.shareProducer.value) await mediasoup.disableProducer('share')
} }
} }
@@ -141,13 +111,10 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version, version,
isTauri, isTauri,
commitSha, commitSha,
toggleShare, toggleShare,
videoEnabled,
sharingEnabled, sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

@@ -0,0 +1,44 @@
import { createGlobalState } from '@vueuse/core'
interface ChatMessage {
id: string
username: string
message: string
}
interface ChatChannel {
id: number
name: string
}
export const useChat = createGlobalState(() => {
const messages = ref([
{
id: '1337',
username: 'Yes',
message: 'Fisting is 300 bucks',
createdAt: Date.now(),
},
])
const channel = ref<number>(0)
async function sendMsg(channelId: ChatChannel['id'], msg: ChatMessage['message']) {
console.log('Trying to send message', channelId, msg)
}
watch(channel, async (id) => {
await console.log('Yes', id)
}, {
immediate: true,
})
return {
channel,
channels,
messages,
sendMsg,
}
})

View File

@@ -1,52 +0,0 @@
import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
const mediasoup = useMediasoup()
const { getClient } = useClients()
const client = computed(() => getClient(unref(socketId))!)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${client.value.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${client.value.userId}`), false, { writeDefaults: false })
const consumers = computed(() => {
return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const audioConsumers = computed(() => {
return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const videoConsumers = computed(() => {
return mediasoup.videoConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const shareConsumers = computed(() => {
return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const producers = computed(() => {
return Object.values(mediasoup.producers.value).filter(producer => producer.appData.socketId === client.value.socketId)
})
const streaming = computed(() => {
return videoConsumers.value.length > 0 || shareConsumers.value.length > 0
})
const speaking = computed(() => {
return mediasoup.speakingClients.value.some(speaker => speaker.clientId === client.value.socketId)
})
return {
volume,
premuted,
consumers,
producers,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
speaking,
}
}

View File

@@ -19,16 +19,7 @@ export const useDevices = createGlobalState(() => {
}) })
} }
;(async () => {
if (permissionGranted.value)
return
await ensurePermissions()
})()
return { return {
ensurePermissions,
permissionGranted,
videoInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(videoInputs.value))), videoInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioInputs.value))), audioInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioOutputs.value))), audioOutputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioOutputs.value))),

View File

@@ -1,5 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
export const useFullscreenGallery = createSharedComposable(() => {
return {}
})

View File

@@ -29,25 +29,11 @@ export const useFullscreenVideo = createGlobalState(() => {
videoEl.value = el videoEl.value = el
} }
stream.getTracks().forEach(t =>
t.addEventListener('ended', hide),
)
videoEl.value.addEventListener('ended', hide)
await videoEl.value.requestFullscreen() await videoEl.value.requestFullscreen()
} }
function hide() { function hide() {
if (!videoEl.value)
return
(videoEl.value.srcObject as MediaStream).getTracks().forEach(t =>
t.removeEventListener('ended', hide),
)
videoEl.value.removeEventListener('ended', hide)
videoEl.value?.remove() videoEl.value?.remove()
videoEl.value = undefined
} }
useEventListener(document, 'fullscreenchange', () => { useEventListener(document, 'fullscreenchange', () => {

View File

@@ -1,18 +1,11 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types' import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client' import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices' import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences' import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling' import { useSignaling } from '~/composables/use-signaling'
type ProducerType = 'microphone' | 'video' | 'share' type ProducerType = 'microphone' | 'camera' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [ const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@@ -40,42 +33,12 @@ export const useMediasoup = createSharedComposable(() => {
const sendTransport = shallowRef<mediasoupClient.types.Transport>() const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>() const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const consumers = ref<Record<Consumer['id'], Consumer>>({}) const micProducer = shallowRef<mediasoupClient.types.Producer>()
const producers = ref<Record<Producer['id'], Producer>>({}) const cameraProducer = shallowRef<mediasoupClient.types.Producer>()
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumersArray = computed(() => { const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
return Object.values(consumers.value) const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
})
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) => { watch(signaling.socket, (socket) => {
if (!socket) if (!socket)
@@ -196,35 +159,20 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.source || 'stream'}`, streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumers.value[consumer.id] = { consumer.on('transportclose', () => {
id: consumer.id, if (consumers.value.delete(consumer.id))
paused: consumer.paused, triggerRef(consumers)
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
}) })
consumer.observer.on('pause', () => { consumers.value.set(consumer.id, consumer)
consumers.value[consumer.id]!.paused = true triggerRef(consumers)
})
consumer.observer.on('close', () => {
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -235,37 +183,11 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
const consumer = consumers.value[consumerId] if (consumers.value.delete(consumerId))
triggerRef(consumers)
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', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined rtpCapabilities.value = undefined
@@ -276,12 +198,43 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
consumers.value = {} micProducer.value = undefined
producers.value = {} cameraProducer.value = undefined
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
triggerRef(consumers)
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.resume()
triggerRef(consumers)
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
async function createProducer(options: ProducerOptions) { async function enableProducer(type: ProducerType, options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value) if (!device.value || !sendTransport.value)
return return
@@ -291,54 +244,47 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind)) if (!device.value.canProduce(options.track.kind as MediaKind))
return return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options }) producer.value = await sendTransport.value.produce(options)
producers.value[producer.id] = { producers.value.set(producer.value.id, producer.value)
id: producer.id, triggerRef(producers)
paused: producer.paused, triggerRef(producer)
appData: producer.appData,
raw: markRaw(producer),
}
producer.observer.on('pause', () => { producer.value.on('transportclose', () => {
producers.value[producer.id]!.paused = true micProducer.value = undefined
}) })
producer.observer.on('resume', () => { producer.value.on('trackended', () => {
producers.value[producer.id]!.paused = false disableProducer(type)
})
producer.observer.on('close', () => {
delete producers.value[producer.id]
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
}) })
} }
async function disableProducer(producer: Producer) { async function disableProducer(type: ProducerType) {
if (!signaling.socket.value) const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return return
producers.value.delete(producer.value.id)
try { try {
producer.raw.close() producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', { await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
} }
finally { finally {
delete producers.value[producer.id] triggerRef(producers)
triggerRef(producer)
} }
producer.value = undefined
} }
async function enableMic() { async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
deviceId: { exact: preferences.inputDeviceId.value }, deviceId: { exact: preferences.inputDeviceId.value },
@@ -353,125 +299,110 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await createProducer({ await enableProducer('microphone', {
track, track,
streamId: 'mic-video',
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
appData: {
source: 'mic-video',
},
}) })
} }
async function disableMic() { async function disableMic() {
if (!micProducer.value) await disableProducer('microphone')
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() { async function enableShare() {
if (shareProducer.value)
return
if (!device.value) if (!device.value)
return return
const stream = await getShareStream(preferences.shareFps.value) const stream = await getShareStream()
const track = stream.getVideoTracks()[0] const track = stream.getVideoTracks()[0]
if (!track) if (!track)
return return
await createProducer({ await enableProducer('share', {
track, track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find( codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1', c => c.mimeType.toLowerCase() === 'video/h264',
), ),
codecOptions: { codecOptions: {
videoGoogleStartBitrate: 1000, videoGoogleStartBitrate: 1000,
}, },
zeroRtpOnPause: true,
appData: { appData: {
source: 'share', source: 'share',
}, },
}) })
} }
async function pauseProducer(producer: Producer) { async function pauseProducer(type: ProducerType) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
if (producer.paused) const producer = getProducerByType(type)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.raw.pause() producer.value.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
producer.raw.resume() producer.value.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(producer: Producer) { async function resumeProducer(type: ProducerType) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.raw.resume() producer.value.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
producer.raw.pause() producer.value.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
}
}
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
} }
} }
@@ -490,22 +421,18 @@ export const useMediasoup = createSharedComposable(() => {
}) })
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
videoProducer, cameraProducer,
shareProducer, shareProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
enableShare, enableShare,
disableProducer, disableProducer,
} }

View File

@@ -14,14 +14,11 @@ export const usePreferences = createGlobalState(() => {
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default') const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default') const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false) const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true) const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true) const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
const shareFps = useLocalStorage('SHARE_FPS', 30)
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('') const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('') const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
@@ -33,10 +30,6 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value) return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
}) })
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced( watchDebounced(
[toggleInputHotkey, toggleOutputHotkey], [toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => { async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -61,15 +54,12 @@ export const usePreferences = createGlobalState(() => {
synced, synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
shareFps,
toggleInputHotkey, toggleInputHotkey,
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
} }
}) })

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full"> <div class="grid grid-cols-[1fr_1.25fr] gap-2 p-2 h-screen grid-rows-[auto_1fr]">
<div <div
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950" class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
> >
<div class="inline-flex items-center gap-3"> <div class="inline-flex items-center gap-3">
<PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" /> <PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " /> <PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div> </div>
@@ -21,12 +21,6 @@
</PrimeButton> </PrimeButton>
</PrimeButtonGroup> </PrimeButtonGroup>
<PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
<template #icon>
<Component :is="videoEnabled ? CameraOff : Camera" />
</template>
</PrimeButton>
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare"> <PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<template #icon> <template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" /> <Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
@@ -40,7 +34,7 @@
<PrimeSelectButton <PrimeSelectButton
v-model="activeTab" v-model="activeTab"
:options="tabs" :options="tabs"
option-label="id" data-key="id"
:allow-empty="false" :allow-empty="false"
style="--p-togglebutton-content-padding: 0.25rem 0.5rem" style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
> >
@@ -50,49 +44,42 @@
</PrimeSelectButton> </PrimeSelectButton>
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0">
<div v-auto-animate class="p-3 space-y-1"> <div v-auto-animate class="p-3 space-y-1">
<ClientRow v-for="client of clients" :key="client.userId" :client="client" /> <ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0">
<div class="p-3"> <div class="p-3">
<slot /> <slot />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
</div> </div>
<FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
Camera,
CameraOff,
MessageCircle, MessageCircle,
Mic, Mic,
MicOff, MicOff,
ScreenShare, ScreenShare,
ScreenShareOff, ScreenShareOff,
Settings, Settings,
TvMinimalPlay,
UserPen, UserPen,
Volume2, Volume2,
VolumeOff, VolumeOff,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { const {
isTauri,
version, version,
clients, clients,
inputMuted, inputMuted,
outputMuted, outputMuted,
videoEnabled,
sharingEnabled, sharingEnabled,
somebodyStreamingVideo,
toggleInput, toggleInput,
toggleOutput, toggleOutput,
toggleVideo,
toggleShare, toggleShare,
} = useApp() } = useApp()
const { connect, connected } = useSignaling() const { connect, connected } = useSignaling()
@@ -105,47 +92,31 @@ interface Tab {
const route = useRoute() const route = useRoute()
const tabs = computed<Tab[]>(() => { const tabs: Tab[] = [
const result = [] {
id: 'Index',
if (somebodyStreamingVideo.value) { icon: MessageCircle,
result.push({ onClick: () => {
id: 'Gallery', navigateTo({ name: 'Index' })
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
{
id: 'Index',
icon: MessageCircle,
onClick: () => {
navigateTo({ name: 'Index' })
},
}, },
{ },
id: 'Profile', {
icon: UserPen, id: 'Profile',
onClick: () => { icon: UserPen,
navigateTo({ name: 'Profile' }) onClick: () => {
}, navigateTo({ name: 'Profile' })
}, },
{ },
id: 'Preferences', {
icon: Settings, id: 'Preferences',
onClick: () => { icon: Settings,
navigateTo({ name: 'Preferences' }) onClick: () => {
}, navigateTo({ name: 'Preferences' })
}, },
) },
]
return result const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!)
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
watch(activeTab, (activeTab) => { watch(activeTab, (activeTab) => {
activeTab.onClick() activeTab.onClick()

View File

@@ -1,64 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<GalleryCard
v-for="item in gallery"
:key="item.client.socketId"
:client="item.client"
:stream="item.stream"
/>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
interface GalleryItem {
client: ChadClient
stream: MediaStream
}
definePageMeta({
name: 'Gallery',
})
const { videoProducer, shareProducer } = useMediasoup()
const { clients, me } = useClients()
const gallery = computed(() => {
return clients.value.reduce<GalleryItem[]>(
(acc, client) => {
const { streaming, videoConsumers, shareConsumers } = useClient(client.socketId)
if (!streaming.value)
return acc
for (const consumer of [...videoConsumers.value, ...shareConsumers.value]) {
acc.push({
client,
stream: new MediaStream([consumer.raw.track]),
})
}
return acc
},
[videoProducer.value, shareProducer.value].reduce<GalleryItem[]>((acc, producer) => {
if (!me.value || !producer || !producer.raw.track)
return acc
acc.push({
client: me.value,
stream: new MediaStream([producer.raw.track]),
})
return acc
}, []),
)
})
watch(gallery, (gallery) => {
if (gallery.length > 0)
return
navigateTo({ name: 'Index' })
})
</script>

View File

@@ -3,7 +3,7 @@
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<PrimeCard> <PrimeCard>
<template #content> <template #content>
The chat is under development. <ChatWidget />
</template> </template>
</PrimeCard> </PrimeCard>
</div> </div>

View File

@@ -11,7 +11,6 @@
option-label="label" option-label="label"
option-value="deviceId" option-value="deviceId"
input-id="inputDevice" input-id="inputDevice"
placeholder="No input device"
fluid fluid
:invalid="!inputDeviceExist" :invalid="!inputDeviceExist"
/> />
@@ -48,42 +47,6 @@
<!-- <label for="outputDevice">Output device</label> --> <!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> --> <!-- </PrimeFloatLabel> -->
<PrimeDivider align="left">
Video
</PrimeDivider>
<PrimeFloatLabel variant="on">
<PrimeSelect
v-model="videoDeviceId"
:options="videoInputs"
option-label="label"
option-value="deviceId"
input-id="videoDevice"
placeholder="No video device"
fluid
:invalid="!videoDeviceExist"
/>
<label for="inputDevice">Input device</label>
</PrimeFloatLabel>
<PrimeDivider align="left">
Screen sharing
</PrimeDivider>
<div>
<p class="text-sm mb-2 text-center">
FPS
</p>
<PrimeSelectButton
v-model="shareFps"
:options="shareFpsOptions"
fluid
size="small"
option-label="label"
option-value="value"
/>
</div>
<template v-if="isTauri"> <template v-if="isTauri">
<PrimeDivider align="left"> <PrimeDivider align="left">
Hotkeys Hotkeys
@@ -143,11 +106,10 @@ definePageMeta({
}) })
const { isTauri, version, commitSha } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices() const { audioInputs, audioOutputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -155,17 +117,8 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
shareFps,
} = usePreferences() } = usePreferences()
const shareFpsOptions = [5, 30, 60].map((value) => {
return {
label: value.toString(),
value,
}
})
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey) const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey) const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)

View File

@@ -86,8 +86,8 @@ export default defineNuxtConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
// target: 'http://localhost:4000/chad', target: 'http://localhost:4000/chad',
target: 'https://api.koptilnya.xyz/chad', // target: 'https://api.koptilnya.xyz/chad',
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => { rewrite: (path) => {

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev --host", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
@@ -20,7 +20,7 @@
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"hotkeys-js": "^4.0.0", "hotkeys-js": "^4.0.0",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.18.6", "mediasoup-client": "^3.16.7",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",

View File

@@ -1,5 +1,3 @@
import type { Consumer as MediasoupConsumer, Producer as MediasoupProducer } from 'mediasoup-client/types'
export interface ChadClient { export interface ChadClient {
socketId: string socketId: string
userId: string userId: string
@@ -7,30 +5,6 @@ export interface ChadClient {
displayName: string displayName: string
inputMuted?: boolean inputMuted?: boolean
outputMuted?: boolean outputMuted?: boolean
consumers: unknown[]
producers: unknown[]
volume: number
isDominant: boolean
}
export interface AppData {
socketId?: ChadClient['socketId']
source?: 'share' | 'mic-video'
}
export interface Consumer {
id: MediasoupConsumer['id']
paused: MediasoupConsumer['paused']
appData: AppData
raw: MediasoupConsumer
}
export interface Producer {
id: MediasoupProducer['id']
paused: MediasoupProducer['paused']
appData: AppData
raw: MediasoupProducer
} }
export type UpdatedClient = Omit<ChadClient, 'socketId' | 'userId' | 'isMe'> export type UpdatedClient = Omit<ChadClient, 'socketId' | 'userId' | 'isMe'>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 B

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,3 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M216 32C220.418 32 224 35.5817 224 40V205.846C224 212.802 215.735 216.444 210.602 211.75L191.292 194.096C189.818 192.748 187.892 192 185.895 192H40C35.5817 192 32 188.418 32 184V40C32 35.5817 35.5817 32 40 32H216ZM129.494 64.6367C121.221 64.6367 113.752 66.4847 107.085 70.1816C100.418 73.8786 95.1307 79.288 91.2217 86.4092C87.3127 93.5303 85.3585 102.212 85.3584 112.454C85.3584 122.666 87.2825 131.333 91.1309 138.454C94.9793 145.575 100.222 151 106.858 154.728C113.525 158.424 121.07 160.272 129.494 160.272C135.888 160.272 141.555 159.303 146.494 157.363C151.464 155.424 155.676 152.819 159.131 149.546C162.585 146.243 165.297 142.591 167.267 138.591C168.565 135.993 169.526 133.37 170.147 130.722C170.684 128.434 168.833 126.397 166.483 126.383L151.51 126.293C149.559 126.281 147.933 127.701 147.275 129.538C146.924 130.519 146.497 131.446 145.994 132.318C144.994 134.076 143.707 135.576 142.131 136.818C140.585 138.03 138.782 138.954 136.722 139.591C134.691 140.227 132.434 140.546 129.949 140.546C125.525 140.546 121.692 139.5 118.449 137.409C115.237 135.288 112.737 132.151 110.949 128C109.192 123.818 108.312 118.636 108.312 112.454C108.313 106.515 109.176 101.454 110.903 97.2725C112.661 93.0907 115.161 89.8937 118.403 87.6816C121.676 85.4696 125.57 84.3633 130.085 84.3633C132.63 84.3633 134.949 84.7268 137.04 85.4541C139.161 86.1511 140.995 87.1667 142.54 88.5C144.085 89.8333 145.327 91.4396 146.267 93.3184C146.721 94.2263 147.101 95.1871 147.406 96.2012C147.986 98.1257 149.635 99.6366 151.645 99.6367H166.447C168.81 99.6367 170.677 97.5927 170.23 95.2725C169.492 91.4407 168.292 87.9403 166.631 84.7725C164.358 80.4392 161.403 76.788 157.767 73.8184C154.13 70.8185 149.919 68.5454 145.131 67C140.343 65.4242 135.131 64.6367 129.494 64.6367Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -4,7 +4,7 @@ pub fn run() {
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|_, _, _| {})) // .plugin(tauri_plugin_single_instance::init(|_, _, _| {}))
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "chad", "productName": "chad",
"version": "0.2.27", "version": "0.2.17",
"identifier": "xyz.koptilnya.chad", "identifier": "xyz.koptilnya.chad",
"build": { "build": {
"frontendDist": "../.output/public", "frontendDist": "../.output/public",
@@ -12,14 +12,12 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"maximizable": true, "maximizable": false,
"label": "main", "label": "main",
"title": "Chad", "title": "Chad",
"width": 800, "width": 800,
"height": 600, "height": 600,
"minWidth": 800, "resizable": false,
"minHeight": 600,
"resizable": true,
"fullscreen": false, "fullscreen": false,
"center": true, "center": true,
"theme": "Dark", "theme": "Dark",
@@ -42,12 +40,7 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ]
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico"
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View File

@@ -4060,7 +4060,7 @@ __metadata:
eslint-plugin-format: "npm:^1.0.2" eslint-plugin-format: "npm:^1.0.2"
hotkeys-js: "npm:^4.0.0" hotkeys-js: "npm:^4.0.0"
lucide-vue-next: "npm:^0.562.0" lucide-vue-next: "npm:^0.562.0"
mediasoup-client: "npm:^3.18.6" mediasoup-client: "npm:^3.16.7"
nuxt: "npm:^4.2.2" nuxt: "npm:^4.2.2"
postcss: "npm:^8.5.6" postcss: "npm:^8.5.6"
primeicons: "npm:^7.0.0" primeicons: "npm:^7.0.0"
@@ -6176,12 +6176,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"h264-profile-level-id@npm:^2.3.2": "h264-profile-level-id@npm:^2.3.1":
version: 2.3.2 version: 2.3.1
resolution: "h264-profile-level-id@npm:2.3.2" resolution: "h264-profile-level-id@npm:2.3.1"
dependencies: dependencies:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
checksum: 10c0/75bd12ff36707ffacf379c31c403d4508f3116ef2065e375deadcfafd4f7d163521cf0c70ae5385ebac970fa0acc07f9dd497c4248cfc1ee5623b4533707731d checksum: 10c0/c3459549bb28e456db62428c79885cffd4958ce282099c4181b09576f8e5ad90b42395a77209fff4f20a7cb920aaeb660f73902f08343daead0f5527faeb4015
languageName: node languageName: node
linkType: hard linkType: hard
@@ -7302,9 +7302,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mediasoup-client@npm:^3.18.6": "mediasoup-client@npm:^3.16.7":
version: 3.18.6 version: 3.16.7
resolution: "mediasoup-client@npm:3.18.6" resolution: "mediasoup-client@npm:3.16.7"
dependencies: dependencies:
"@types/debug": "npm:^4.1.12" "@types/debug": "npm:^4.1.12"
"@types/events-alias": "npm:@types/events@^3.0.3" "@types/events-alias": "npm:@types/events@^3.0.3"
@@ -7312,10 +7312,10 @@ __metadata:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
events-alias: "npm:events@^3.3.0" events-alias: "npm:events@^3.3.0"
fake-mediastreamtrack: "npm:^2.2.1" fake-mediastreamtrack: "npm:^2.2.1"
h264-profile-level-id: "npm:^2.3.2" h264-profile-level-id: "npm:^2.3.1"
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^2.15.0"
supports-color: "npm:^10.2.2" supports-color: "npm:^10.2.2"
checksum: 10c0/f5baff9139afccf88de5db767c1139efa5cdd68f4871e2fa9d6ff94d2e71d2365dc40e9ba6e903cde5fbb51a2d82972e738da656be9f6fc7006640fdd82dd5da checksum: 10c0/da44c6de8889963192c5b0b7907ed628e04d48be73b7bbfbf18012d66b07ede9d7367c0723466e496a87c7002c07f1af432d854c4c5e16cbd0887013870d8abe
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9829,12 +9829,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sdp-transform@npm:^3.0.0": "sdp-transform@npm:^2.15.0":
version: 3.0.0 version: 2.15.0
resolution: "sdp-transform@npm:3.0.0" resolution: "sdp-transform@npm:2.15.0"
bin: bin:
sdp-verify: checker.js sdp-verify: checker.js
checksum: 10c0/828a4595041ba64c86b29075aa4007ab384519b1fa29882db59ccb83b54b2b2a33b60848293f8da537fe151c52f5844fc17c8325396cac309fb19e2e81ec5bf4 checksum: 10c0/96c060f113a3d5418defa168db609f7e23e5bd7954fa1cf7784f103dbe702e24d667e5310d2ac6d88abdb32322af83d6ebd0df08e07f4f172d5ed5888f921386
languageName: node languageName: node
linkType: hard linkType: hard

12
node_modules/.yarn-integrity generated vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"systemParams": "win32-x64-137",
"modulesFolders": [
"node_modules"
],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [],
"lockfileEntries": {},
"files": [],
"artifacts": {}
}

View File

@@ -0,0 +1,22 @@
import client from '../../prisma/client.ts'
export async function chatInit() {
const existing = client.chatChannel.findFirst({
where: {
id: 0,
},
})
if (!existing) {
await client.chatChannel.create({
create: {
id: 0,
name: 'Main channel',
},
update: null,
where: {
id: 0,
},
})
}
}

View File

@@ -22,8 +22,8 @@ export default fp<Partial<ServerOptions>>(
await fastify.io.close() await fastify.io.close()
}) })
fastify.ready(async () => { fastify.ready(() => {
await registerWebrtcSocket(fastify.io, fastify.mediasoupRouter) registerWebrtcSocket(fastify.io, fastify.mediasoupRouter)
}) })
}, },
{ name: 'socket-io', dependencies: ['mediasoup-worker', 'mediasoup-router'] }, { name: 'socket-io', dependencies: ['mediasoup-worker', 'mediasoup-router'] },

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `volumes` on the `UserPreferences` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT DEFAULT '',
"toggleOutputHotkey" TEXT DEFAULT '',
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"content" TEXT NOT NULL DEFAULT '',
CONSTRAINT "ChatMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ChatChannel" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "ChatChannel_name_key" ON "ChatChannel"("name");

View File

@@ -0,0 +1,34 @@
/*
Warnings:
- The primary key for the `ChatChannel` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to alter the column `id` on the `ChatChannel` table. The data in that column could be lost. The data in that column will be cast from `String` to `Int`.
- You are about to alter the column `channelId` on the `ChatMessage` table. The data in that column could be lost. The data in that column will be cast from `String` to `Int`.
- Added the required column `createdAt` to the `ChatMessage` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ChatChannel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
INSERT INTO "new_ChatChannel" ("id", "name") SELECT "id", "name" FROM "ChatChannel";
DROP TABLE "ChatChannel";
ALTER TABLE "new_ChatChannel" RENAME TO "ChatChannel";
CREATE UNIQUE INDEX "ChatChannel_name_key" ON "ChatChannel"("name");
CREATE TABLE "new_ChatMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" INTEGER NOT NULL,
"content" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL,
CONSTRAINT "ChatMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ChatMessage" ("channelId", "content", "id", "userId") SELECT "channelId", "content", "id", "userId" FROM "ChatMessage";
DROP TABLE "ChatMessage";
ALTER TABLE "new_ChatMessage" RENAME TO "ChatMessage";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ChatMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ChatMessage" ("channelId", "content", "createdAt", "id", "userId") SELECT "channelId", "content", "createdAt", "id", "userId" FROM "ChatMessage";
DROP TABLE "ChatMessage";
ALTER TABLE "new_ChatMessage" RENAME TO "ChatMessage";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -18,6 +18,8 @@ model User {
Session Session[] Session Session[]
UserPreferences UserPreferences? UserPreferences UserPreferences?
ChatMessage ChatMessage[]
} }
model Session { model Session {
@@ -34,7 +36,26 @@ model UserPreferences {
userId String @id userId String @id
toggleInputHotkey String? @default("") toggleInputHotkey String? @default("")
toggleOutputHotkey String? @default("") toggleOutputHotkey String? @default("")
volumes Json? @default("{}")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model ChatMessage {
id String @id
userId String
channelId Int
content String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
}
model ChatChannel {
id Int @id
name String @unique
messages ChatMessage[]
}

23
server/routes/chat.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { FastifyInstance } from 'fastify'
import prisma from '../prisma/client.ts'
export default function (fastify: FastifyInstance) {
fastify.get('/chats', async (req, reply) => {
if (req.user) {
return prisma.chatChannel.findMany()
}
reply.code(401).send(false)
})
fastify.get('/chats/:id', async (req, reply) => {
if (req.user) {
console.log('Trying to fetch chat with id', req.body.id)
// return prisma.userPreferences.findFirst({ where: { userId: req.user.id } })
return true
}
reply.code(401).send(false)
})
}

View File

@@ -4,6 +4,7 @@ import FastifyAutoLoad from '@fastify/autoload'
import FastifyCookie from '@fastify/cookie' import FastifyCookie from '@fastify/cookie'
import FastifyCors from '@fastify/cors' import FastifyCors from '@fastify/cors'
import Fastify from 'fastify' import Fastify from 'fastify'
import { chatInit } from './modules/chat/index.ts'
import prisma from './prisma/client.ts' import prisma from './prisma/client.ts'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
@@ -43,6 +44,8 @@ fastify.register(FastifyAutoLoad, {
await prisma.$connect() await prisma.$connect()
fastify.log.info('Testing DB Connection. OK') fastify.log.info('Testing DB Connection. OK')
await chatInit()
} }
catch (err) { catch (err) {
fastify.log.error(err) fastify.log.error(err)

View File

@@ -1,7 +1,6 @@
import type { types } from 'mediasoup' import type { types } from 'mediasoup'
import type { Server as SocketServer } from 'socket.io' import type { Server as SocketServer } from 'socket.io'
import type { import type {
ChadClient,
Namespace, Namespace,
SomeSocket, SomeSocket,
} from '../types/webrtc.ts' } from '../types/webrtc.ts'
@@ -9,39 +8,9 @@ import { consola } from 'consola'
import prisma from '../prisma/client.ts' import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts' import { socketToClient } from '../utils/socket-to-client.ts'
export default async function (io: SocketServer, router: types.Router) { export default function (io: SocketServer, router: types.Router) {
const namespace: Namespace = io.of('/webrtc') const namespace: Namespace = io.of('/webrtc')
const audioLevelObserver = await router.createAudioLevelObserver({
maxEntries: 10,
threshold: -80,
interval: 800,
})
const activeSpeakerObserver = await router.createActiveSpeakerObserver()
audioLevelObserver.on('volumes', async (volumes: types.AudioLevelObserverVolume[]) => {
namespace.emit('speakingPeers', volumes.map(({ producer, volume }) => {
const { socketId } = producer.appData as { socketId: ChadClient['socketId'] }
return {
clientId: socketId,
volume,
}
}))
})
audioLevelObserver.on('silence', () => {
namespace.emit('speakingPeers', [])
namespace.emit('activeSpeaker', undefined)
})
activeSpeakerObserver.on('dominantspeaker', ({ producer }) => {
const { socketId } = producer.appData as { socketId: ChadClient['socketId'] }
namespace.emit('activeSpeaker', socketId)
})
namespace.on('connection', async (socket) => { namespace.on('connection', async (socket) => {
consola.info('[WebRtc]', 'Client connected', socket.id) consola.info('[WebRtc]', 'Client connected', socket.id)
@@ -213,8 +182,8 @@ export default async function (io: SocketServer, router: types.Router) {
) )
} }
await audioLevelObserver.addProducer({ producerId: producer.id }) // TODO: Add into the AudioLevelObserver and ActiveSpeakerObserver.
await activeSpeakerObserver.addProducer({ producerId: producer.id }) // https://github.com/versatica/mediasoup-demo/blob/v3/server/lib/Room.js#L1276
} }
catch (error) { catch (error) {
if (error instanceof Error) { if (error instanceof Error) {

View File

@@ -118,8 +118,6 @@ export interface ServerToClientEvents {
consumerResumed: (arg: { consumerId: string }) => void consumerResumed: (arg: { consumerId: string }) => void
consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void
clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void
speakingPeers: (arg: { clientId: ChadClient['socketId'], volume: types.AudioLevelObserverVolume['volume'] }[]) => void
activeSpeaker: (clientId?: ChadClient['socketId']) => void
} }
export interface InterServerEvent {} export interface InterServerEvent {}

4
yarn.lock Normal file
View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1