1 Commits

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

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(printf '%s\\\\n' \"\n================================================================================\nCOMPREHENSIVE ARCHITECTURE REPORT\nTauri 2 + Nuxt 4 + mediasoup-client Voice Chat App\n================================================================================\")"
]
}
}

Binary file not shown.

View File

@@ -1,118 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Chad is a voice/video chat desktop app built with **Tauri 2 + Nuxt 4 (SPA) + mediasoup-client**. It uses an SFU (Selective Forwarding Unit) architecture for WebRTC media — each peer sends to the server, which forwards selectively to others.
## Commands
| Command | Description |
|---------|-------------|
| `yarn dev` | Start dev server (binds to all interfaces via `--host`) |
| `yarn generate` | Generate static site to `.output/public` |
| `yarn build` | Build for production |
| `yarn preview` | Preview production build |
| `npx eslint .` | Lint the project |
| `npx eslint --fix .` | Lint and auto-fix |
**Tauri desktop build**: `yarn tauri build` (runs `yarn generate` first automatically).
Package manager is **Yarn 4.12.0**. No test framework is configured.
## Architecture
### Composable-Based State (No Pinia/Vuex)
All state is managed through Vue composables using `@vueuse/core`'s `createGlobalState` and `createSharedComposable`. Nuxt auto-imports all composables from `app/composables/`.
**Dependency graph** (arrows = "depends on"):
```
useAuth (foundation — user session)
└→ useSignaling (Socket.IO connection to /webrtc namespace)
└→ useClients (connected peer list)
└→ useMediasoup (transports, producers, consumers)
├← usePreferences (device IDs, audio effects, hotkeys)
│ └← useDevices (enumerates hardware via getUserMedia)
└← useSfx (Howler.js sound effects)
useApp (top-level orchestrator — mute/unmute, video/share toggles)
└→ depends on useClients, useMediasoup, useSignaling, useSfx
```
### Composable Tiers
**Global state** (`createGlobalState` — single instance, persists for app lifetime):
- `useApp` — ready state, input/output mute, video/share toggles, version info
- `useAuth``me` ref, login/register/logout
- `useClients``clients[]` array, add/remove/update peers
- `usePreferences` — device selections, audio effects, hotkeys (localStorage + server sync)
- `useUpdater` — Tauri app update checks
- `useFullscreenVideo` — fullscreen video element control
**Shared** (`createSharedComposable` — single instance, disposed when last consumer unmounts):
- `useMediasoup` — mediasoup Device, transports, producers, consumers, speaking state
- `useSignaling` — Socket.IO client, connect/disconnect
- `useSfx` — sound effect playback via Howler.js
**Per-instance** (new instance per call):
- `useClient(socketId)` — per-peer volume/mute (localStorage), filtered consumers/producers
- `useAudioContext(audioTrack)` — Web Audio API gain node for per-client volume
- `useDevices` — media device enumeration
### Initialization Flow
1. **Middleware** (global, ordered by filename prefix):
- `00.updater` — Tauri update check, redirects to `/updater` if available
- `01.auth` — validates session via `GET /me`, redirects guests to `/login`
- `02.user-preferences` — loads server-synced preferences for authenticated users
2. **Plugins**: build info logging, Tauri hotkey registration
3. **Layout** (`default.vue`): calls `useApp()` which triggers `useSignaling().connect()`
4. **Socket authenticated** → mediasoup Device created → transports created → `join()` → mic enabled
### mediasoup Integration
**Producer types** (tracked in `appData.source`):
- Microphone: `kind='audio'`, `source='mic-video'`
- Camera: `kind='video'`, `source='mic-video'`
- Screen share: `kind='video'`, `source='share'`
**Consumer filtering** uses `kind` + `appData.source` to distinguish audio/video/share streams.
Transports use `emitWithAck()` on the Socket.IO connection for all signaling (create, connect, produce, join, pause/resume/close).
### API & Networking
- **REST API**: `shared/chad-api.ts``$fetch` instance with `credentials: 'include'`, base URL from `__API_BASE_URL__` build-time define
- **WebSocket**: Socket.IO to `/webrtc` namespace for signaling
- **Vite proxy**: `/api` → production API server (for dev)
### Shared Types
`shared/types.ts` defines `ChadClient`, `Consumer`, `Producer`, `AppData`, `UpdatedClient` — used by both composables and components.
## Code Conventions
- **ESLint**: `@antfu/eslint-config` with formatters enabled
- **Vue SFC block order**: `<template>`, `<script>`, `<style>` (enforced by ESLint)
- **`console.log` allowed** (no-console is off)
- **PrimeVue components** are prefixed: `PrimeButton`, `PrimeCard`, etc.
- **Icons**: `lucide-vue-next` + `primeicons`
- **Styling**: Tailwind CSS 4 + PrimeVue Aura theme (Zinc palette, dark-first)
- **Reactivity**: mediasoup objects wrapped with `markRaw()` to prevent deep reactivity; `triggerRef()` used for manual reactivity triggers on the clients array
## Build-Time Defines
Set via `.env` or environment variables:
- `API_BASE_URL` — backend API base (default: `http://localhost:4000/chad`)
- `COMMIT_SHA` — git commit hash (default: `'local'`)
## Tauri
- App identifier: `xyz.koptilnya.chad`
- Windows-only NSIS installer (`bundle.targets: ["nsis"]`)
- Plugins: global-shortcut, process, updater, single-instance, log
- Frontend dist: `.output/public` (from `yarn generate`)
- `@tauri-apps/plugin-*` packages provide JS bindings; use `window.__TAURI__` check or the `useTauri()` composable for Tauri detection

