16 Commits

Author SHA1 Message Date
a645885cf2 client volumes
All checks were successful
Deploy / publish-web (push) Successful in 1m31s
2026-01-29 22:05:05 +06:00
4c8a0e791c client volumes 2026-01-29 22:04:40 +06:00
fbdceb2e55 client volumes 2026-01-29 21:59:41 +06:00
aeaea47609 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 33s
2026-01-29 21:40:56 +06:00
f4fd752448 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 34s
2026-01-29 21:34:46 +06:00
595354b7f0 Merge pull request 'shareFps' (#9) from shareFps into master
All checks were successful
Deploy / publish-web (push) Successful in 1m32s
Reviewed-on: #9
2026-01-12 07:23:51 +00:00
Nadar
d08b011596 shareFps 2026-01-12 10:22:56 +03:00
12ce381abd minor update
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-27 02:52:17 +06:00
2d30ac2863 minor update
All checks were successful
Deploy / publish-web (push) Successful in 44s
2025-12-27 02:49:39 +06:00
0f218c1519 button colors 2025-12-27 01:58:01 +06:00
4b1a563850 screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 1m25s
2025-12-27 01:49:25 +06:00
169d43f0db screen sharing 2025-12-27 01:48:49 +06:00
47a464f08f screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 48s
2025-12-26 18:22:22 +06:00
4d5db12e1b screen sharing
Some checks failed
Deploy / deploy (push) Successful in 35s
Deploy / publish-web (push) Failing after 22s
2025-12-26 17:36:30 +06:00
4f59cbcf65 screen sharing
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 17:21:59 +06:00
3b3f6b6e40 update 2025-12-26 01:44:16 +06:00
22 changed files with 647 additions and 214 deletions

Binary file not shown.

View File

@@ -2,9 +2,6 @@ FROM node:lts-alpine AS builder
WORKDIR /app WORKDIR /app
RUN corepack enable
RUN yarn set version stable
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn

View File

@@ -3,8 +3,6 @@ FROM node:lts AS builder
WORKDIR /app WORKDIR /app
# RUN corepack enable yarn && yarn set version stable
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn

View File

@@ -28,3 +28,20 @@ body {
.p-scrollpanel-bar-y { .p-scrollpanel-bar-y {
translate: -0.25rem; translate: -0.25rem;
} }
.p-select-overlay {
/* Force dropdown width to match computed min-width from PrimeVue internals. */
width: 0 !important;
}
.p-select-label {
width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.p-select-option-label {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}

View File

@@ -16,7 +16,6 @@ declare module 'vue' {
PrimeDivider: typeof import('primevue/divider')['default'] PrimeDivider: typeof import('primevue/divider')['default']
PrimeFloatLabel: typeof import('primevue/floatlabel')['default'] PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputText: typeof import('primevue/inputtext')['default'] PrimeInputText: typeof import('primevue/inputtext')['default']
PrimeMenu: typeof import('primevue/menu')['default']
PrimePassword: typeof import('primevue/password')['default'] PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default'] PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default'] PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
@@ -28,4 +27,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
export interface ComponentCustomProperties {
Tooltip: typeof import('primevue/tooltip')['default']
}
} }

View File

@@ -1,110 +1,138 @@
<template> <template>
<div class="py-3"> <div
<div class="flex items-center gap-3"> class="overflow-hidden rounded-xl transition-[background-color]"
<PrimeAvatar size="small"> :class="{
'hover:bg-surface-800 cursor-pointer': !isMe,
'bg-surface-800': expanded,
}"
>
<div class="p-3 flex items-center gap-3" @click="toggleExpand">
<PrimeAvatar
size="small"
:class="{
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<template #icon> <template #icon>
<User :size="20" /> <User :size="20" />
</template> </template>
</PrimeAvatar> </PrimeAvatar>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
<div class="overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis"> {{ client.displayName || client.username }}
{{ client.displayName }}
</div>
<div v-if="client.username !== client.displayName" class="overflow-hidden mt-1 text-xs leading-5 text-muted-color">
{{ client.username }}
</div>
</div> </div>
<PrimeBadge v-if="client.outputMuted" severity="info" value="No sound" /> <div class="flex align-center gap-1">
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" /> <PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<PrimeBadge v-if="isMe" severity="secondary" value="You" />
<template v-if="!isMe"> <PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeButton icon="pi pi-ellipsis-h" text size="small" severity="contrast" @click="menuRef?.toggle" /> <PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" size="small" />
<PrimeMenu ref="menu" popup :model="menuItems" style="translate: calc(-100% + 2rem) 0.5rem"> <PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
<template #start> </div>
<div class="px-4 py-3">
<div class="flex justify-between"> <Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" class="mt-4" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
</div>
</template>
</PrimeMenu>
</template>
</div> </div>
<CollapseTransition v-if="!isMe">
<div v-if="expanded">
<div class="px-3 pb-3">
<div class="flex justify-between text-sm mb-3">
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
<div class="mt-3 flex gap-1 justify-end">
<PrimeButton size="small" variant="text" @click="premuted = !premuted">
{{ premuted ? 'Unmute' : 'Mute' }}
</PrimeButton>
</div>
</div>
</div>
</CollapseTransition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ChadClient } from '#shared/types' import type { ChadClient } from '#shared/types'
import type { MenuItem } from 'primevue/menuitem'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import { User } from 'lucide-vue-next' import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import CollapseTransition from '~/components/CollapseTransition.vue'
const props = defineProps<{ const props = defineProps<{
client: ChadClient client: ChadClient
}>() }>()
const { outputMuted } = useApp() const { outputMuted } = useApp()
const { getClientConsumers, micProducer } = useMediasoup() const { consumers: allConsumers, micProducer, speakingClients } = useMediasoup()
const { me } = useClients() const { me } = useClients()
const { show } = useFullscreenVideo()
const expanded = ref(false)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false }) const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false })
const menuRef = useTemplateRef<HTMLAudioElement>('menu')
const menuItems: MenuItem[] = [
{
label: 'Mute',
icon: 'pi pi-headphones',
},
{
label: 'DM',
icon: 'pi pi-comment',
disabled: true,
},
]
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 audioConsumer = computed(() => { const consumers = computed(() => {
if (isMe.value) return allConsumers.value.values().filter(consumer => consumer.appData.socketId === props.client.socketId).toArray()
return undefined
const consumers = getClientConsumers(props.client.socketId)
return consumers.find(consumer => consumer.track.kind === 'audio')
}) })
const inputMuted = computed(() => { const audioConsumer = computed(() => {
if (isMe.value) return consumers.value.find(consumer => consumer.track.kind === 'audio')
return micProducer.value?.paused ?? false })
const consumers = getClientConsumers(props.client.socketId) const videoConsumers = computed(() => {
return consumers.value.filter(consumer => consumer.track.kind === 'video')
})
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused 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?.track
}) })
const speaking = computed(() => {
return speakingClients.value.find(speaker => speaker.clientId === props.client.socketId)
})
const audioConsumerPaused = ref(false)
const inputMuted = computed(() => {
if (isMe.value)
return micProducer.value?.paused ?? false
return premuted.value || audioConsumerPaused.value
})
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack) const { setGain } = useAudioContext(audioTrack)
watch(volume, (volume) => { watchEffect(() => {
if (outputMuted.value) setGain((outputMuted.value || premuted.value) ? 0 : (volume.value * 0.01))
})
function toggleExpand() {
if (isMe.value)
return return
setGain(volume * 0.01) expanded.value = !expanded.value
}, { immediate: true }) }
watch(outputMuted, (outputMuted) => { function watchStream() {
setGain(outputMuted ? 0 : (volume.value * 0.01)) if (!shareConsumer.value)
}) return
show(new MediaStream([shareConsumer.value.track]))
}
</script> </script>

