1 Commits

Author SHA1 Message Date
65583b1564 channels 2026-01-21 22:39:08 +06:00
278 changed files with 20161 additions and 869 deletions

Binary file not shown.

View File

@@ -17,12 +17,10 @@ declare module 'vue' {
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'] 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']
PrimeToast: typeof import('primevue/toast')['default'] PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default'] PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

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" class="text-muted-color" />
</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

@@ -8,7 +8,6 @@ export const useApp = createGlobalState(() => {
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling() const signaling = useSignaling()
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
@@ -32,42 +31,29 @@ 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')
sfx.play('/sfx/off_micr.ogg').then()
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')
sfx.play('/sfx/on_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
@@ -115,25 +101,12 @@ export const useApp = createGlobalState(() => {
await muteOutput() await muteOutput()
} }
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
await sfx.play('/sfx/on_trans.ogg', 0.03)
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
}
}
async function toggleShare() { async function toggleShare() {
if (!mediasoup.shareProducer.value) { if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare() await mediasoup.enableShare()
await sfx.play('/sfx/on_trans.ogg', 0.03)
} }
else { else {
await mediasoup.disableProducer(mediasoup.shareProducer.value) await mediasoup.disableProducer('share')
await sfx.play('/sfx/off_trans.ogg', 0.03)
} }
} }
@@ -148,13 +121,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

@@ -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

@@ -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' },
@@ -29,7 +22,6 @@ const ICE_SERVERS: RTCIceServer[] = [
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient } = useClients()
@@ -41,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)
@@ -176,7 +138,6 @@ export const useMediasoup = createSharedComposable(() => {
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client) addClient(client)
}) })
@@ -198,41 +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 (kind === 'video')
sfx.play('/sfx/on_trans.ogg', 0.03).then()
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', () => {
if (kind === 'video')
sfx.play('/sfx/off_trans.ogg', 0.03).then()
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -243,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
@@ -284,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
@@ -299,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 },
@@ -361,67 +299,21 @@ 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
@@ -432,54 +324,85 @@ export const useMediasoup = createSharedComposable(() => {
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
} }
} }
@@ -498,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

@@ -10,11 +10,10 @@ export interface SyncedPreferences {
export const usePreferences = createGlobalState(() => { export const usePreferences = createGlobalState(() => {
const { videoInputs, audioInputs, audioOutputs } = useDevices() const { videoInputs, audioInputs, audioOutputs } = useDevices()
const synced = ref(false) const fetched = ref(false)
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)
@@ -33,10 +32,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]) => {
@@ -58,10 +53,9 @@ export const usePreferences = createGlobalState(() => {
) )
return { return {
synced, fetched,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -70,6 +64,5 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
} }
}) })

View File

@@ -1,43 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
import { Howl, Howler } 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
}
export const useSfx = createSharedComposable(() => {
async function play(src: string, volume = 0.2): Promise<void> {
Howler.stop()
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
howl.on('end', () => {
resolve()
})
})
}
async function playRandomConnectionSound(seed: string) {
await play('/sfx/on_trans.ogg', 0.03)
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length + 1)]!, 0.1)
}
return {
play,
playRandomConnectionSound,
}
})

View File

@@ -1,5 +1,5 @@
<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"
> >
@@ -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,33 +44,28 @@
</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,
@@ -87,12 +76,9 @@ 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()
@@ -105,47 +91,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

@@ -7,9 +7,9 @@ export default defineNuxtRouteMiddleware(async () => {
if (!me.value) if (!me.value)
return return
const { synced, toggleInputHotkey, toggleOutputHotkey } = usePreferences() const { fetched, toggleInputHotkey, toggleOutputHotkey } = usePreferences()
if (synced.value) if (fetched.value)
return return
try { try {
@@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async () => {
toggleInputHotkey.value = preferences.toggleInputHotkey ?? toggleInputHotkey.value toggleInputHotkey.value = preferences.toggleInputHotkey ?? toggleInputHotkey.value
toggleOutputHotkey.value = preferences.toggleOutputHotkey ?? toggleOutputHotkey.value toggleOutputHotkey.value = preferences.toggleOutputHotkey ?? toggleOutputHotkey.value
synced.value = true fetched.value = true
} }
catch {} catch {}
}) })

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

@@ -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"
/> />
@@ -52,36 +51,12 @@
Video Video
</PrimeDivider> </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>
<p class="text-sm mb-2 text-center"> <div class="flex justify-between text-sm mb-3">
FPS <span>FPS</span>
</p> <span>{{ shareFps }}</span>
<PrimeSelectButton </div>
v-model="shareFps" <PrimeSlider v-model="shareFps" class="mx-[10px]" :min="30" :max="60" :step="5" />
:options="shareFpsOptions"
fluid
size="small"
option-label="label"
option-value="value"
/>
</div> </div>
<template v-if="isTauri"> <template v-if="isTauri">
@@ -143,11 +118,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 +129,9 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
shareFps, 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"
@@ -19,9 +19,8 @@
"@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-updater": "~2",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"hotkeys-js": "^4.0.0", "hotkeys-js": "^4.0.0",
"howler": "^2.2.4",
"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",
@@ -38,7 +37,6 @@
"@antfu/eslint-config": "^5.4.1", "@antfu/eslint-config": "^5.4.1",
"@primevue/nuxt-module": "^4.4.0", "@primevue/nuxt-module": "^4.4.0",
"@tauri-apps/cli": "^2.8.4", "@tauri-apps/cli": "^2.8.4",
"@types/howler": "^2",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-format": "^1.0.2", "eslint-plugin-format": "^1.0.2",
"sass-embedded": "^1.93.2", "sass-embedded": "^1.93.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More