View File

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

View File

@@ -13,18 +13,19 @@ declare module 'vue' {
PrimeButton: typeof import('primevue/button')['default'] PrimeButton: typeof import('primevue/button')['default']
PrimeButtonGroup: typeof import('primevue/buttongroup')['default'] PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
PrimeCard: typeof import('primevue/card')['default'] PrimeCard: typeof import('primevue/card')['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']
PrimePassword: typeof import('primevue/password')['default'] PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default'] PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
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'] 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'] PrimeToast: typeof import('primevue/toast')['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']
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +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, Consumer, Producer } from '#shared/types'
const props = defineProps<{
client: ChadClient
consumer?: Consumer
producer?: Producer
}>()
const { me } = useClients()
const fullscreenVideo = useFullscreenVideo()
const isMe = computed(() => {
return props.client.socketId === me.value?.socketId
})
const track = computed(() => {
return props.consumer?.raw.track ?? props.producer?.raw.track
})
const stream = computed<MediaStream | undefined>((previousStream) => {
if (previousStream?.getTracks()[0] === track.value) {
return previousStream
}
return track.value ? new MediaStream([track.value]) : undefined
})
function watch() {
if (stream.value)
fullscreenVideo.show(stream.value)
}
</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

@@ -7,22 +7,12 @@ export const useApp = createGlobalState(() => {
const { clients } = useClients() const { clients } = useClients()
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling() const signaling = useSignaling()
const { emit } = useEventBus() const toast = useToast()
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__ const commitSha = __COMMIT_SHA__
const version = computedAsync(() => { const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-')
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const inputMuted = computed(() => { const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused return !!mediasoup.micProducer.value?.paused
@@ -31,41 +21,30 @@ export const useApp = createGlobalState(() => {
const outputMuted = ref(false) const outputMuted = ref(false)
const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
const sharingEnabled = computed(() => { const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value return !!mediasoup.shareProducer.value
}) })
const somebodyStreamingVideo = computed(() => {
return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() { async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value) if (inputMuted.value)
return return
await mediasoup.pauseProducer(mediasoup.micProducer.value) await mediasoup.pauseProducer('microphone')
emit('audio:muted') toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
async function unmuteInput() { async function unmuteInput() {
if (!inputMuted.value || !mediasoup.micProducer.value) if (!inputMuted.value)
return return
if (outputMuted.value) { if (outputMuted.value) {
await unmuteOutput() await unmuteOutput()
} }
await mediasoup.resumeProducer(mediasoup.micProducer.value) await mediasoup.resumeProducer('microphone')
emit('audio:unmuted') toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
async function toggleInput() { async function toggleInput() {
@@ -89,7 +68,7 @@ export const useApp = createGlobalState(() => {
outputMuted: true, outputMuted: true,
}) })
emit('output:muted') toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
} }
async function unmuteOutput() { async function unmuteOutput() {
@@ -102,7 +81,7 @@ export const useApp = createGlobalState(() => {
outputMuted: false, outputMuted: false,
}) })
emit('output:unmuted') toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
} }
async function toggleOutput() { async function toggleOutput() {
@@ -112,25 +91,12 @@ export const useApp = createGlobalState(() => {
await muteOutput() await muteOutput()
} }
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
emit('video:enabled')
}
else {
await mediasoup.disableProducer(mediasoup.videoProducer.value)
emit('video:disabled')
}
}
async function toggleShare() { async function toggleShare() {
if (!mediasoup.shareProducer.value) { if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare() await mediasoup.enableShare()
emit('share:enabled')
} }
else { else {
await mediasoup.disableProducer(mediasoup.shareProducer.value) await mediasoup.disableProducer('share')
emit('share:disabled')
} }
} }
@@ -145,13 +111,10 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version, version,
isTauri, isTauri,
commitSha, commitSha,
toggleShare, toggleShare,
videoEnabled,
sharingEnabled, sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

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

@@ -4,7 +4,7 @@ import { createGlobalState } from '@vueuse/core'
export const useClients = createGlobalState(() => { export const useClients = createGlobalState(() => {
const auth = useAuth() const auth = useAuth()
const signaling = useSignaling() const signaling = useSignaling()
const { emit } = useEventBus() const toast = useToast()
const clients = shallowRef<ChadClient[]>([]) const clients = shallowRef<ChadClient[]>([])
@@ -16,17 +16,10 @@ export const useClients = createGlobalState(() => {
socket.on('clientChanged', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => { socket.on('clientChanged', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => {
const client = getClient(clientId) const client = getClient(clientId)
if (!client)
return
updateClient(clientId, updatedClient) updateClient(clientId, updatedClient)
emit('client:updated', { if (client && client.displayName !== updatedClient.displayName)
socketId: clientId, toast.add({ severity: 'info', summary: `${client.displayName} is now ${updatedClient.displayName}`, closable: false, life: 1000 })
oldClient: client,
updatedClient,
})
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {

View File

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

View File

@@ -1,42 +0,0 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { EventType } from 'mitt'
import mitt from 'mitt'
export interface AppEvents extends Record<EventType, unknown> {
'socket:connected': void
'socket:disconnected': void
'socket:authenticated': { socketId: string }
'client:added': ChadClient
'client:removed': ChadClient
'client:updated': { socketId: string, oldClient: ChadClient, updatedClient: Partial<ChadClient> }
'consumer:added': Consumer
'consumer:removed': Consumer
'consumer:paused': Consumer
'consumer:resumed': Consumer
'producer:added': Producer
'producer:removed': Producer
'producer:paused': Producer
'producer:resumed': Producer
'audio:muted': void
'audio:unmuted': void
'output:muted': void
'output:unmuted': void
'video:enabled': void
'video:disabled': void
'share:enabled': void
'share:disabled': void
}
const emitter = mitt<AppEvents>()
export function useEventBus() {
return {
emit: emitter.emit,
on: emitter.on,
off: emitter.off,
}
}

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 videoEl.value = el
} }
stream.getTracks().forEach(t =>
t.addEventListener('ended', hide),
)
videoEl.value.addEventListener('ended', hide)
await videoEl.value.requestFullscreen() await videoEl.value.requestFullscreen()
} }
function hide() { 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?.remove()
videoEl.value = undefined
} }
useEventListener(document, 'fullscreenchange', () => { useEventListener(document, 'fullscreenchange', () => {

View File

@@ -1,16 +1,11 @@
import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types' import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client' import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices' import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences' import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling' import { useSignaling } from '~/composables/use-signaling'
interface SpeakingClient { type ProducerType = 'microphone' | 'camera' | 'share'
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' },
@@ -26,10 +21,10 @@ const ICE_SERVERS: RTCIceServer[] = [
] ]
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const { emit } = useEventBus() const toast = useToast()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient, me } = useClients() const { addClient, removeClient } = useClients()
const preferences = usePreferences() const preferences = usePreferences()
const { getShareStream } = useDevices() const { getShareStream } = useDevices()
@@ -38,42 +33,12 @@ export const useMediasoup = createSharedComposable(() => {
const sendTransport = shallowRef<mediasoupClient.types.Transport>() const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>() const recvTransport = shallowRef<mediasoupClient.types.Transport>()
const consumers = ref<Record<Consumer['id'], Consumer>>({}) const micProducer = shallowRef<mediasoupClient.types.Producer>()
const producers = ref<Record<Producer['id'], Producer>>({}) const cameraProducer = shallowRef<mediasoupClient.types.Producer>()
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumersArray = computed(() => { const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map())
return Object.values(consumers.value) const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map())
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => { watch(signaling.socket, (socket) => {
if (!socket) if (!socket)
@@ -167,25 +132,17 @@ export const useMediasoup = createSharedComposable(() => {
addClient(...joinedClients) addClient(...joinedClients)
if (me.value) toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 })
emit('socket:authenticated', { socketId: me.value.socketId })
await enableMic() await enableMic()
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
addClient(client) addClient(client)
emit('client:added', client)
}) })
socket.on('peerClosed', (id) => { socket.on('peerClosed', (id) => {
const { getClient } = useClients()
const client = getClient(id)
removeClient(id) removeClient(id)
if (client)
emit('client:removed', client)
}) })
socket.on( socket.on(
@@ -202,46 +159,20 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.source || 'stream'}`, streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumers.value[consumer.id] = { consumer.on('transportclose', () => {
id: consumer.id, if (consumers.value.delete(consumer.id))
paused: consumer.paused, triggerRef(consumers)
appData: consumer.appData,
raw: markRaw(consumer),
}
emit('consumer:added', consumers.value[consumer.id]!)
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
emit('consumer:resumed', consumers.value[consumer.id]!)
}) })
consumer.observer.on('pause', () => { consumers.value.set(consumer.id, consumer)
consumers.value[consumer.id]!.paused = true triggerRef(consumers)
emit('consumer:paused', consumers.value[consumer.id]!)
})
consumer.observer.on('close', () => {
const consumerData = consumers.value[consumer.id]
delete consumers.value[consumer.id]
if (consumerData)
emit('consumer:removed', consumerData)
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -252,37 +183,11 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
const consumer = consumers.value[consumerId] if (consumers.value.delete(consumerId))
triggerRef(consumers)
if (!consumer)
return
consumer.raw.close()
}, },
) )
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined rtpCapabilities.value = undefined
@@ -293,12 +198,43 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
consumers.value = {} micProducer.value = undefined
producers.value = {} cameraProducer.value = undefined
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
triggerRef(consumers)
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.resume()
triggerRef(consumers)
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
async function createProducer(options: ProducerOptions) { async function enableProducer(type: ProducerType, options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value) if (!device.value || !sendTransport.value)
return return
@@ -308,65 +244,47 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind)) if (!device.value.canProduce(options.track.kind as MediaKind))
return return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options }) producer.value = await sendTransport.value.produce(options)
producers.value[producer.id] = { producers.value.set(producer.value.id, producer.value)
id: producer.id, triggerRef(producers)
paused: producer.paused, triggerRef(producer)
appData: producer.appData,
raw: markRaw(producer),
}
emit('producer:added', producers.value[producer.id]!) producer.value.on('transportclose', () => {
micProducer.value = undefined
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
emit('producer:paused', producers.value[producer.id]!)
}) })
producer.observer.on('resume', () => { producer.value.on('trackended', () => {
producers.value[producer.id]!.paused = false disableProducer(type)
emit('producer:resumed', producers.value[producer.id]!)
})
producer.observer.on('close', () => {
const producerData = producers.value[producer.id]
delete producers.value[producer.id]
if (producerData)
emit('producer:removed', producerData)
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
}) })
} }
async function disableProducer(producer: Producer) { async function disableProducer(type: ProducerType) {
if (!signaling.socket.value) const producer = getProducerByType(type)
if (!signaling.socket.value || !producer.value)
return return
producers.value.delete(producer.value.id)
try { try {
producer.raw.close() producer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', { await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
} }
finally { finally {
delete producers.value[producer.id] triggerRef(producers)
triggerRef(producer)
} }
producer.value = undefined
} }
async function enableMic() { async function enableMic() {
if (micProducer.value)
return
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
deviceId: { exact: preferences.inputDeviceId.value }, deviceId: { exact: preferences.inputDeviceId.value },
@@ -381,125 +299,110 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await createProducer({ await enableProducer('microphone', {
track, track,
streamId: 'mic-video',
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
appData: {
source: 'mic-video',
},
}) })
} }
async function disableMic() { async function disableMic() {
if (!micProducer.value) await disableProducer('microphone')
return
await disableProducer(micProducer.value)
}
async function enableVideo() {
if (videoProducer.value)
return
if (!device.value)
return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
} }
async function enableShare() { async function enableShare() {
if (shareProducer.value)
return
if (!device.value) if (!device.value)
return return
const stream = await getShareStream(preferences.shareFps.value) const stream = await getShareStream()
const track = stream.getVideoTracks()[0] const track = stream.getVideoTracks()[0]
if (!track) if (!track)
return return
await createProducer({ await enableProducer('share', {
track, track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find( codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1', c => c.mimeType.toLowerCase() === 'video/h264',
), ),
codecOptions: { codecOptions: {
videoGoogleStartBitrate: 1000, videoGoogleStartBitrate: 1000,
}, },
zeroRtpOnPause: true,
appData: { appData: {
source: 'share', source: 'share',
}, },
}) })
} }
async function pauseProducer(producer: Producer) { async function pauseProducer(type: ProducerType) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
if (producer.paused) const producer = getProducerByType(type)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.raw.pause() producer.value.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
producer.raw.resume() producer.value.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(producer: Producer) { async function resumeProducer(type: ProducerType) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.raw.resume() producer.value.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id, producerId: producer.value.id,
}) })
} }
catch { catch {
producer.raw.pause() producer.value.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
}
}
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
} }
} }
@@ -518,22 +421,18 @@ export const useMediasoup = createSharedComposable(() => {
}) })
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
videoProducer, cameraProducer,
shareProducer, shareProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
enableShare, enableShare,
disableProducer, disableProducer,
} }

View File

@@ -14,14 +14,11 @@ export const usePreferences = createGlobalState(() => {
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']>('')
@@ -33,10 +30,6 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value) return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
}) })
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced( watchDebounced(
[toggleInputHotkey, toggleOutputHotkey], [toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => { async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -61,15 +54,12 @@ 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,
videoDeviceExist,
} }
}) })

View File

@@ -1,93 +0,0 @@
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,
}
// TODO: refactor this shit
export const useSfx = createSharedComposable(() => {
const { outputMuted } = useApp()
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')
if (outputMuted.value)
return
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length)]!, 0.1)
}
return {
playOneShot,
play,
playRandomConnectionSound,
playEvent,
}
})

View File

@@ -4,7 +4,7 @@ import { io } from 'socket.io-client'
import { parseURL } from 'ufo' import { parseURL } from 'ufo'
export const useSignaling = createSharedComposable(() => { export const useSignaling = createSharedComposable(() => {
const { emit } = useEventBus() const toast = useToast()
const { me } = useAuth() const { me } = useAuth()
const socket = shallowRef<Socket>() const socket = shallowRef<Socket>()
@@ -41,9 +41,9 @@ export const useSignaling = createSharedComposable(() => {
watch(connected, (connected) => { watch(connected, (connected) => {
if (connected) if (connected)
emit('socket:connected') toast.add({ severity: 'success', summary: 'Connected', closable: false, life: 1000 })
else else
emit('socket:disconnected') toast.add({ severity: 'error', summary: 'Disconnected', closable: false, life: 1000 })
}, { immediate: true }) }, { immediate: true })
watch(me, (me) => { watch(me, (me) => {

View File

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

View File

@@ -1,56 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<GalleryCard
v-for="producer in producers"
:key="`producer-${producer.id}`"
:client="me!"
:producer="producer"
/>
<GalleryCard
v-for="consumer in consumers"
:key="`consumer-${consumer.consumer.id}`"
:client="consumer.client"
:consumer="consumer.consumer"
/>
</div>
</template>
<script setup lang="ts">
import type { ChadClient, Consumer, Producer } from '#shared/types'
definePageMeta({
name: 'Gallery',
})
const { videoProducer, shareProducer } = useMediasoup()
const { clients, me } = useClients()
const producers = computed(() => {
return [videoProducer.value, shareProducer.value].filter(p => !!p && !!p.raw.track) as Producer[]
})
const consumers = computed(() => {
return clients.value.reduce<{ client: ChadClient, consumer: Consumer }[]>((acc, client) => {
const { streaming, videoConsumers: clientVideoConsumers, shareConsumers: clientShareConsumers } = useClient(client.socketId)
if (!streaming.value)
return acc
for (const consumer of [...clientVideoConsumers.value, ...clientShareConsumers.value]) {
acc.push({ client, consumer })
}
return acc
}, [])
})
const hasItems = computed(() => producers.value.length > 0 || consumers.value.length > 0)
watch(hasItems, (hasItems) => {
if (hasItems)
return
navigateTo({ name: 'Index' })
})
</script>

View File

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

View File

@@ -11,7 +11,6 @@
option-label="label" option-label="label"
option-value="deviceId" option-value="deviceId"
input-id="inputDevice" input-id="inputDevice"
placeholder="No input device"
fluid fluid
:invalid="!inputDeviceExist" :invalid="!inputDeviceExist"
/> />
@@ -48,42 +47,6 @@
<!-- <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
@@ -143,11 +106,10 @@ definePageMeta({
}) })
const { isTauri, version, commitSha } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices() const { audioInputs, audioOutputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -155,17 +117,8 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
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

@@ -1,57 +0,0 @@
export default defineNuxtPlugin(() => {
const { on } = useEventBus()
const sfx = useSfx()
// Connection sounds
on('socket:authenticated', ({ socketId }) => {
sfx.playRandomConnectionSound(socketId)
})
// Client events
on('client:added', (client) => {
sfx.playRandomConnectionSound(client.socketId)
})
on('client:removed', () => {
sfx.playEvent('stream-off')
})
// Audio mute/unmute
on('audio:muted', () => {
sfx.playEvent('mic-off')
})
on('audio:unmuted', () => {
sfx.playEvent('mic-on')
})
// Video/share toggle
on('video:enabled', () => {
sfx.playEvent('stream-on')
})
on('video:disabled', () => {
sfx.playEvent('stream-off')
})
on('share:enabled', () => {
sfx.playEvent('stream-on')
})
on('share:disabled', () => {
sfx.playEvent('stream-off')
})
// Consumer video streams
on('consumer:added', (consumer) => {
if (consumer.raw.kind === 'video') {
sfx.playEvent('stream-on')
}
})
on('consumer:removed', (consumer) => {
if (consumer.raw.kind === 'video') {
sfx.playEvent('stream-off')
}
})
})

View File

@@ -1,62 +0,0 @@
export default defineNuxtPlugin(() => {
const { on } = useEventBus()
const toast = useToast()
// Socket events
on('socket:connected', () => {
toast.add({ severity: 'success', summary: 'Connected', life: 1000 })
})
on('socket:disconnected', () => {
toast.add({ severity: 'error', summary: 'Disconnected', life: 1000 })
})
on('socket:authenticated', () => {
toast.add({ severity: 'success', summary: 'Joined', life: 1000 })
})
// Client events
on('client:added', (client) => {
toast.add({
severity: 'info',
summary: `${client.displayName} connected`,
life: 1000,
})
})
on('client:removed', (client) => {
toast.add({
severity: 'info',
summary: `${client.displayName} disconnected`,
life: 1000,
})
})
on('client:updated', ({ oldClient, updatedClient }) => {
if (oldClient.displayName !== updatedClient.displayName) {
toast.add({
severity: 'info',
summary: `${oldClient.displayName} is now ${updatedClient.displayName}`,
life: 1000,
})
}
})
// Audio control
on('audio:muted', () => {
toast.add({ severity: 'info', summary: 'Microphone muted', life: 1000 })
})
on('audio:unmuted', () => {
toast.add({ severity: 'info', summary: 'Microphone activated', life: 1000 })
})
// Output mute control
on('output:muted', () => {
toast.add({ severity: 'info', summary: 'Sound muted', life: 1000 })
})
on('output:unmuted', () => {
toast.add({ severity: 'info', summary: 'Sound resumed', life: 1000 })
})
})

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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