View File

@@ -0,0 +1,118 @@
<template>
<Transition name="collapse-transition" v-on="bindings">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
defineOptions({
name: 'CollapseTransition',
})
const emit = defineEmits<{
expanded: []
collapsed: []
}>()
const bindings = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.elExistsHeight = el.style.height ?? undefined
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow
if (el.dataset.elExistsHeight) {
el.style.maxHeight = el.dataset.elExistsHeight
}
else if (el.scrollHeight !== 0) {
el.style.maxHeight = `${el.scrollHeight}px`
}
else {
el.style.maxHeight = 0
}
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
el.style.overflow = 'hidden'
})
},
afterEnter(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
emit('expanded')
},
enterCancelled(el: RendererElement) {
reset(el)
},
beforeLeave(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.oldOverflow = el.style.overflow
el.style.maxHeight = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
}
},
afterLeave(el: RendererElement) {
reset(el)
emit('collapsed')
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
function reset(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
}
</script>
<style lang="scss">
.collapse-transition {
transition-property: height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition-property: opacity, max-height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-to,
.collapse-transition-enter-from {
opacity: 0;
}
</style>

View File

@@ -10,78 +10,104 @@ export const useApp = createGlobalState(() => {
const toast = useToast() const toast = useToast()
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => {
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const inputMuted = ref(false) const inputMuted = computed(() => {
const outputMuted = ref(false) return !!mediasoup.micProducer.value?.paused
})
const previousInputMuted = ref(inputMuted.value) const previousInputMuted = ref(inputMuted.value)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const outputMuted = ref(false)
const commitSha = __COMMIT_SHA__ const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value
const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-')
watch(inputMuted, async (inputMuted) => {
if (inputMuted) {
await mediasoup.pauseProducer('microphone')
}
else {
if (outputMuted.value) {
outputMuted.value = false
}
await mediasoup.resumeProducer('microphone')
}
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated'
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
}) })
watch(outputMuted, async (outputMuted) => { async function muteInput() {
if (outputMuted) { if (inputMuted.value)
previousInputMuted.value = inputMuted.value return
muteInput()
} await mediasoup.pauseProducer('microphone')
else {
inputMuted.value = previousInputMuted.value toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
}
async function unmuteInput() {
if (!inputMuted.value)
return
if (outputMuted.value) {
await unmuteOutput()
} }
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { await mediasoup.resumeProducer('microphone')
outputMuted,
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
}
async function toggleInput() {
if (inputMuted.value)
await unmuteInput()
else
await muteInput()
}
async function muteOutput() {
if (outputMuted.value)
return
outputMuted.value = true
previousInputMuted.value = inputMuted.value
await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: true,
}) })
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed' toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
})
function muteInput() {
inputMuted.value = true
} }
function unmuteInput() { async function unmuteOutput() {
inputMuted.value = false
}
function toggleInput() {
if (inputMuted.value)
unmuteInput()
else
muteInput()
}
function muteOutput() {
outputMuted.value = true
}
function unmuteOutput() {
outputMuted.value = false outputMuted.value = false
if (!previousInputMuted.value)
await unmuteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
} }
function toggleOutput() { async function toggleOutput() {
if (outputMuted.value) if (outputMuted.value)
unmuteOutput() await unmuteOutput()
else else
muteOutput() await muteOutput()
}
async function toggleShare() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
}
else {
await mediasoup.disableProducer('share')
}
} }
return { return {
@@ -98,5 +124,7 @@ export const useApp = createGlobalState(() => {
version, version,
isTauri, isTauri,
commitSha, commitSha,
toggleShare,
sharingEnabled,
} }
}) })

