Merge branch 'master' into channels

# Conflicts:
#	server/plugins/socket.ts
#	server/socket/webrtc.ts
This commit is contained in:
2026-02-12 18:12:21 +06:00
239 changed files with 1153 additions and 230 deletions

View File

@@ -17,10 +17,12 @@ declare module 'vue' {
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputText: typeof import('primevue/inputtext')['default']
PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default']
PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

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

View File

@@ -0,0 +1,25 @@
<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

@@ -0,0 +1,20 @@
<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

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

View File

@@ -0,0 +1,54 @@
<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, Consumer, Producer } from '#shared/types'
const props = defineProps<{
client: ChadClient
consumer?: Consumer
producer?: Producer
}>()
const { me } = useClients()
const fullscreenVideo = useFullscreenVideo()
const isMe = computed(() => {
return props.client.socketId === me.value?.socketId
})
const track = computed(() => {
return props.consumer?.raw.track ?? props.producer?.raw.track
})
const stream = computed<MediaStream | undefined>((previousStream) => {
if (previousStream?.getTracks()[0] === track.value) {
return previousStream
}
return track.value ? new MediaStream([track.value]) : undefined
})
function watch() {
if (stream.value)
fullscreenVideo.show(stream.value)
}
</script>
<style>
</style>

View File

