13 Commits

Author SHA1 Message Date
363f1008c6 update 2026-02-11 07:05:20 +06:00
1bd8aa0fea cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 51s
2026-02-06 23:14:13 +06:00
626f52c616 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 50s
2026-02-06 23:06:50 +06:00
29914d73a0 cringe sfx 2026-02-06 23:06:42 +06:00
dd530266f9 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 49s
2026-02-06 22:44:11 +06:00
a37b2048fe cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 53s
2026-02-06 22:41:58 +06:00
e3ac3e003c cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 2m35s
2026-02-06 22:32:01 +06:00
6fa142f133 productName typo 2026-02-03 22:06:09 +06:00
8e0a08da05 icons
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 22:02:45 +06:00
0a3b2c3dc8 resize
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 17:05:43 +06:00
e5f1e6bbb3 показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-02-03 16:54:51 +06:00
1354ca3f7e показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 42s
2026-02-03 16:47:33 +06:00
269b19a5be вебкамера там, туда-сюда
All checks were successful
Deploy / publish-web (push) Successful in 1m16s
2026-02-02 14:39:16 +06:00
237 changed files with 1087 additions and 232 deletions

View File

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

Binary file not shown.

118
client/CLAUDE.md Normal file
View File

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

@@ -22,6 +22,7 @@ declare module 'vue' {
PrimeSelect: typeof import('primevue/select')['default'] PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default'] PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default'] PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default']
PrimeToast: typeof import('primevue/toast')['default'] PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default'] PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -28,7 +28,7 @@
</div> </div>
<div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2"> <div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" /> <PrimeBadge v-if="streaming" 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" />
@@ -60,7 +60,6 @@
<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'
@@ -69,44 +68,40 @@ const props = defineProps<{
}>() }>()
const { outputMuted } = useApp() const { outputMuted } = useApp()
const { consumers: allConsumers, micProducer, speakingClients } = useMediasoup() const { consumers: allConsumers, micProducer } = useMediasoup()
const { me } = useClients() const { me } = useClients()
const { show } = useFullscreenVideo() const { show } = useFullscreenVideo()
const expanded = ref(false) const expanded = ref(false)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false }) const {
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false }) volume,
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 consumers.value.find(consumer => consumer.track.kind === 'audio') return audioConsumers.value[0]
})
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?.track return audioConsumer.value?.raw.track
}) })
const speaking = computed(() => { const audioConsumerPaused = computed(() => {
return speakingClients.value.find(speaker => speaker.clientId === props.client.socketId) if (Object.keys(allConsumers.value).length === 0)
}) return false
const audioConsumerPaused = ref(false) return audioConsumer.value?.paused ?? false
})
const inputMuted = computed(() => { const inputMuted = computed(() => {
if (isMe.value) if (isMe.value)
@@ -116,17 +111,13 @@ const inputMuted = computed(() => {
}) })
const hasBadges = computed(() => { const hasBadges = computed(() => {
return !!shareConsumer.value return streaming.value
|| premuted.value || premuted.value
|| inputMuted.value || inputMuted.value
|| props.client.outputMuted || props.client.outputMuted
|| isMe.value || isMe.value
}) })
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack) const { setGain } = useAudioContext(audioTrack)
watchEffect(() => { watchEffect(() => {
@@ -141,9 +132,11 @@ function toggleExpand() {
} }
function watchStream() { function watchStream() {
if (!shareConsumer.value) if (!streaming.value)
return return
show(new MediaStream([shareConsumer.value.track])) const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.track]))
} }
</script> </script>

View File

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

View File

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

View File

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

View File

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

@@ -7,7 +7,7 @@ export const useApp = createGlobalState(() => {
const { clients } = useClients() const { clients } = useClients()
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling() const signaling = useSignaling()
const toast = useToast() const { emit } = useEventBus()
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
@@ -31,30 +31,41 @@ 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) if (inputMuted.value || !mediasoup.micProducer.value)
return return
await mediasoup.pauseProducer('microphone') await mediasoup.pauseProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 }) emit('audio:muted')
} }
async function unmuteInput() { async function unmuteInput() {
if (!inputMuted.value) if (!inputMuted.value || !mediasoup.micProducer.value)
return return
if (outputMuted.value) { if (outputMuted.value) {
await unmuteOutput() await unmuteOutput()
} }
await mediasoup.resumeProducer('microphone') await mediasoup.resumeProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) emit('audio:unmuted')
} }
async function toggleInput() { async function toggleInput() {
@@ -78,7 +89,7 @@ export const useApp = createGlobalState(() => {
outputMuted: true, outputMuted: true,
}) })
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 }) emit('output:muted')
} }
async function unmuteOutput() { async function unmuteOutput() {
@@ -91,7 +102,7 @@ export const useApp = createGlobalState(() => {
outputMuted: false, outputMuted: false,
}) })
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 }) emit('output:unmuted')
} }
async function toggleOutput() { async function toggleOutput() {
@@ -101,12 +112,25 @@ 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('share') await mediasoup.disableProducer(mediasoup.shareProducer.value)
emit('share:disabled')
} }
} }
@@ -121,10 +145,13 @@ 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,52 @@
import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
const mediasoup = useMediasoup()
const { getClient } = useClients()
const client = computed(() => getClient(unref(socketId))!)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${client.value.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${client.value.userId}`), false, { writeDefaults: false })
const consumers = computed(() => {
return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const audioConsumers = computed(() => {
return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const videoConsumers = computed(() => {
return mediasoup.videoConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const shareConsumers = computed(() => {
return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const producers = computed(() => {
return Object.values(mediasoup.producers.value).filter(producer => producer.appData.socketId === client.value.socketId)
})
const streaming = computed(() => {
return videoConsumers.value.length > 0 || shareConsumers.value.length > 0
})
const speaking = computed(() => {
return mediasoup.speakingClients.value.some(speaker => speaker.clientId === client.value.socketId)
})
return {
volume,
premuted,
consumers,
producers,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
speaking,
}
}

View File

@@ -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 toast = useToast() const { emit } = useEventBus()
const clients = shallowRef<ChadClient[]>([]) const clients = shallowRef<ChadClient[]>([])
@@ -16,10 +16,17 @@ 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)
if (client && client.displayName !== updatedClient.displayName) emit('client:updated', {
toast.add({ severity: 'info', summary: `${client.displayName} is now ${updatedClient.displayName}`, closable: false, life: 1000 }) socketId: clientId,
oldClient: client,
updatedClient,
})
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {

View File

@@ -19,7 +19,16 @@ 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

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

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

View File

@@ -1,4 +1,4 @@
import type { SpeakingClient } from '#shared/types' 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'
@@ -7,7 +7,10 @@ import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences' import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling' import { useSignaling } from '~/composables/use-signaling'
type ProducerType = 'microphone' | 'camera' | 'share' interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [ const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@@ -23,10 +26,10 @@ const ICE_SERVERS: RTCIceServer[] = [
] ]
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const toast = useToast() const { emit } = useEventBus()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient, me } = useClients()
const preferences = usePreferences() const preferences = usePreferences()
const { getShareStream } = useDevices() const { getShareStream } = useDevices()
@@ -35,12 +38,40 @@ 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 micProducer = shallowRef<mediasoupClient.types.Producer>() const consumers = ref<Record<Consumer['id'], Consumer>>({})
const cameraProducer = shallowRef<mediasoupClient.types.Producer>() const producers = ref<Record<Producer['id'], Producer>>({})
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map()) const consumersArray = computed(() => {
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map()) return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([]) const speakingClients = shallowRef<SpeakingClient[]>([])
@@ -136,17 +167,25 @@ export const useMediasoup = createSharedComposable(() => {
addClient(...joinedClients) addClient(...joinedClients)
toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 }) if (me.value)
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(
@@ -163,20 +202,46 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumer.on('transportclose', () => { consumers.value[consumer.id] = {
if (consumers.value.delete(consumer.id)) id: consumer.id,
triggerRef(consumers) paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
emit('consumer:added', consumers.value[consumer.id]!)
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
emit('consumer:resumed', consumers.value[consumer.id]!)
}) })
consumers.value.set(consumer.id, consumer) consumer.observer.on('pause', () => {
triggerRef(consumers) consumers.value[consumer.id]!.paused = true
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()
}, },
@@ -187,11 +252,37 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
if (consumers.value.delete(consumerId)) const consumer = consumers.value[consumerId]
triggerRef(consumers)
if (!consumer)
return
consumer.raw.close()
}, },
) )
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined rtpCapabilities.value = undefined
@@ -202,47 +293,12 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
micProducer.value = undefined consumers.value = {}
cameraProducer.value = undefined producers.value = {}
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
triggerRef(consumers)
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.resume()
triggerRef(consumers)
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
async function enableProducer(type: ProducerType, options: ProducerOptions) { async function createProducer(options: ProducerOptions) {
const producer = getProducerByType(type)
if (producer.value)
return
if (!device.value || !sendTransport.value) if (!device.value || !sendTransport.value)
return return
@@ -252,47 +308,65 @@ export const useMediasoup = createSharedComposable(() => {
if (!device.value.canProduce(options.track.kind as MediaKind)) if (!device.value.canProduce(options.track.kind as MediaKind))
return return
producer.value = await sendTransport.value.produce(options) const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producers.value.set(producer.value.id, producer.value) producers.value[producer.id] = {
triggerRef(producers) id: producer.id,
triggerRef(producer) paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producer.value.on('transportclose', () => { emit('producer:added', producers.value[producer.id]!)
micProducer.value = undefined
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
emit('producer:paused', producers.value[producer.id]!)
}) })
producer.value.on('trackended', () => { producer.observer.on('resume', () => {
disableProducer(type) producers.value[producer.id]!.paused = false
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(type: ProducerType) { async function disableProducer(producer: Producer) {
const producer = getProducerByType(type) if (!signaling.socket.value)
if (!signaling.socket.value || !producer.value)
return return
producers.value.delete(producer.value.id)
try { try {
producer.value.close() producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', { await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
} }
finally { finally {
triggerRef(producers) delete producers.value[producer.id]
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 },
@@ -307,21 +381,67 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await enableProducer('microphone', { await createProducer({
track, track,
streamId: 'mic-video',
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
appData: {
source: 'mic-video',
},
}) })
} }
async function disableMic() { async function disableMic() {
await disableProducer('microphone') 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',
},
})
} }
async function enableShare() { async function enableShare() {
if (shareProducer.value)
return
if (!device.value) if (!device.value)
return return
@@ -332,85 +452,54 @@ export const useMediasoup = createSharedComposable(() => {
if (!track) if (!track)
return return
await enableProducer('share', { await createProducer({
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/AV1',
), ),
codecOptions: { codecOptions: {
videoGoogleStartBitrate: 1000, videoGoogleStartBitrate: 1000,
}, },
zeroRtpOnPause: true,
appData: { appData: {
source: 'share', source: 'share',
}, },
}) })
} }
async function pauseProducer(type: ProducerType) { async function pauseProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type) if (producer.paused)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.value.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.resume() producer.raw.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(type: ProducerType) { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.value.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.pause() producer.raw.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
}
}
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
} }
} }
@@ -429,8 +518,10 @@ export const useMediasoup = createSharedComposable(() => {
}) })
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients, speakingClients,
sendTransport, sendTransport,
@@ -438,10 +529,11 @@ export const useMediasoup = createSharedComposable(() => {
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
cameraProducer, videoProducer,
shareProducer, shareProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
enableShare, enableShare,
disableProducer, disableProducer,
} }

View File

@@ -14,6 +14,7 @@ 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)
@@ -32,6 +33,10 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value) return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
}) })
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced( watchDebounced(
[toggleInputHotkey, toggleOutputHotkey], [toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => { async ([toggleInputHotkey, toggleOutputHotkey]) => {
@@ -56,6 +61,7 @@ export const usePreferences = createGlobalState(() => {
synced, synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -64,5 +70,6 @@ export const usePreferences = createGlobalState(() => {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
} }
}) })

View File

@@ -0,0 +1,93 @@
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 toast = useToast() const { emit } = useEventBus()
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)
toast.add({ severity: 'success', summary: 'Connected', closable: false, life: 1000 }) emit('socket:connected')
else else
toast.add({ severity: 'error', summary: 'Disconnected', closable: false, life: 1000 }) emit('socket:disconnected')
}, { immediate: true }) }, { immediate: true })
watch(me, (me) => { watch(me, (me) => {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="grid grid-cols-[1fr_1.25fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full"> <div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
<div <div
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950" class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
> >
@@ -21,6 +21,12 @@
</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" />
@@ -34,7 +40,7 @@
<PrimeSelectButton <PrimeSelectButton
v-model="activeTab" v-model="activeTab"
:options="tabs" :options="tabs"
data-key="id" option-label="id"
:allow-empty="false" :allow-empty="false"
style="--p-togglebutton-content-padding: 0.25rem 0.5rem" style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
> >
@@ -44,28 +50,33 @@
</PrimeSelectButton> </PrimeSelectButton>
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div v-auto-animate class="p-3 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" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div class="p-3"> <div class="p-3">
<slot /> <slot />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
</div> </div>
<FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
Camera,
CameraOff,
MessageCircle, MessageCircle,
Mic, Mic,
MicOff, MicOff,
ScreenShare, ScreenShare,
ScreenShareOff, ScreenShareOff,
Settings, Settings,
TvMinimalPlay,
UserPen, UserPen,
Volume2, Volume2,
VolumeOff, VolumeOff,
@@ -76,9 +87,12 @@ const {
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()
@@ -91,7 +105,20 @@ interface Tab {
const route = useRoute() const route = useRoute()
const tabs: Tab[] = [ const tabs = computed<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,
@@ -113,9 +140,12 @@ const tabs: Tab[] = [
navigateTo({ name: 'Preferences' }) navigateTo({ name: 'Preferences' })
}, },
}, },
] )
const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!) return result
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
watch(activeTab, (activeTab) => { watch(activeTab, (activeTab) => {
activeTab.onClick() activeTab.onClick()

View File

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

@@ -11,6 +11,7 @@
option-label="label" option-label="label"
option-value="deviceId" option-value="deviceId"
input-id="inputDevice" input-id="inputDevice"
placeholder="No input device"
fluid fluid
:invalid="!inputDeviceExist" :invalid="!inputDeviceExist"
/> />
@@ -51,12 +52,36 @@
Video Video
</PrimeDivider> </PrimeDivider>
<div> <PrimeFloatLabel variant="on">
<div class="text-sm mb-2"> <PrimeSelect
Share FPS v-model="videoDeviceId"
</div> :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>
<PrimeSelectButton v-model="shareFps" :options="[5, 30, 60]" fluid size="small" /> <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> </div>
<template v-if="isTauri"> <template v-if="isTauri">
@@ -118,10 +143,11 @@ definePageMeta({
}) })
const { isTauri, version, commitSha } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs } = useDevices() const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
@@ -129,9 +155,17 @@ const {
toggleOutputHotkey, toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoDeviceExist,
shareFps, 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

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

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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