View File

@@ -0,0 +1,28 @@
import { createGlobalState, useDevicesList } from '@vueuse/core'
export const useDevices = createGlobalState(() => {
const {
ensurePermissions,
permissionGranted,
videoInputs,
audioInputs,
audioOutputs,
} = useDevicesList()
async function getShareStream(fps = 30) {
return navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'monitor',
frameRate: { max: fps },
},
})
}
return {
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))),
getShareStream,
}
})

View File

@@ -0,0 +1,65 @@
import { createGlobalState, useEventListener } from '@vueuse/core'
export const useFullscreenVideo = createGlobalState(() => {
const videoEl = shallowRef<HTMLVideoElement>()
const visible = computed(() => !!videoEl.value)
async function show(stream: MediaStream) {
if (videoEl.value) {
videoEl.value.srcObject = stream
}
else {
const el = document.createElement('video')
el.srcObject = stream
el.autoplay = true
el.playsInline = true
el.controls = false
el.muted = true
// el.style.position = 'fixed'
// el.style.top = '0'
// el.style.left = '0'
// el.style.width = '1px'
// el.style.height = '1px'
// el.style.opacity = '0'
// el.style.pointerEvents = 'none'
document.body.appendChild(el)
videoEl.value = el
}
stream.getTracks().forEach(t =>
t.addEventListener('ended', hide),
)
videoEl.value.addEventListener('ended', hide)
await videoEl.value.requestFullscreen()
}
function hide() {
if (!videoEl.value)
return
(videoEl.value.srcObject as MediaStream).getTracks().forEach(t =>
t.removeEventListener('ended', hide),
)
videoEl.value.removeEventListener('ended', hide)
videoEl.value?.remove()
videoEl.value = undefined
}
useEventListener(document, 'fullscreenchange', () => {
if (!document.fullscreenElement && videoEl.value) {
videoEl.value?.remove()
videoEl.value = undefined
}
})
return {
visible,
show,
hide,
}
})

