27 Commits

Author SHA1 Message Date
1bd8aa0fea cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 51s
2026-02-06 23:14:13 +06:00
626f52c616 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 50s
2026-02-06 23:06:50 +06:00
29914d73a0 cringe sfx 2026-02-06 23:06:42 +06:00
dd530266f9 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 49s
2026-02-06 22:44:11 +06:00
a37b2048fe cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 53s
2026-02-06 22:41:58 +06:00
e3ac3e003c cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 2m35s
2026-02-06 22:32:01 +06:00
6fa142f133 productName typo 2026-02-03 22:06:09 +06:00
8e0a08da05 icons
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 22:02:45 +06:00
0a3b2c3dc8 resize
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 17:05:43 +06:00
e5f1e6bbb3 показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-02-03 16:54:51 +06:00
1354ca3f7e показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 42s
2026-02-03 16:47:33 +06:00
269b19a5be вебкамера там, туда-сюда
All checks were successful
Deploy / publish-web (push) Successful in 1m16s
2026-02-02 14:39:16 +06:00
0922fc4f41 новый-старый clientrow
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-01-29 23:19:31 +06:00
9fc8f954e3 новый-старый clientrow 2026-01-29 23:18:47 +06:00
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
239 changed files with 1283 additions and 354 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

@@ -27,4 +27,21 @@ 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,16 +16,19 @@ 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']
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']
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,142 @@
<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="{
<template #icon> 'hover:bg-surface-800 cursor-pointer': !isMe,
<User :size="20" /> 'bg-surface-800': expanded,
</template> }"
</PrimeAvatar> >
<div class="p-3" @click="toggleExpand">
<div class="flex items-center gap-3">
<PrimeAvatar
size="small"
class="shrink-0"
:class="{
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<template #icon>
<User :size="20" />
</template>
</PrimeAvatar>
<div class="flex-1 overflow-hidden"> <p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
<div class="overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis"> {{ client.displayName || client.username }}
{{ client.displayName }} </p>
</div>
<div v-if="client.username !== client.displayName" class="overflow-hidden mt-1 text-xs leading-5 text-muted-color"> <Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
{{ client.username }}
</div>
</div> </div>
<PrimeBadge v-if="client.outputMuted" severity="info" value="No sound" /> <div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" /> <PrimeBadge v-if="streaming" 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">
<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 { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import { useLocalStorage } from '@vueuse/core' import CollapseTransition from '~/components/CollapseTransition.vue'
import { User } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
client: ChadClient client: ChadClient
}>() }>()
const { outputMuted } = useApp() const { outputMuted } = useApp()
const { getClientConsumers, micProducer } = useMediasoup() const { consumers: allConsumers, micProducer } = useMediasoup()
const { me } = useClients() const { me } = useClients()
const { show } = useFullscreenVideo()
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false }) const expanded = ref(false)
const menuRef = useTemplateRef<HTMLAudioElement>('menu') const {
volume,
const menuItems: MenuItem[] = [ premuted,
{ speaking,
label: 'Mute', audioConsumers,
icon: 'pi pi-headphones', videoConsumers,
}, shareConsumers,
{ streaming,
label: 'DM', } = useClient(toRef(() => props.client.socketId))
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 audioConsumer = computed(() => {
if (isMe.value) return audioConsumers.value[0]
return undefined })
const consumers = getClientConsumers(props.client.socketId) const audioTrack = computed(() => {
return audioConsumer.value?.raw.track
})
return consumers.find(consumer => consumer.track.kind === 'audio') const audioConsumerPaused = computed(() => {
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)
return micProducer.value?.paused ?? false return micProducer.value?.paused ?? false
const consumers = getClientConsumers(props.client.socketId) return premuted.value || audioConsumerPaused.value
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused
}) })
const audioTrack = computed(() => { const hasBadges = computed(() => {
return audioConsumer.value?.track return streaming.value
|| premuted.value
|| inputMuted.value
|| props.client.outputMuted
|| isMe.value
}) })
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 (!streaming.value)
}) return
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.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

@@ -0,0 +1,25 @@
<template>
<div class="text-sm overflow-x-auto">
<p class="text-muted-color">
{{ consumer.id }}
</p>
<p>paused: {{ consumer.paused }}</p>
<p v-for="[key, value] in appData" :key="key">
{{ key }}: {{ value }}
</p>
</div>
</template>
<script setup lang="ts">
import type { Consumer } from 'mediasoup-client/types'
const props = defineProps<{
consumer: Consumer
}>()
const appData = computed(() => {
return Object.entries(props.consumer.appData)
})
</script>

View File

@@ -0,0 +1,20 @@
<template>
<Teleport to="body">
<div ref="root" class="fullscreen-gallery">
{{ videoConsumers.length + shareConsumers.length }}
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
const rootRef = useTemplateRef('root')
const { enter } = useFullscreen(rootRef)
const { videoConsumers, shareConsumers } = useMediasoup()
onMounted(() => {
// enter()
})
</script>

View File

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

