1 Commits

Author SHA1 Message Date
269b19a5be вебкамера там, туда-сюда
All checks were successful
Deploy / publish-web (push) Successful in 1m16s
2026-02-02 14:39:16 +06:00
20 changed files with 546 additions and 211 deletions

Binary file not shown.

View File

@@ -28,7 +28,7 @@
</div> </div>
<div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2"> <div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" /> <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-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" />
@@ -60,7 +60,6 @@
<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'
@@ -69,44 +68,40 @@ const props = defineProps<{
}>() }>()
const { outputMuted } = useApp() const { outputMuted } = useApp()
const { consumers: allConsumers, micProducer, speakingClients } = useMediasoup() const { consumers: allConsumers, micProducer } = useMediasoup()
const { me } = useClients() const { me } = useClients()
const { show } = useFullscreenVideo() const { show } = useFullscreenVideo()
const expanded = ref(false) const expanded = ref(false)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false }) const {
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false }) volume,
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 consumers.value.find(consumer => consumer.track.kind === 'audio') return audioConsumers.value[0]
})
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?.track return audioConsumer.value?.raw.track
}) })
const speaking = computed(() => { const audioConsumerPaused = computed(() => {
return speakingClients.value.find(speaker => speaker.clientId === props.client.socketId) if (Object.keys(allConsumers.value).length === 0)
}) return false
const audioConsumerPaused = ref(false) return audioConsumer.value?.paused ?? false
})
const inputMuted = computed(() => { const inputMuted = computed(() => {
if (isMe.value) if (isMe.value)
@@ -116,17 +111,13 @@ const inputMuted = computed(() => {
}) })
const hasBadges = computed(() => { const hasBadges = computed(() => {
return !!shareConsumer.value return shareConsumers.value.length > 0
|| premuted.value || premuted.value
|| inputMuted.value || inputMuted.value
|| props.client.outputMuted || props.client.outputMuted
|| isMe.value || isMe.value
}) })
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack) const { setGain } = useAudioContext(audioTrack)
watchEffect(() => { watchEffect(() => {
@@ -141,9 +132,11 @@ function toggleExpand() {
} }
function watchStream() { function watchStream() {
if (!shareConsumer.value) if (!streaming.value)
return return
show(new MediaStream([shareConsumer.value.track])) const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.track]))
} }
</script> </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,21 @@
<template>
<div class="cursor-pointer hover:outline outline-primary rounded overflow-hidden flex items-center justify-center">
<video :srcObject="mediaStream" muted autoplay />
</div>
</template>
<script setup lang="ts">
import type { Consumer } from '#shared/types'
const props = defineProps<{
consumer: Consumer
}>()
const mediaStream = computed(() => {
return new MediaStream([props.consumer.raw.track])
})
</script>
<style>
</style>

View File

@@ -31,28 +31,36 @@ 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.videoConsumers.value.length > 0 || mediasoup.shareConsumers.value.length > 0
})
async function muteInput() { async function muteInput() {
if (inputMuted.value) if (inputMuted.value || !mediasoup.micProducer.value)
return return
await mediasoup.pauseProducer('microphone') await mediasoup.pauseProducer(mediasoup.micProducer.value)
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) if (!inputMuted.value || !mediasoup.micProducer.value)
return return
if (outputMuted.value) { if (outputMuted.value) {
await unmuteOutput() await unmuteOutput()
} }
await mediasoup.resumeProducer('microphone') await mediasoup.resumeProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
@@ -101,12 +109,21 @@ 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('share') await mediasoup.disableProducer(mediasoup.shareProducer.value)
} }
} }
@@ -121,10 +138,13 @@ 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,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 producers = computed(() => {
return mediasoup.producers.value.values().filter(producer => producer.appData.socketId === client.value.socketId).toArray()
})
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 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,7 +19,16 @@ 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

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

View File

