1 Commits

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

Binary file not shown.

View File

@@ -17,12 +17,10 @@ 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,29 +6,19 @@
'bg-surface-800': expanded,
}"
>
<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,
}"
>
<div class="p-3 flex items-center gap-3" @click="toggleExpand">
<PrimeAvatar size="small">
<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 }}
</p>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
</div>
<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" />
<div class="flex align-center gap-1">
<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-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" />
</div>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
</div>
<CollapseTransition v-if="!isMe">
@@ -60,6 +52,7 @@
<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'
@@ -74,34 +67,34 @@ const { show } = useFullscreenVideo()
const expanded = ref(false)
const {
volume,
premuted,
speaking,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
} = useClient(toRef(() => props.client.socketId))
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 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 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(() => {
return audioConsumer.value?.raw.track
return audioConsumer.value?.track
})
const audioConsumerPaused = computed(() => {
if (Object.keys(allConsumers.value).length === 0)
return false
return audioConsumer.value?.paused ?? false
})
const audioConsumerPaused = ref(false)
const inputMuted = computed(() => {
if (isMe.value)
@@ -110,12 +103,8 @@ const inputMuted = computed(() => {
return premuted.value || audioConsumerPaused.value
})
const hasBadges = computed(() => {
return streaming.value
|| premuted.value
|| inputMuted.value
|| props.client.outputMuted
|| isMe.value
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack)
@@ -132,11 +121,9 @@ function toggleExpand() {
}
function watchStream() {
if (!streaming.value)
if (!shareConsumer.value)
return
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.track]))
show(new MediaStream([shareConsumer.value.track]))
}
</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 signaling = useSignaling()
const toast = useToast()
const sfx = useSfx()
const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
@@ -32,42 +31,29 @@ 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 || !mediasoup.micProducer.value)
if (inputMuted.value)
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 })
}
async function unmuteInput() {
if (!inputMuted.value || !mediasoup.micProducer.value)
if (!inputMuted.value)
return
if (outputMuted.value) {
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 })
}
@@ -115,25 +101,12 @@ export const useApp = createGlobalState(() => {
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() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
await sfx.play('/sfx/on_trans.ogg', 0.03)
}
else {
await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
await mediasoup.disableProducer('share')
}
}
@@ -148,13 +121,10 @@ export const useApp = createGlobalState(() => {
muteOutput,
unmuteOutput,
toggleOutput,
toggleVideo,
version,
isTauri,
commitSha,
toggleShare,
videoEnabled,
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 {
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

@@ -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 { 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' | 'video' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
type ProducerType = 'microphone' | 'camera' | 'share'
const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
@@ -29,7 +22,6 @@ const ICE_SERVERS: RTCIceServer[] = [
export const useMediasoup = createSharedComposable(() => {
const toast = useToast()
const sfx = useSfx()
const signaling = useSignaling()
const { addClient, removeClient } = useClients()
@@ -41,42 +33,12 @@ export const useMediasoup = createSharedComposable(() => {
const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const consumers = ref<Record<Consumer['id'], Consumer>>({})
const producers = ref<Record<Producer['id'], Producer>>({})
const micProducer = shallowRef<mediasoupClient.types.Producer>()
const cameraProducer = shallowRef<mediasoupClient.types.Producer>()
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
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[]>([])
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
watch(signaling.socket, (socket) => {
if (!socket)
@@ -176,7 +138,6 @@ export const useMediasoup = createSharedComposable(() => {
})
socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client)
})
@@ -198,38 +159,20 @@ export const useMediasoup = createSharedComposable(() => {
producerId,
kind,
rtpParameters,
streamId: `${socketId}-${appData.source || 'stream'}`,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId },
})
if (kind === 'video')
sfx.play('/sfx/on_trans.ogg', 0.03).then()
if (producerPaused)
consumer.pause()
consumers.value[consumer.id] = {
id: consumer.id,
paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
consumer.on('transportclose', () => {
if (consumers.value.delete(consumer.id))
triggerRef(consumers)
})
consumer.observer.on('pause', () => {
consumers.value[consumer.id]!.paused = true
})
consumer.observer.on('close', () => {
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
consumers.value.set(consumer.id, consumer)
triggerRef(consumers)
cb()
},
@@ -240,37 +183,11 @@ export const useMediasoup = createSharedComposable(() => {
async (
{ consumerId },
) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.close()
if (consumers.value.delete(consumerId))
triggerRef(consumers)
},
)
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
@@ -281,12 +198,43 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close()
recvTransport.value = undefined
consumers.value = {}
producers.value = {}
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)
})
}, { 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)
return
@@ -296,54 +244,47 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind))
return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producer.value = await sendTransport.value.produce(options)
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producers.value.set(producer.value.id, producer.value)
triggerRef(producers)
triggerRef(producer)
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
producer.value.on('transportclose', () => {
micProducer.value = undefined
})
producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false
})
producer.observer.on('close', () => {
delete producers.value[producer.id]
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
producer.value.on('trackended', () => {
disableProducer(type)
})
}
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
async function disableProducer(type: ProducerType) {
const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return
producers.value.delete(producer.value.id)
try {
producer.raw.close()
producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
catch {
}
finally {
delete producers.value[producer.id]
triggerRef(producers)
triggerRef(producer)
}
producer.value = undefined
}
async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: preferences.inputDeviceId.value },
@@ -358,67 +299,21 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
await createProducer({
await enableProducer('microphone', {
track,
streamId: 'mic-video',
codecOptions: {
opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос
},
appData: {
source: 'mic-video',
},
})
}
async function disableMic() {
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',
},
})
await disableProducer('microphone')
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
@@ -429,54 +324,85 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
await createProducer({
await enableProducer('share', {
track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1',
c => c.mimeType.toLowerCase() === 'video/h264',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(producer: Producer) {
async function pauseProducer(type: ProducerType) {
if (!signaling.socket.value)
return
if (producer.paused)
const producer = getProducerByType(type)
if (!producer.value)
return
if (producer.value.paused)
return
try {
producer.raw.pause()
producer.value.pause()
await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
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)
return
const producer = getProducerByType(type)
if (!producer.value)
return
try {
producer.raw.resume()
producer.value.resume()
await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
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
}
}
@@ -495,22 +421,18 @@ export const useMediasoup = createSharedComposable(() => {
})
return {
init,
consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers,
speakingClients,
sendTransport,
recvTransport,
rtpCapabilities,
device,
micProducer,
videoProducer,
cameraProducer,
shareProducer,
pauseProducer,
resumeProducer,
enableVideo,
enableShare,
disableProducer,
}

View File

@@ -10,11 +10,10 @@ export interface SyncedPreferences {
export const usePreferences = createGlobalState(() => {
const { videoInputs, audioInputs, audioOutputs } = useDevices()
const synced = ref(false)
const fetched = ref(false)
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)
@@ -33,10 +32,6 @@ 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]) => {
@@ -58,10 +53,9 @@ export const usePreferences = createGlobalState(() => {
)
return {
synced,
fetched,
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
@@ -70,6 +64,5 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey,
inputDeviceExist,
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>
<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
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
>
@@ -21,12 +21,6 @@
</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" />
@@ -40,7 +34,7 @@
<PrimeSelectButton
v-model="activeTab"
:options="tabs"
option-label="id"
data-key="id"
:allow-empty="false"
style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
>
@@ -50,33 +44,28 @@
</PrimeSelectButton>
</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">
<ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div>
</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">
<slot />
</div>
</PrimeScrollPanel>
</div>
<FullscreenGallery />
</template>
<script setup lang="ts">
import {
Camera,
CameraOff,
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
TvMinimalPlay,
UserPen,
Volume2,
VolumeOff,
@@ -87,12 +76,9 @@ const {
clients,
inputMuted,
outputMuted,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
toggleInput,
toggleOutput,
toggleVideo,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling()
@@ -105,20 +91,7 @@ interface Tab {
const route = useRoute()
const tabs = computed<Tab[]>(() => {
const result = []
if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
const tabs: Tab[] = [
{
id: 'Index',
icon: MessageCircle,
@@ -140,12 +113,9 @@ const tabs = computed<Tab[]>(() => {
navigateTo({ name: 'Preferences' })
},
},
)
]
return result
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!)
watch(activeTab, (activeTab) => {
activeTab.onClick()

View File

@@ -7,9 +7,9 @@ export default defineNuxtRouteMiddleware(async () => {
if (!me.value)
return
const { synced, toggleInputHotkey, toggleOutputHotkey } = usePreferences()
const { fetched, toggleInputHotkey, toggleOutputHotkey } = usePreferences()
if (synced.value)
if (fetched.value)
return
try {
@@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async () => {
toggleInputHotkey.value = preferences.toggleInputHotkey ?? toggleInputHotkey.value
toggleOutputHotkey.value = preferences.toggleOutputHotkey ?? toggleOutputHotkey.value
synced.value = true
fetched.value = true
}
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-value="deviceId"
input-id="inputDevice"
placeholder="No input device"
fluid
:invalid="!inputDeviceExist"
/>
@@ -52,36 +51,12 @@
Video
</PrimeDivider>
<PrimeFloatLabel variant="on">
<PrimeSelect
v-model="videoDeviceId"
:options="videoInputs"
option-label="label"
option-value="deviceId"
input-id="videoDevice"
placeholder="No video device"
fluid
:invalid="!videoDeviceExist"
/>
<label for="inputDevice">Input device</label>
</PrimeFloatLabel>
<PrimeDivider align="left">
Screen sharing
</PrimeDivider>
<div>
<p class="text-sm mb-2 text-center">
FPS
</p>
<PrimeSelectButton
v-model="shareFps"
:options="shareFpsOptions"
fluid
size="small"
option-label="label"
option-value="value"
/>
<div 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" />
</div>
<template v-if="isTauri">
@@ -143,11 +118,10 @@ definePageMeta({
})
const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { audioInputs, audioOutputs } = useDevices()
const {
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
@@ -155,17 +129,9 @@ 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

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

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --host",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
@@ -19,9 +19,8 @@
"@tauri-apps/plugin-updater": "~2",
"@vueuse/core": "^13.9.0",
"hotkeys-js": "^4.0.0",
"howler": "^2.2.4",
"lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.18.6",
"mediasoup-client": "^3.16.7",
"nuxt": "^4.2.2",
"postcss": "^8.5.6",
"primeicons": "^7.0.0",
@@ -38,7 +37,6 @@
"@antfu/eslint-config": "^5.4.1",
"@primevue/nuxt-module": "^4.4.0",
"@tauri-apps/cli": "^2.8.4",
"@types/howler": "^2",
"eslint": "^9.36.0",
"eslint-plugin-format": "^1.0.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