1 Commits

Author SHA1 Message Date
Ivan Grachyov
ca773a56c6 chat WIP 2025-12-26 23:36:21 +03:00
250 changed files with 536 additions and 815 deletions

Binary file not shown.

View File

@@ -31,17 +31,17 @@ body {
.p-select-overlay {
/* Force dropdown width to match computed min-width from PrimeVue internals. */
width: 0 !important;
width: 0;
}
.p-select-label {
width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.p-select-option-label {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -13,18 +13,19 @@ declare module 'vue' {
PrimeButton: typeof import('primevue/button')['default']
PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
PrimeCard: typeof import('primevue/card')['default']
PrimeDivider: typeof import('primevue/divider')['default']
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputText: typeof import('primevue/inputtext')['default']
PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default']
PrimeTab: typeof import('primevue/tab')['default']
PrimeTabList: typeof import('primevue/tablist')['default']
PrimeTabPanel: typeof import('primevue/tabpanel')['default']
PrimeTabPanels: typeof import('primevue/tabpanels')['default']
PrimeTabs: typeof import('primevue/tabs')['default']
PrimeTextarea: typeof import('primevue/textarea')['default']
PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -6,29 +6,19 @@
'bg-surface-800': expanded,
}"
>
<div class="p-3" @click="toggleExpand">
<div class="flex items-center gap-3">
<PrimeAvatar
size="small"
class="shrink-0"
:class="{
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<div class="p-3 flex items-center gap-3" @click="toggleExpand">
<PrimeAvatar size="small">
<template #icon>
<User :size="20" />
</template>
</PrimeAvatar>
<p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
<div class="flex-1 overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
{{ client.displayName || client.username }}
</p>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
</div>
<div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-if="streaming" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<div class="flex align-center gap-1">
<PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
@@ -36,6 +26,8 @@
<PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
</div>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" />
</div>
<CollapseTransition v-if="!isMe">
@@ -60,6 +52,7 @@
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import CollapseTransition from '~/components/CollapseTransition.vue'
@@ -74,34 +67,34 @@ const { show } = useFullscreenVideo()
const expanded = ref(false)
const {
volume,
premuted,
speaking,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
} = useClient(toRef(() => props.client.socketId))
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false })
const isMe = computed(() => {
return me.value && props.client.userId === me.value.userId
})
const consumers = computed(() => {
return allConsumers.value.values().filter(consumer => consumer.appData.socketId === props.client.socketId).toArray()
})
const audioConsumer = computed(() => {
return audioConsumers.value[0]
return consumers.value.find(consumer => consumer.track.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumers.value.filter(consumer => consumer.track.kind === 'video')
})
const shareConsumer = computed(() => {
return videoConsumers.value.find(consumer => consumer.appData.source === 'share')
})
const audioTrack = computed(() => {
return audioConsumer.value?.raw.track
return audioConsumer.value?.track
})
const audioConsumerPaused = computed(() => {
if (Object.keys(allConsumers.value).length === 0)
return false
return audioConsumer.value?.paused ?? false
})
const audioConsumerPaused = ref(false)
const inputMuted = computed(() => {
if (isMe.value)
@@ -110,12 +103,8 @@ const inputMuted = computed(() => {
return premuted.value || audioConsumerPaused.value
})
const hasBadges = computed(() => {
return streaming.value
|| premuted.value
|| inputMuted.value
|| props.client.outputMuted
|| isMe.value
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack)
@@ -132,11 +121,9 @@ function toggleExpand() {
}
function watchStream() {
if (!streaming.value)
if (!shareConsumer.value)
return
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.track]))
show(new MediaStream([shareConsumer.value.track]))
}
</script>

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
<template>
<div
class="group cursor-pointer hover:outline outline-primary relative rounded overflow-hidden flex items-center justify-center"
@click="watch"
>
<video :srcObject="stream" muted autoplay />
<PrimeTag
severity="secondary"
class="absolute bottom-2 text-sm opacity-70 group-hover:opacity-100"
rounded
>
{{ isMe ? 'You' : client.displayName }}
</PrimeTag>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
const props = defineProps<{
client: ChadClient
stream: MediaStream
}>()
const { me } = useClients()
const fullscreenVideo = useFullscreenVideo()
const isMe = computed(() => {
return props.client.socketId === me.value?.socketId
})
function watch() {
fullscreenVideo.show(props.stream)
}
</script>
<style>
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="chat-editor">
<PrimeTextarea v-model="msg" />
<PrimeButton :disabled="!msg" @click="handleSend()">
Send
</PrimeButton>
</div>
</template>
<script lang="ts" setup>
interface Emits {
(e: 'send', msg: string): void
}
const emit = defineEmits<Emits>()
const msg = ref<string | undefined>()
function handleSend() {
emit('send', msg.value!)
msg.value = ''
}
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,27 @@
<template>
<PrimeCard>
<template #header>
<span class="font-bold">
{{ username }}
</span>
</template>
<template #content>
{{ message }}
</template>
<template #footer>
{{ createdAt }}
</template>
</PrimeCard>
</template>
<script lang="ts" setup>
interface Props {
username: string
message: string
createdAt: Date
}
defineProps<Props>()
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="chat-tabs">
<div class="chat-tabs__messages">
<ChatMessage v-for="msg in messages" :key="msg.id" :created-at="msg.createdAt" :username="msg.username" :message="msg.message" />
</div>
<PrimeTabs :value="channels[0]">
<PrimeTabList>
<PrimeTab v-for="channel in channels" :key="channel" :value="channel">
Channel: {{ channel }}
</PrimeTab>
</PrimeTabList>
<PrimeTabPanels>
<PrimeTabPanel :value="channel">
<ChatEditor />
</PrimeTabPanel>
</PrimeTabPanels>
</PrimeTabs>
</div>
</template>
<script lang="ts" setup>
const {
channel,
messages,
channels,
} = useChat()
</script>
<style lang="scss" scoped>
</style>

View File

@@ -8,22 +8,11 @@ export const useApp = createGlobalState(() => {
const mediasoup = useMediasoup()
const signaling = useSignaling()
const toast = useToast()
const sfx = useSfx()
const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => {
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-')
const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused
@@ -32,42 +21,29 @@ export const useApp = createGlobalState(() => {
const outputMuted = ref(false)
const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value
})
const somebodyStreamingVideo = computed(() => {
return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value)
if (inputMuted.value)
return
await mediasoup.pauseProducer(mediasoup.micProducer.value)
await mediasoup.pauseProducer('microphone')
sfx.play('/sfx/off_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
}
async function unmuteInput() {
if (!inputMuted.value || !mediasoup.micProducer.value)
if (!inputMuted.value)
return
if (outputMuted.value) {
await unmuteOutput()
}
await mediasoup.resumeProducer(mediasoup.micProducer.value)
await mediasoup.resumeProducer('microphone')
sfx.play('/sfx/on_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
}
@@ -115,25 +91,12 @@ export const useApp = createGlobalState(() => {
await muteOutput()
}
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
await sfx.play('/sfx/on_trans.ogg', 0.03)
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
}
}
async function toggleShare() {
if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare()
await sfx.play('/sfx/on_trans.ogg', 0.03)
}
else {
await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
await mediasoup.disableProducer('share')
}
}
@@ -148,13 +111,10 @@ export const useApp = createGlobalState(() => {
muteOutput,
unmuteOutput,
toggleOutput,
toggleVideo,
version,
isTauri,
commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
}
})

View File

@@ -0,0 +1,44 @@
import { createGlobalState } from '@vueuse/core'
interface ChatMessage {
id: string
username: string
message: string
}
interface ChatChannel {
id: number
name: string
}
export const useChat = createGlobalState(() => {
const messages = ref([
{
id: '1337',
username: 'Yes',
message: 'Fisting is 300 bucks',
createdAt: Date.now(),
},
])
const channel = ref<number>(0)
async function sendMsg(channelId: ChatChannel['id'], msg: ChatMessage['message']) {
console.log('Trying to send message', channelId, msg)
}
watch(channel, async (id) => {
await console.log('Yes', id)
}, {
immediate: true,
})
return {
channel,
channels,
messages,
sendMsg,
}
})

View File

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

View File

@@ -19,16 +19,7 @@ export const useDevices = createGlobalState(() => {
})
}
;(async () => {
if (permissionGranted.value)
return
await ensurePermissions()
})()
return {
ensurePermissions,
permissionGranted,
videoInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioOutputs.value))),

View File

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

View File

@@ -29,25 +29,11 @@ export const useFullscreenVideo = createGlobalState(() => {
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', () => {

View File

@@ -1,18 +1,11 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling'
type ProducerType = 'microphone' | 'video' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
type ProducerType = 'microphone' | 'camera' | 'share'
const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
@@ -29,7 +22,6 @@ const ICE_SERVERS: RTCIceServer[] = [
export const useMediasoup = createSharedComposable(() => {
const toast = useToast()
const sfx = useSfx()
const signaling = useSignaling()
const { addClient, removeClient } = useClients()
@@ -41,42 +33,12 @@ export const useMediasoup = createSharedComposable(() => {
const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const consumers = ref<Record<Consumer['id'], Consumer>>({})
const producers = ref<Record<Producer['id'], Producer>>({})
const micProducer = shallowRef<mediasoupClient.types.Producer>()
const cameraProducer = shallowRef<mediasoupClient.types.Producer>()
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumersArray = computed(() => {
return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
watch(signaling.socket, (socket) => {
if (!socket)
@@ -176,7 +138,6 @@ export const useMediasoup = createSharedComposable(() => {
})
socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client)
})
@@ -198,35 +159,20 @@ export const useMediasoup = createSharedComposable(() => {
producerId,
kind,
rtpParameters,
streamId: `${socketId}-${appData.source || 'stream'}`,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId },
})
if (producerPaused)
consumer.pause()
consumers.value[consumer.id] = {
id: consumer.id,
paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
consumer.on('transportclose', () => {
if (consumers.value.delete(consumer.id))
triggerRef(consumers)
})
consumer.observer.on('pause', () => {
consumers.value[consumer.id]!.paused = true
})
consumer.observer.on('close', () => {
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
consumers.value.set(consumer.id, consumer)
triggerRef(consumers)
cb()
},
@@ -237,37 +183,11 @@ export const useMediasoup = createSharedComposable(() => {
async (
{ consumerId },
) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.close()
if (consumers.value.delete(consumerId))
triggerRef(consumers)
},
)
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => {
device.value = undefined
rtpCapabilities.value = undefined
@@ -278,12 +198,43 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close()
recvTransport.value = undefined
consumers.value = {}
producers.value = {}
micProducer.value = undefined
cameraProducer.value = undefined
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
triggerRef(consumers)
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.resume()
triggerRef(consumers)
})
}, { immediate: true, flush: 'sync' })
async function createProducer(options: ProducerOptions) {
async function enableProducer(type: ProducerType, options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value)
return
@@ -293,54 +244,47 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind))
return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producer.value = await sendTransport.value.produce(options)
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producers.value.set(producer.value.id, producer.value)
triggerRef(producers)
triggerRef(producer)
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
producer.value.on('transportclose', () => {
micProducer.value = undefined
})
producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false
})
producer.observer.on('close', () => {
delete producers.value[producer.id]
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
producer.value.on('trackended', () => {
disableProducer(type)
})
}
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
async function disableProducer(type: ProducerType) {
const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return
producers.value.delete(producer.value.id)
try {
producer.raw.close()
producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
catch {
}
finally {
delete producers.value[producer.id]
triggerRef(producers)
triggerRef(producer)
}
producer.value = undefined
}
async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: preferences.inputDeviceId.value },
@@ -355,125 +299,110 @@ export const useMediasoup = createSharedComposable(() => {
if (!track)
return
await createProducer({
await enableProducer('microphone', {
track,
streamId: 'mic-video',
codecOptions: {
opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос
},
appData: {
source: 'mic-video',
},
})
}
async function disableMic() {
if (!micProducer.value)
return
await disableProducer(micProducer.value)
}
async function enableVideo() {
if (videoProducer.value)
return
if (!device.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
await disableProducer('microphone')
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
const stream = await getShareStream(preferences.shareFps.value)
const stream = await getShareStream()
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
await enableProducer('share', {
track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1',
c => c.mimeType.toLowerCase() === 'video/h264',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(producer: Producer) {
async function pauseProducer(type: ProducerType) {
if (!signaling.socket.value)
return
if (producer.paused)
const producer = getProducerByType(type)
if (!producer.value)
return
if (producer.value.paused)
return
try {
producer.raw.pause()
producer.value.pause()
await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
catch {
producer.raw.resume()
producer.value.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
}
}
async function resumeProducer(producer: Producer) {
async function resumeProducer(type: ProducerType) {
if (!signaling.socket.value)
return
const producer = getProducerByType(type)
if (!producer.value)
return
try {
producer.raw.resume()
producer.value.resume()
await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id,
producerId: producer.value.id,
})
}
catch {
producer.raw.pause()
producer.value.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
}
}
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
}
}
@@ -492,22 +421,18 @@ export const useMediasoup = createSharedComposable(() => {
})
return {
init,
consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers,
speakingClients,
sendTransport,
recvTransport,
rtpCapabilities,
device,
micProducer,
videoProducer,
cameraProducer,
shareProducer,
pauseProducer,
resumeProducer,
enableVideo,
enableShare,
disableProducer,
}

View File

@@ -14,14 +14,11 @@ export const usePreferences = createGlobalState(() => {
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
const shareFps = useLocalStorage('SHARE_FPS', 30)
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
@@ -33,10 +30,6 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
})
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced(
[toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -61,15 +54,12 @@ export const usePreferences = createGlobalState(() => {
synced,
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
shareFps,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
}
})

View File

@@ -1,43 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
import { Howl, Howler } from 'howler'
const CONNECTION_SOUNDS = Object.keys(import.meta.glob('@/../public/sfx/connection/*.ogg')).map(path => path.replace('../public', ''))
console.log('CONNECTION_SOUNDS', CONNECTION_SOUNDS)
function hashStringToNumber(str: string, cap: number): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) | 0
}
return Math.abs(hash) % cap
}
export const useSfx = createSharedComposable(() => {
async function play(src: string, volume = 0.2): Promise<void> {
Howler.stop()
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
howl.on('end', () => {
resolve()
})
})
}
async function playRandomConnectionSound(seed: string) {
await play('/sfx/on_trans.ogg', 0.03)
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length + 1)]!, 0.1)
}
return {
play,
playRandomConnectionSound,
}
})

View File

@@ -1,10 +1,10 @@
<template>
<div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
<div class="grid grid-cols-[1fr_1.25fr] gap-2 p-2 h-screen grid-rows-[auto_1fr]">
<div
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
>
<div class="inline-flex items-center gap-3">
<PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div>
@@ -21,12 +21,6 @@
</PrimeButton>
</PrimeButtonGroup>
<PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
<template #icon>
<Component :is="videoEnabled ? CameraOff : Camera" />
</template>
</PrimeButton>
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
@@ -40,7 +34,7 @@
<PrimeSelectButton
v-model="activeTab"
:options="tabs"
option-label="id"
data-key="id"
:allow-empty="false"
style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
>
@@ -50,49 +44,42 @@
</PrimeSelectButton>
</div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0">
<div v-auto-animate class="p-3 space-y-1">
<ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div>
</PrimeScrollPanel>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0">
<div class="p-3">
<slot />
</div>
</PrimeScrollPanel>
</div>
<FullscreenGallery />
</template>
<script setup lang="ts">
import {
Camera,
CameraOff,
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
TvMinimalPlay,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const {
isTauri,
version,
clients,
inputMuted,
outputMuted,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
toggleInput,
toggleOutput,
toggleVideo,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling()
@@ -105,20 +92,7 @@ interface Tab {
const route = useRoute()
const tabs = computed<Tab[]>(() => {
const result = []
if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
const tabs: Tab[] = [
{
id: 'Index',
icon: MessageCircle,
@@ -140,12 +114,9 @@ const tabs = computed<Tab[]>(() => {
navigateTo({ name: 'Preferences' })
},
},
)
]
return result
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!)
watch(activeTab, (activeTab) => {
activeTab.onClick()

View File

@@ -1,64 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<GalleryCard
v-for="item in gallery"
:key="item.client.socketId"
:client="item.client"
:stream="item.stream"
/>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
interface GalleryItem {
client: ChadClient
stream: MediaStream
}
definePageMeta({
name: 'Gallery',
})
const { videoProducer, shareProducer } = useMediasoup()
const { clients, me } = useClients()
const gallery = computed(() => {
return clients.value.reduce<GalleryItem[]>(
(acc, client) => {
const { streaming, videoConsumers, shareConsumers } = useClient(client.socketId)
if (!streaming.value)
return acc
for (const consumer of [...videoConsumers.value, ...shareConsumers.value]) {
acc.push({
client,
stream: new MediaStream([consumer.raw.track]),
})
}
return acc
},
[videoProducer.value, shareProducer.value].reduce<GalleryItem[]>((acc, producer) => {
if (!me.value || !producer || !producer.raw.track)
return acc
acc.push({
client: me.value,
stream: new MediaStream([producer.raw.track]),
})
return acc
}, []),
)
})
watch(gallery, (gallery) => {
if (gallery.length > 0)
return
navigateTo({ name: 'Index' })
})
</script>

View File

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

View File

@@ -11,7 +11,6 @@
option-label="label"
option-value="deviceId"
input-id="inputDevice"
placeholder="No input device"
fluid
:invalid="!inputDeviceExist"
/>
@@ -48,42 +47,6 @@
<!-- <label for="outputDevice">Output device</label> -->
<!-- </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">
<PrimeDivider align="left">
Hotkeys
@@ -143,11 +106,10 @@ definePageMeta({
})
const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { audioInputs, audioOutputs } = useDevices()
const {
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
@@ -155,17 +117,8 @@ const {
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
shareFps,
} = usePreferences()
const shareFpsOptions = [5, 30, 60].map((value) => {
return {
label: value.toString(),
value,
}
})
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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