@@ -1,4 +1,4 @@
import type { SpeakingClient } from '#shared/types' 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'
@@ -7,7 +7,12 @@ 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' | 'camera' | 'share' type ProducerType = 'microphone' | 'video' | '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' },
@@ -35,12 +40,40 @@ 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 micProducer = shallowRef<mediasoupClient.types.Producer>() const consumers = ref<Record<Consumer['id'], Consumer>>({})
const cameraProducer = shallowRef<mediasoupClient.types.Producer>() const producers = ref<Record<Producer['id'], Producer>>({})
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map()) const consumersArray = computed(() => {
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map()) 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[]>([]) const speakingClients = shallowRef<SpeakingClient[]>([])
@@ -163,20 +196,35 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumer.on('transportclose', () => { consumers.value[consumer.id] = {
if (consumers.value.delete(consumer.id)) id: consumer.id,
triggerRef(consumers) paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
}) })
consumers.value.set(consumer.id, consumer) consumer.observer.on('pause', () => {
triggerRef(consumers) consumers.value[consumer.id]!.paused = true
})
consumer.observer.on('close', () => {
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -187,11 +235,37 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
if (consumers.value.delete(consumerId)) const consumer = consumers.value[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
@@ -202,47 +276,12 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
micProducer.value = undefined consumers.value = {}
cameraProducer.value = undefined producers.value = {}
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)
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
async function enableProducer(type: ProducerType, options: ProducerOptions) { async function createProducer(options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value) if (!device.value || !sendTransport.value)
return return
@@ -252,47 +291,54 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind)) if (!device.value.canProduce(options.track.kind as MediaKind))
return 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) producers.value[producer.id] = {
triggerRef(producers) id: producer.id,
triggerRef(producer) paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producer.value.on('transportclose', () => { producer.observer.on('pause', () => {
micProducer.value = undefined producers.value[producer.id]!.paused = true
}) })
producer.value.on('trackended', () => { producer.observer.on('resume', () => {
disableProducer(type) producers.value[producer.id]!.paused = false
})
producer.observer.on('close', () => {
delete producers.value[producer.id]
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
}) })
} }
async function disableProducer(type: ProducerType) { async function disableProducer(producer: Producer) {
const producer = getProducerByType(type) if (!signaling.socket.value)
if (!signaling.socket.value || !producer.value)
return return
producers.value.delete(producer.value.id)
try { try {
producer.value.close() producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', { await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
} }
finally { finally {
triggerRef(producers) delete producers.value[producer.id]
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 },
@@ -307,21 +353,64 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await enableProducer('microphone', { await createProducer({
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() {
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 },
},
})
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
@@ -332,85 +421,54 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await enableProducer('share', { await createProducer({
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/AV1',
), ),
codecOptions: { codecOptions: {
videoGoogleStartBitrate: 1000, videoGoogleStartBitrate: 1000,
}, },
zeroRtpOnPause: true,
appData: { appData: {
source: 'share', source: 'share',
}, },
}) })
} }
async function pauseProducer(type: ProducerType) { async function pauseProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type) if (producer.paused)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.value.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.resume() producer.raw.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(type: ProducerType) { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.value.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.pause() producer.raw.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
} }
} }
@@ -429,8 +487,10 @@ export const useMediasoup = createSharedComposable(() => {
}) })
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients, speakingClients,
sendTransport, sendTransport,
@@ -438,10 +498,11 @@ export const useMediasoup = createSharedComposable(() => {
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
cameraProducer, videoProducer,
shareProducer, shareProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
enableShare, enableShare,
disableProducer, disableProducer,
} }

View File

@@ -14,6 +14,7 @@ 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)
@@ -32,6 +33,10 @@ 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]) => {
@@ -56,6 +61,7 @@ export const usePreferences = createGlobalState(() => {
synced, synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -64,5 +70,6 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
} }
}) })

View File