View File

@@ -1,6 +1,9 @@
import type { ChadClient } from '#shared/types' import type { SpeakingClient } from '#shared/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 { usePreferences } from '~/composables/use-preferences' import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling' import { useSignaling } from '~/composables/use-signaling'
@@ -25,7 +28,7 @@ export const useMediasoup = createSharedComposable(() => {
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient } = useClients()
const preferences = usePreferences() const preferences = usePreferences()
const { me } = useAuth() const { getShareStream } = useDevices()
const device = shallowRef<mediasoupClient.Device>() const device = shallowRef<mediasoupClient.Device>()
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>() const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
@@ -39,6 +42,8 @@ export const useMediasoup = createSharedComposable(() => {
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map()) const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map()) const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => { watch(signaling.socket, (socket) => {
if (!socket) if (!socket)
return return
@@ -158,7 +163,7 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.share ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
@@ -213,8 +218,6 @@ export const useMediasoup = createSharedComposable(() => {
consumer.pause() consumer.pause()
console.log(consumerId)
triggerRef(consumers) triggerRef(consumers)
}) })
@@ -228,22 +231,68 @@ export const useMediasoup = createSharedComposable(() => {
triggerRef(consumers) triggerRef(consumers)
}) })
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
function getClientConsumers(socketId: ChadClient['socketId']) { async function enableProducer(type: ProducerType, options: ProducerOptions) {
return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId) const producer = getProducerByType(type)
}
async function enableMic() { if (producer.value)
if (micProducer.value)
return return
if (!device.value || !sendTransport.value) if (!device.value || !sendTransport.value)
return return
if (!device.value.canProduce('audio')) if (!options.track)
return return
if (!device.value.canProduce(options.track.kind as MediaKind))
return
producer.value = await sendTransport.value.produce(options)
producers.value.set(producer.value.id, producer.value)
triggerRef(producers)
triggerRef(producer)
producer.value.on('transportclose', () => {
micProducer.value = undefined
})
producer.value.on('trackended', () => {
disableProducer(type)
})
}
async function disableProducer(type: ProducerType) {
const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return
producers.value.delete(producer.value.id)
try {
producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.value.id,
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(producer)
}
producer.value = undefined
}
async function enableMic() {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
deviceId: { exact: preferences.inputDeviceId.value }, deviceId: { exact: preferences.inputDeviceId.value },
@@ -258,7 +307,7 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
micProducer.value = await sendTransport.value.produce({ await enableProducer('microphone', {
track, track,
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
@@ -266,41 +315,35 @@ export const useMediasoup = createSharedComposable(() => {
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
}) })
producers.value.set(micProducer.value.id, micProducer.value)
triggerRef(producers)
triggerRef(micProducer)
micProducer.value.on('transportclose', () => {
micProducer.value = undefined
})
micProducer.value.on('trackended', () => {
disableMic()
})
} }
async function disableMic() { async function disableMic() {
if (!signaling.socket.value || !micProducer.value) await disableProducer('microphone')
}
async function enableShare() {
if (!device.value)
return return
producers.value.delete(micProducer.value.id) const stream = await getShareStream(preferences.shareFps.value)
try { const track = stream.getVideoTracks()[0]
micProducer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', { if (!track)
producerId: micProducer.value.id, return
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(micProducer)
}
micProducer.value = undefined await enableProducer('share', {
track,
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/h264',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
appData: {
source: 'share',
},
})
} }
async function pauseProducer(type: ProducerType) { async function pauseProducer(type: ProducerType) {
@@ -371,18 +414,6 @@ export const useMediasoup = createSharedComposable(() => {
} }
} }
watch(
preferences.inputDeviceId,
async (inputDeviceId) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
},
)
watch([ watch([
preferences.inputDeviceId, preferences.inputDeviceId,
preferences.echoCancellation, preferences.echoCancellation,
@@ -401,6 +432,7 @@ export const useMediasoup = createSharedComposable(() => {
init, init,
consumers, consumers,
producers, producers,
speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities,
@@ -408,8 +440,9 @@ export const useMediasoup = createSharedComposable(() => {
micProducer, micProducer,
cameraProducer, cameraProducer,
shareProducer, shareProducer,
getClientConsumers,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableShare,
disableProducer,
} }
}) })

View File

@@ -1,5 +1,5 @@
import chadApi from '#shared/chad-api' import chadApi from '#shared/chad-api'
import { createGlobalState, useDevicesList, useLocalStorage, watchDebounced } from '@vueuse/core' import { createGlobalState, useLocalStorage, watchDebounced } from '@vueuse/core'
export interface SyncedPreferences { export interface SyncedPreferences {
toggleInputHotkey: string toggleInputHotkey: string
@@ -8,6 +8,8 @@ export interface SyncedPreferences {
} }
export const usePreferences = createGlobalState(() => { export const usePreferences = createGlobalState(() => {
const { videoInputs, audioInputs, audioOutputs } = useDevices()
const synced = ref(false) const synced = ref(false)
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default') const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
@@ -17,17 +19,11 @@ export const usePreferences = createGlobalState(() => {
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true) const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true) const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
const shareFps = useLocalStorage('SHARE_FPS', 30)
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('') const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('') const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
const {
ensurePermissions,
permissionGranted,
videoInputs,
audioInputs,
audioOutputs,
} = useDevicesList()
const inputDeviceExist = computed(() => { const inputDeviceExist = computed(() => {
return audioInputs.value.some(device => device.deviceId === inputDeviceId.value) return audioInputs.value.some(device => device.deviceId === inputDeviceId.value)
}) })
@@ -63,12 +59,10 @@ export const usePreferences = createGlobalState(() => {
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
shareFps,
toggleInputHotkey, toggleInputHotkey,
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))),
} }
}) })