View File

@@ -0,0 +1,40 @@
<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,80 +8,133 @@ 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 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 videoEnabled = computed(() => {
return !!mediasoup.videoProducer.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) => { const sharingEnabled = computed(() => {
if (outputMuted) { return !!mediasoup.shareProducer.value
previousInputMuted.value = inputMuted.value })
muteInput()
} const somebodyStreamingVideo = computed(() => {
else { return !!mediasoup.videoProducer.value
inputMuted.value = previousInputMuted.value || !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value)
return
await mediasoup.pauseProducer(mediasoup.micProducer.value)
sfx.playEvent('mic-off').then()
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
}
async function unmuteInput() {
if (!inputMuted.value || !mediasoup.micProducer.value)
return
if (outputMuted.value) {
await unmuteOutput()
} }
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { await mediasoup.resumeProducer(mediasoup.micProducer.value)
outputMuted,
sfx.playEvent('mic-on').then()
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 toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
await sfx.playEvent('stream-on')
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.playEvent('stream-off')
}
}
async function toggleShare() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
await sfx.playEvent('stream-on')
}
else {
await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.playEvent('stream-off')
}
} }
return { return {
@@ -95,8 +148,13 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version, version,
isTauri, isTauri,
commitSha, commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

@@ -0,0 +1,52 @@
import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
const mediasoup = useMediasoup()
const { getClient } = useClients()
const client = computed(() => getClient(unref(socketId))!)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${client.value.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${client.value.userId}`), false, { writeDefaults: false })
const consumers = computed(() => {
return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const audioConsumers = computed(() => {
return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const videoConsumers = computed(() => {
return mediasoup.videoConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const shareConsumers = computed(() => {
return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const producers = computed(() => {
return Object.values(mediasoup.producers.value).filter(producer => producer.appData.socketId === client.value.socketId)
})
const streaming = computed(() => {
return videoConsumers.value.length > 0 || shareConsumers.value.length > 0
})
const speaking = computed(() => {
return mediasoup.speakingClients.value.some(speaker => speaker.clientId === client.value.socketId)
})
return {
volume,
premuted,
consumers,
producers,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
speaking,
}
}

View File

@@ -0,0 +1,37 @@
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 },
},
})
}
;(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))),
getShareStream,
}
})

View File

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

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,10 +1,18 @@
import type { ChadClient } from '#shared/types' import type { ChadClient, Consumer, Producer } 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'
type ProducerType = 'microphone' | 'camera' | 'share' type ProducerType = 'microphone' | 'video' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [ const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@@ -21,23 +29,54 @@ 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, me } = 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>()
const sendTransport = shallowRef<mediasoupClient.types.Transport>() const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>() const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const micProducer = shallowRef<mediasoupClient.types.Producer>() const consumers = ref<Record<Consumer['id'], Consumer>>({})
const cameraProducer = shallowRef<mediasoupClient.types.Producer>() const producers = ref<Record<Producer['id'], Producer>>({})
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map()) const consumersArray = computed(() => {
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map()) return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => { watch(signaling.socket, (socket) => {
if (!socket) if (!socket)
@@ -131,12 +170,16 @@ export const useMediasoup = createSharedComposable(() => {
addClient(...joinedClients) addClient(...joinedClients)
if (me.value)
sfx.playRandomConnectionSound(me.value.socketId).then()
toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 }) toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 })
await enableMic() await enableMic()
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client) addClient(client)
}) })
@@ -158,20 +201,41 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.share ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (kind === 'video')
sfx.playEvent('stream-on').then()
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumer.on('transportclose', () => { consumers.value[consumer.id] = {
if (consumers.value.delete(consumer.id)) id: consumer.id,
triggerRef(consumers) paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
}) })
consumers.value.set(consumer.id, consumer) consumer.observer.on('pause', () => {
triggerRef(consumers) consumers.value[consumer.id]!.paused = true
})
consumer.observer.on('close', () => {
if (kind === 'video')
sfx.playEvent('stream-off').then()
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -182,11 +246,37 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
if (consumers.value.delete(consumerId)) const consumer = consumers.value[consumerId]
triggerRef(consumers)
if (!consumer)
return
consumer.raw.close()
}, },
) )
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined rtpCapabilities.value = undefined
@@ -197,53 +287,69 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
micProducer.value = undefined consumers.value = {}
cameraProducer.value = undefined producers.value = {}
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
console.log(consumerId)
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' })
function getClientConsumers(socketId: ChadClient['socketId']) { async function createProducer(options: ProducerOptions) {
return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId) if (!device.value || !sendTransport.value)
return
if (!options.track)
return
if (!device.value.canProduce(options.track.kind as MediaKind))
return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
})
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]!)
})
}
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
return
try {
producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id,
})
}
catch {
}
finally {
delete producers.value[producer.id]
}
} }
async function enableMic() { async function enableMic() {
if (micProducer.value) if (micProducer.value)
return return
if (!device.value || !sendTransport.value)
return
if (!device.value.canProduce('audio'))
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 },
@@ -258,131 +364,128 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
micProducer.value = await sendTransport.value.produce({ await createProducer({
track, track,
streamId: 'mic-video',
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
}) appData: {
source: 'mic-video',
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) if (!micProducer.value)
return return
producers.value.delete(micProducer.value.id) await disableProducer(micProducer.value)
try {
micProducer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: micProducer.value.id,
})
}
catch {
}
finally {
triggerRef(producers)
triggerRef(micProducer)
}
micProducer.value = undefined
} }
async function pauseProducer(type: ProducerType) { async function enableVideo() {
if (videoProducer.value)
return
if (!device.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
const stream = await getShareStream(preferences.shareFps.value)
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type) if (producer.paused)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.value.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.resume() producer.raw.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(type: ProducerType) { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.value.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.pause() producer.raw.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
}
}
watch(
preferences.inputDeviceId,
async (inputDeviceId) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
},
)
watch([ watch([
preferences.inputDeviceId, preferences.inputDeviceId,
preferences.echoCancellation, preferences.echoCancellation,
@@ -398,18 +501,23 @@ 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,
cameraProducer, videoProducer,
shareProducer, shareProducer,
getClientConsumers,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
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,26 +8,23 @@ 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')
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)
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)
}) })
@@ -36,6 +33,10 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value) return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
}) })
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced( watchDebounced(
[toggleInputHotkey, toggleOutputHotkey], [toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => { async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -60,15 +61,15 @@ export const usePreferences = createGlobalState(() => {
synced, synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
shareFps,
toggleInputHotkey, toggleInputHotkey,
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))), videoDeviceExist,
audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))),
} }
}) })

View File

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

View File

@@ -1,25 +1,37 @@
<template> <template>
<div class="grid grid-cols-2 gap-2 p-2 h-screen grid-rows-[auto_1fr]"> <div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
<div <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="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" />
</template>
</PrimeButton>
</div> </div>
<div <div
@@ -28,7 +40,7 @@
<PrimeSelectButton <PrimeSelectButton
v-model="activeTab" v-model="activeTab"
:options="tabs" :options="tabs"
data-key="id" option-label="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"
> >
@@ -38,24 +50,51 @@
</PrimeSelectButton> </PrimeSelectButton>
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div v-auto-animate class="p-3 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>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div class="p-3"> <div class="p-3">
<slot /> <slot />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
</div> </div>
<FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MessageCircle, Mic, MicOff, Settings, UserPen, Volume2, VolumeOff } from 'lucide-vue-next' import {
Camera,
CameraOff,
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
TvMinimalPlay,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp() const {
version,
clients,
inputMuted,
outputMuted,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
toggleInput,
toggleOutput,
toggleVideo,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling() const { connect, connected } = useSignaling()
interface Tab { interface Tab {
@@ -66,31 +105,47 @@ interface Tab {
const route = useRoute() const route = useRoute()
const tabs: Tab[] = [ const tabs = computed<Tab[]>(() => {
{ const result = []
id: 'Index',
icon: MessageCircle,
onClick: () => {
navigateTo({ name: 'Index' })
},
},
{
id: 'Profile',
icon: UserPen,
onClick: () => {
navigateTo({ name: 'Profile' })
},
},
{
id: 'Preferences',
icon: Settings,
onClick: () => {
navigateTo({ name: 'Preferences' })
},
},
]
const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!) if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
{
id: 'Index',
icon: MessageCircle,
onClick: () => {
navigateTo({ name: 'Index' })
},
},
{
id: 'Profile',
icon: UserPen,
onClick: () => {
navigateTo({ name: 'Profile' })
},
},
{
id: 'Preferences',
icon: Settings,
onClick: () => {
navigateTo({ name: 'Preferences' })
},
},
)
return result
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
watch(activeTab, (activeTab) => { watch(activeTab, (activeTab) => {
activeTab.onClick() activeTab.onClick()

View File

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

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

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

@@ -11,6 +11,7 @@
option-label="label" option-label="label"
option-value="deviceId" option-value="deviceId"
input-id="inputDevice" input-id="inputDevice"
placeholder="No input device"
fluid fluid
:invalid="!inputDeviceExist" :invalid="!inputDeviceExist"
/> />
@@ -47,6 +48,42 @@
<!-- <label for="outputDevice">Output device</label> --> <!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> --> <!-- </PrimeFloatLabel> -->
<PrimeDivider align="left">
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>
<template v-if="isTauri"> <template v-if="isTauri">
<PrimeDivider align="left"> <PrimeDivider align="left">
Hotkeys Hotkeys
@@ -106,9 +143,11 @@ definePageMeta({
}) })
const { isTauri, version, commitSha } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -116,10 +155,17 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
audioInputs, videoDeviceExist,
audioOutputs, 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", "dev": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
@@ -19,8 +19,9 @@
"@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.16.7", "mediasoup-client": "^3.18.6",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -37,6 +38,7 @@
"@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.

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