@@ -21,6 +21,12 @@
</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" />
@@ -44,28 +50,33 @@
</PrimeSelectButton> </PrimeSelectButton>
</div> </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"> <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" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" 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,
@@ -76,9 +87,12 @@ const {
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()
@@ -91,31 +105,47 @@ interface Tab {
const route = useRoute() const route = useRoute()
const tabs: Tab[] = [ const tabs = computed<Tab[]>(() => {
{ const result = []
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 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) => { watch(activeTab, (activeTab) => {
activeTab.onClick() activeTab.onClick()

View File

@@ -0,0 +1,38 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<template v-for="(group, id) in groupedConsumers" :key="id">
<GalleryCard
v-for="consumer in group"
:key="consumer.id"
:consumer="consumer"
@click="watch(consumer)"
/>
</template>
</div>
</template>
<script setup lang="ts">
import type { Consumer } from '#shared/types'
definePageMeta({
name: 'Gallery',
})
const { videoConsumers, shareConsumers } = useMediasoup()
const fullscreenVideo = useFullscreenVideo()
const groupedConsumers = computed<Partial<Record<string, Consumer[]>>>(() => {
if (fullscreenVideo.visible.value)
return {}
const consumers = [...videoConsumers.value, ...shareConsumers.value]
return Object.groupBy(consumers, (consumer) => {
return consumer.appData.socketId!
})
})
function watch(consumer: Consumer) {
fullscreenVideo.show(new MediaStream([consumer.raw.track]))
}
</script>

View File

@@ -11,6 +11,7 @@
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"
/> />
@@ -51,11 +52,28 @@
Video Video
</PrimeDivider> </PrimeDivider>
<div> <PrimeFloatLabel variant="on">
<div class="text-sm mb-2"> <PrimeSelect
Share FPS v-model="videoDeviceId"
</div> :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="[5, 30, 60]" fluid size="small" /> <PrimeSelectButton v-model="shareFps" :options="[5, 30, 60]" fluid size="small" />
</div> </div>
@@ -118,10 +136,11 @@ definePageMeta({
}) })
const { isTauri, version, commitSha } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs } = useDevices() const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -129,6 +148,7 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
shareFps, shareFps,
} = usePreferences() } = usePreferences()

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", "dev": "nuxt dev --host",
"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.16.7", "mediasoup-client": "^3.18.6",
"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,3 +1,5 @@
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
@@ -5,11 +7,30 @@ 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'>
export interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}

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.22", "version": "0.2.23",
"identifier": "xyz.koptilnya.chad", "identifier": "xyz.koptilnya.chad",
"build": { "build": {
"frontendDist": "../.output/public", "frontendDist": "../.output/public",

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.16.7" mediasoup-client: "npm:^3.18.6"
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.1": "h264-profile-level-id@npm:^2.3.2":
version: 2.3.1 version: 2.3.2
resolution: "h264-profile-level-id@npm:2.3.1" resolution: "h264-profile-level-id@npm:2.3.2"
dependencies: dependencies:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
checksum: 10c0/c3459549bb28e456db62428c79885cffd4958ce282099c4181b09576f8e5ad90b42395a77209fff4f20a7cb920aaeb660f73902f08343daead0f5527faeb4015 checksum: 10c0/75bd12ff36707ffacf379c31c403d4508f3116ef2065e375deadcfafd4f7d163521cf0c70ae5385ebac970fa0acc07f9dd497c4248cfc1ee5623b4533707731d
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.16.7": "mediasoup-client@npm:^3.18.6":
version: 3.16.7 version: 3.18.6
resolution: "mediasoup-client@npm:3.16.7" resolution: "mediasoup-client@npm:3.18.6"
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.1" h264-profile-level-id: "npm:^2.3.2"
sdp-transform: "npm:^2.15.0" sdp-transform: "npm:^3.0.0"
supports-color: "npm:^10.2.2" supports-color: "npm:^10.2.2"
checksum: 10c0/da44c6de8889963192c5b0b7907ed628e04d48be73b7bbfbf18012d66b07ede9d7367c0723466e496a87c7002c07f1af432d854c4c5e16cbd0887013870d8abe checksum: 10c0/f5baff9139afccf88de5db767c1139efa5cdd68f4871e2fa9d6ff94d2e71d2365dc40e9ba6e903cde5fbb51a2d82972e738da656be9f6fc7006640fdd82dd5da
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9829,12 +9829,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sdp-transform@npm:^2.15.0": "sdp-transform@npm:^3.0.0":
version: 2.15.0 version: 3.0.0
resolution: "sdp-transform@npm:2.15.0" resolution: "sdp-transform@npm:3.0.0"
bin: bin:
sdp-verify: checker.js sdp-verify: checker.js
checksum: 10c0/96c060f113a3d5418defa168db609f7e23e5bd7954fa1cf7784f103dbe702e24d667e5310d2ac6d88abdb32322af83d6ebd0df08e07f4f172d5ed5888f921386 checksum: 10c0/828a4595041ba64c86b29075aa4007ab384519b1fa29882db59ccb83b54b2b2a33b60848293f8da537fe151c52f5844fc17c8325396cac309fb19e2e81ec5bf4
languageName: node languageName: node
linkType: hard linkType: hard