View File

@@ -1,25 +1,31 @@
<template> <template>
<div class="grid grid-cols-2 gap-2 p-2 h-screen grid-rows-[auto_1fr]"> <div class="grid grid-cols-[1fr_1.25fr] gap-2 p-2 h-screen grid-rows-[auto_1fr]">
<div <div
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950" class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
> >
<div class="inline-flex items-center gap-3"> <div class="inline-flex items-center gap-3">
<PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" /> <PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " /> <PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div> </div>
<PrimeButtonGroup class="ml-auto"> <PrimeButtonGroup class="ml-auto">
<PrimeButton outlined @click="toggleInput"> <PrimeButton :severity="inputMuted ? 'info' : undefined" outlined @click="toggleInput">
<template #icon> <template #icon>
<Component :is="inputMuted ? MicOff : Mic" /> <Component :is="inputMuted ? MicOff : Mic" />
</template> </template>
</PrimeButton> </PrimeButton>
<PrimeButton outlined @click="toggleOutput"> <PrimeButton :severity="outputMuted ? 'info' : undefined" outlined @click="toggleOutput">
<template #icon> <template #icon>
<Component :is="outputMuted ? VolumeOff : Volume2" /> <Component :is="outputMuted ? VolumeOff : Volume2" />
</template> </template>
</PrimeButton> </PrimeButton>
</PrimeButtonGroup> </PrimeButtonGroup>
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
</template>
</PrimeButton>
</div> </div>
<div <div
@@ -39,8 +45,8 @@
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0">
<div v-auto-animate class="p-3 divide-y divide-surface-800"> <div v-auto-animate class="p-3 space-y-1">
<ClientRow v-for="client of clients" :key="client.id" :client="client" /> <ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
@@ -53,9 +59,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MessageCircle, Mic, MicOff, Settings, UserPen, Volume2, VolumeOff } from 'lucide-vue-next' import {
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp() const {
version,
clients,
inputMuted,
outputMuted,
sharingEnabled,
toggleInput,
toggleOutput,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling() const { connect, connected } = useSignaling()
interface Tab { interface Tab {

View File

@@ -6,7 +6,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!me.value) { if (!me.value) {
try { try {
setMe(await chadApi('/me', { method: 'GET' })) setMe(await chadApi('/me', { method: 'GET' }))
return navigateTo({ name: 'Index' })
if (to.meta.auth !== false)
return navigateTo({ name: 'Index' })
} }
catch { catch {
if (to.meta.auth !== 'guest') { if (to.meta.auth !== 'guest') {
@@ -16,6 +18,6 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
} }
if (me.value && to.meta.auth === 'guest') { if (me.value && to.meta.auth === 'guest') {
return navigateTo('/') return navigateTo({ name: 'Index' })
} }
}) })

View File

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

View File

@@ -47,6 +47,18 @@
<!-- <label for="outputDevice">Output device</label> --> <!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> --> <!-- </PrimeFloatLabel> -->
<PrimeDivider align="left">
Video
</PrimeDivider>
<div>
<div class="text-sm mb-2">
Share FPS
</div>
<PrimeSelectButton v-model="shareFps" :options="[5, 30, 60]" fluid size="small" />
</div>
<template v-if="isTauri"> <template v-if="isTauri">
<PrimeDivider align="left"> <PrimeDivider align="left">
Hotkeys Hotkeys
@@ -106,6 +118,7 @@ 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 { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
@@ -116,8 +129,7 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
audioInputs, shareFps,
audioOutputs,
} = usePreferences() } = usePreferences()
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey) const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)