@@ -7,7 +7,7 @@ export const useApp = createGlobalState(() => {
const { clients } = useClients()
const mediasoup = useMediasoup()
const signaling = useSignaling()
const toast = useToast()
const { emit } = useEventBus()
const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
@@ -31,30 +31,41 @@ export const useApp = createGlobalState(() => {
const outputMuted = ref(false)
const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
const sharingEnabled = computed(() => {
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() {
if (inputMuted.value)
if (inputMuted.value || !mediasoup.micProducer.value)
return
await mediasoup.pauseProducer('microphone')
await mediasoup.pauseProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
emit('audio:muted')
}
async function unmuteInput() {
if (!inputMuted.value)
if (!inputMuted.value || !mediasoup.micProducer.value)
return
if (outputMuted.value) {
await unmuteOutput()
}
await mediasoup.resumeProducer('microphone')
await mediasoup.resumeProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
emit('audio:unmuted')
}
async function toggleInput() {
@@ -78,7 +89,7 @@ export const useApp = createGlobalState(() => {
outputMuted: true,
})
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
emit('output:muted')
}
async function unmuteOutput() {
@@ -91,7 +102,7 @@ export const useApp = createGlobalState(() => {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
emit('output:unmuted')
}
async function toggleOutput() {
@@ -101,12 +112,25 @@ export const useApp = createGlobalState(() => {
await muteOutput()
}
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
emit('video:enabled')
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
emit('video:disabled')
}
}
async function toggleShare() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
emit('share:enabled')
}
else {
await mediasoup.disableProducer('share')
await mediasoup.disableProducer(mediasoup.shareProducer.value)
emit('share:disabled')
}
}
@@ -121,10 +145,13 @@ export const useApp = createGlobalState(() => {
muteOutput,
unmuteOutput,
toggleOutput,
toggleVideo,
version,
isTauri,
commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
}
})

View File

@@ -0,0 +1,52 @@
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

@@ -4,7 +4,7 @@ import { createGlobalState } from '@vueuse/core'
export const useClients = createGlobalState(() => {
const auth = useAuth()
const signaling = useSignaling()
const toast = useToast()
const { emit } = useEventBus()
const clients = shallowRef<ChadClient[]>([])
@@ -16,10 +16,17 @@ export const useClients = createGlobalState(() => {
socket.on('clientChanged', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => {
const client = getClient(clientId)
if (!client)
return
updateClient(clientId, updatedClient)
if (client && client.displayName !== updatedClient.displayName)
toast.add({ severity: 'info', summary: `${client.displayName} is now ${updatedClient.displayName}`, closable: false, life: 1000 })
emit('client:updated', {
socketId: clientId,
oldClient: client,
updatedClient,
})
})
socket.on('disconnect', () => {

View File

@@ -19,7 +19,16 @@ export const useDevices = createGlobalState(() => {
})
}
;(async () => {
if (permissionGranted.value)
return
await ensurePermissions()
})()
return {
ensurePermissions,
permissionGranted,
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))),

View File

@@ -0,0 +1,42 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { EventType } from 'mitt'
import mitt from 'mitt'
export interface AppEvents extends Record<EventType, unknown> {
'socket:connected': void
'socket:disconnected': void
'socket:authenticated': { socketId: string }
'client:added': ChadClient
'client:removed': ChadClient
'client:updated': { socketId: string, oldClient: ChadClient, updatedClient: Partial<ChadClient> }
'consumer:added': Consumer
'consumer:removed': Consumer
'consumer:paused': Consumer
'consumer:resumed': Consumer
'producer:added': Producer
'producer:removed': Producer
'producer:paused': Producer
'producer:resumed': Producer
'audio:muted': void
'audio:unmuted': void
'output:muted': void
'output:unmuted': void
'video:enabled': void
'video:disabled': void
'share:enabled': void
'share:disabled': void
}
const emitter = mitt<AppEvents>()
export function useEventBus() {
return {
emit: emitter.emit,
on: emitter.on,
off: emitter.off,
}
}

View File

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

View File

@@ -1,11 +1,16 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling'
type ProducerType = 'microphone' | 'camera' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
@@ -21,10 +26,10 @@ const ICE_SERVERS: RTCIceServer[] = [
]
export const useMediasoup = createSharedComposable(() => {
const toast = useToast()
const { emit } = useEventBus()
const signaling = useSignaling()
const { addClient, removeClient } = useClients()
const { addClient, removeClient, me } = useClients()
const preferences = usePreferences()
const { getShareStream } = useDevices()
@@ -33,12 +38,42 @@ export const useMediasoup = createSharedComposable(() => {
const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const micProducer = shallowRef<mediasoupClient.types.Producer>()
const cameraProducer = shallowRef<mediasoupClient.types.Producer>()
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumers = ref<Record<Consumer['id'], Consumer>>({})
const producers = ref<Record<Producer['id'], Producer>>({})
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
const consumersArray = computed(() => {
return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => {
if (!socket)
@@ -132,17 +167,25 @@ export const useMediasoup = createSharedComposable(() => {
addClient(...joinedClients)
toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 })
if (me.value)
emit('socket:authenticated', { socketId: me.value.socketId })
await enableMic()
})
socket.on('newPeer', (client) => {
addClient(client)
emit('client:added', client)
})
socket.on('peerClosed', (id) => {
const { getClient } = useClients()
const client = getClient(id)
removeClient(id)
if (client)
emit('client:removed', client)
})
socket.on(
@@ -159,20 +202,46 @@ export const useMediasoup = createSharedComposable(() => {
producerId,
kind,
rtpParameters,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId },
})
if (producerPaused)
consumer.pause()
consumer.on('transportclose', () => {
if (consumers.value.delete(consumer.id))
triggerRef(consumers)
consumers.value[consumer.id] = {
id: consumer.id,
paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
emit('consumer:added', consumers.value[consumer.id]!)
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
emit('consumer:resumed', consumers.value[consumer.id]!)
})
consumers.value.set(consumer.id, consumer)
triggerRef(consumers)
consumer.observer.on('pause', () => {
consumers.value[consumer.id]!.paused = true
emit('consumer:paused', consumers.value[consumer.id]!)
})
consumer.observer.on('close', () => {
const consumerData = consumers.value[consumer.id]
delete consumers.value[consumer.id]
if (consumerData)
emit('consumer:removed', consumerData)
})
consumer.on('trackended', () => {
consumer.close()
})
cb()
},
@@ -183,11 +252,37 @@ export const useMediasoup = createSharedComposable(() => {
async (
{ consumerId },
) => {
if (consumers.value.delete(consumerId))
triggerRef(consumers)
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.close()
},
)
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => {
device.value = undefined
rtpCapabilities.value = undefined
@@ -198,43 +293,12 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close()
recvTransport.value = undefined
micProducer.value = undefined
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)
consumers.value = {}
producers.value = {}
})
}, { immediate: true, flush: 'sync' })
async function enableProducer(type: ProducerType, options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
async function createProducer(options: ProducerOptions) {
if (!device.value || !sendTransport.value)
return
@@ -244,47 +308,65 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind))
return
producer.value = await sendTransport.value.produce(options)
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producers.value.set(producer.value.id, producer.value)
triggerRef(producers)
triggerRef(producer)
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producer.value.on('transportclose', () => {
micProducer.value = undefined
emit('producer:added', producers.value[producer.id]!)
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
emit('producer:paused', producers.value[producer.id]!)
})
producer.value.on('trackended', () => {
disableProducer(type)
producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false
emit('producer:resumed', producers.value[producer.id]!)
})
producer.observer.on('close', () => {
const producerData = producers.value[producer.id]
delete producers.value[producer.id]
if (producerData)
emit('producer:removed', producerData)
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
})
}
async function disableProducer(type: ProducerType) {
const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
return
producers.value.delete(producer.value.id)
try {
producer.value.close()
producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.value.id,
producerId: producer.id,
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(producer)
delete producers.value[producer.id]
}
producer.value = undefined
}
async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: preferences.inputDeviceId.value },
@@ -299,21 +381,67 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
await enableProducer('microphone', {
await createProducer({
track,
streamId: 'mic-video',
codecOptions: {
opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос
},
appData: {
source: 'mic-video',
},
})
}
async function disableMic() {
await disableProducer('microphone')
if (!micProducer.value)
return
await disableProducer(micProducer.value)
}
async function enableVideo() {
if (videoProducer.value)
return
if (!device.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
@@ -324,85 +452,54 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
await enableProducer('share', {
await createProducer({
track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/h264',
c => c.mimeType.toLowerCase() === 'video/AV1',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(type: ProducerType) {
async function pauseProducer(producer: Producer) {
if (!signaling.socket.value)
return
const producer = getProducerByType(type)
if (!producer.value)
return
if (producer.value.paused)
if (producer.paused)
return
try {
producer.value.pause()
producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.value.id,
producerId: producer.id,
})
}
catch {
producer.value.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
producer.raw.resume()
}
}
async function resumeProducer(type: ProducerType) {
async function resumeProducer(producer: Producer) {
if (!signaling.socket.value)
return
const producer = getProducerByType(type)
if (!producer.value)
return
try {
producer.value.resume()
producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.value.id,
producerId: producer.id,
})
}
catch {
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
producer.raw.pause()
}
}
@@ -421,18 +518,22 @@ export const useMediasoup = createSharedComposable(() => {
})
return {
init,
consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers,
speakingClients,
sendTransport,
recvTransport,
rtpCapabilities,
device,
micProducer,
cameraProducer,
videoProducer,
shareProducer,
pauseProducer,
resumeProducer,
enableVideo,
enableShare,
disableProducer,
}

View File

@@ -14,6 +14,7 @@ export const usePreferences = createGlobalState(() => {
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_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 noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
@@ -32,6 +33,10 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
})
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced(
[toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -56,6 +61,7 @@ export const usePreferences = createGlobalState(() => {
fetched,
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
@@ -64,5 +70,6 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
}
})

View File

@@ -0,0 +1,93 @@
import { createSharedComposable } from '@vueuse/core'
import { Howl } from 'howler'
const CONNECTION_SOUNDS = Object.keys(import.meta.glob('@/../public/sfx/connection/*.ogg')).map(path => path.replace('../public', ''))
console.log('CONNECTION_SOUNDS', CONNECTION_SOUNDS)
function hashStringToNumber(str: string, cap: number): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) | 0
}
return Math.abs(hash) % cap
}
const oneShots: Howl[] = []
type SfxEvent = 'mic-on' | 'mic-off' | 'stream-on' | 'stream-off' | 'connection'
const EVENT_VOLUME: Record<SfxEvent, number> = {
'mic-on': 0.2,
'mic-off': 0.2,
'stream-on': 0.03,
'stream-off': 0.03,
'connection': 0.1,
}
// TODO: refactor this shit
export const useSfx = createSharedComposable(() => {
const { outputMuted } = useApp()
async function play(src: string, volume = 0.2): Promise<void> {
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
howl.on('end', () => {
resolve()
})
})
}
async function playOneShot(src: string, volume = 0.2): Promise<void> {
for (const oneShot of oneShots) {
oneShot.stop()
}
oneShots.length = 0
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
oneShots.push(howl)
howl.on('end', () => {
resolve()
})
})
}
async function playEvent(event: SfxEvent) {
switch (event) {
default:
await playOneShot(`/sfx/${event}.ogg`, EVENT_VOLUME[event])
break
}
}
async function playRandomConnectionSound(seed: string) {
await playEvent('stream-on')
if (outputMuted.value)
return
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length)]!, 0.1)
}
return {
playOneShot,
play,
playRandomConnectionSound,
playEvent,
}
})

View File

@@ -4,7 +4,7 @@ import { io } from 'socket.io-client'
import { parseURL } from 'ufo'
export const useSignaling = createSharedComposable(() => {
const toast = useToast()
const { emit } = useEventBus()
const { me } = useAuth()
const socket = shallowRef<Socket>()
@@ -41,9 +41,9 @@ export const useSignaling = createSharedComposable(() => {
watch(connected, (connected) => {
if (connected)
toast.add({ severity: 'success', summary: 'Connected', closable: false, life: 1000 })
emit('socket:connected')
else
toast.add({ severity: 'error', summary: 'Disconnected', closable: false, life: 1000 })
emit('socket:disconnected')
}, { immediate: true })
watch(me, (me) => {

View File

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

View File

@@ -0,0 +1,56 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<GalleryCard
v-for="producer in producers"
:key="`producer-${producer.id}`"
:client="me!"
:producer="producer"
/>
<GalleryCard
v-for="consumer in consumers"
:key="`consumer-${consumer.consumer.id}`"
:client="consumer.client"
:consumer="consumer.consumer"
/>
</div>
</template>
<script setup lang="ts">
import type { ChadClient, Consumer, Producer } from '#shared/types'
definePageMeta({
name: 'Gallery',
})
const { videoProducer, shareProducer } = useMediasoup()
const { clients, me } = useClients()
const producers = computed(() => {
return [videoProducer.value, shareProducer.value].filter(p => !!p && !!p.raw.track) as Producer[]
})
const consumers = computed(() => {
return clients.value.reduce<{ client: ChadClient, consumer: Consumer }[]>((acc, client) => {
const { streaming, videoConsumers: clientVideoConsumers, shareConsumers: clientShareConsumers } = useClient(client.socketId)
if (!streaming.value)
return acc
for (const consumer of [...clientVideoConsumers.value, ...clientShareConsumers.value]) {
acc.push({ client, consumer })
}
return acc
}, [])
})
const hasItems = computed(() => producers.value.length > 0 || consumers.value.length > 0)
watch(hasItems, (hasItems) => {
if (hasItems)
return
navigateTo({ name: 'Index' })
})
</script>

View File

@@ -11,6 +11,7 @@
option-label="label"
option-value="deviceId"
input-id="inputDevice"
placeholder="No input device"
fluid
:invalid="!inputDeviceExist"
/>
@@ -51,12 +52,36 @@
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>
<div class="flex justify-between text-sm mb-3">
<span>FPS</span>
<span>{{ shareFps }}</span>
</div>
<PrimeSlider v-model="shareFps" class="mx-[10px]" :min="30" :max="60" :step="5" />
<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">
@@ -118,10 +143,11 @@ definePageMeta({
})
const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs } = useDevices()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const {
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
@@ -129,9 +155,17 @@ const {
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
shareFps,
} = usePreferences()
const shareFpsOptions = [5, 30, 60].map((value) => {
return {
label: value.toString(),
value,
}
})
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)

View File

@@ -0,0 +1,57 @@
export default defineNuxtPlugin(() => {
const { on } = useEventBus()
const sfx = useSfx()
// Connection sounds
on('socket:authenticated', ({ socketId }) => {
sfx.playRandomConnectionSound(socketId)
})
// Client events
on('client:added', (client) => {
sfx.playRandomConnectionSound(client.socketId)
})
on('client:removed', () => {
sfx.playEvent('stream-off')
})
// Audio mute/unmute
on('audio:muted', () => {
sfx.playEvent('mic-off')
})
on('audio:unmuted', () => {
sfx.playEvent('mic-on')
})
// Video/share toggle
on('video:enabled', () => {
sfx.playEvent('stream-on')
})
on('video:disabled', () => {
sfx.playEvent('stream-off')
})
on('share:enabled', () => {
sfx.playEvent('stream-on')
})
on('share:disabled', () => {
sfx.playEvent('stream-off')
})
// Consumer video streams
on('consumer:added', (consumer) => {
if (consumer.raw.kind === 'video') {
sfx.playEvent('stream-on')
}
})
on('consumer:removed', (consumer) => {
if (consumer.raw.kind === 'video') {
sfx.playEvent('stream-off')
}
})
})

View File

@@ -0,0 +1,62 @@
export default defineNuxtPlugin(() => {
const { on } = useEventBus()
const toast = useToast()
// Socket events
on('socket:connected', () => {
toast.add({ severity: 'success', summary: 'Connected', life: 1000 })
})
on('socket:disconnected', () => {
toast.add({ severity: 'error', summary: 'Disconnected', life: 1000 })
})
on('socket:authenticated', () => {
toast.add({ severity: 'success', summary: 'Joined', life: 1000 })
})
// Client events
on('client:added', (client) => {
toast.add({
severity: 'info',
summary: `${client.displayName} connected`,
life: 1000,
})
})
on('client:removed', (client) => {
toast.add({
severity: 'info',
summary: `${client.displayName} disconnected`,
life: 1000,
})
})
on('client:updated', ({ oldClient, updatedClient }) => {
if (oldClient.displayName !== updatedClient.displayName) {
toast.add({
severity: 'info',
summary: `${oldClient.displayName} is now ${updatedClient.displayName}`,
life: 1000,
})
}
})
// Audio control
on('audio:muted', () => {
toast.add({ severity: 'info', summary: 'Microphone muted', life: 1000 })
})
on('audio:unmuted', () => {
toast.add({ severity: 'info', summary: 'Microphone activated', life: 1000 })
})
// Output mute control
on('output:muted', () => {
toast.add({ severity: 'info', summary: 'Sound muted', life: 1000 })
})
on('output:unmuted', () => {
toast.add({ severity: 'info', summary: 'Sound resumed', life: 1000 })
})
})

View File