View File

@@ -8,3 +8,8 @@ export interface ChadClient {
} }
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.14", "version": "0.2.21",
"identifier": "xyz.koptilnya.chad", "identifier": "xyz.koptilnya.chad",
"build": { "build": {
"frontendDist": "../.output/public", "frontendDist": "../.output/public",

View File

@@ -25,5 +25,50 @@ export const autoConfig: mediasoup.types.RouterOptions = {
channels: 2, channels: 2,
parameters: { useinbandfec: 1, stereo: 1 }, parameters: { useinbandfec: 1, stereo: 1 },
}, },
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: {
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '4d0032',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/AV1',
clockRate: 90000,
parameters: {},
},
], ],
} }

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ export interface ClientToServerEvents {
transportId: types.WebRtcTransport['id'] transportId: types.WebRtcTransport['id']
kind: types.MediaKind kind: types.MediaKind
rtpParameters: types.RtpParameters rtpParameters: types.RtpParameters
appData: { source: 'share' | string }
}, },
cb: EventCallback<{ id: types.Producer['id'] }> cb: EventCallback<{ id: types.Producer['id'] }>
) => void ) => void
@@ -117,6 +118,8 @@ export interface ServerToClientEvents {
consumerResumed: (arg: { consumerId: string }) => void consumerResumed: (arg: { consumerId: string }) => void
consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void
clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void
speakingPeers: (arg: { clientId: ChadClient['socketId'], volume: types.AudioLevelObserverVolume['volume'] }[]) => void
activeSpeaker: (clientId?: ChadClient['socketId']) => void
} }
export interface InterServerEvent {} export interface InterServerEvent {}