9 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
226 changed files with 534 additions and 66 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

@@ -10,17 +10,18 @@
class="absolute bottom-2 text-sm opacity-70 group-hover:opacity-100" class="absolute bottom-2 text-sm opacity-70 group-hover:opacity-100"
rounded rounded
> >
{{ isMe ? 'You' : client.username }} {{ isMe ? 'You' : client.displayName }}
</PrimeTag> </PrimeTag>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ChadClient } from '#shared/types' import type { ChadClient, Consumer, Producer } from '#shared/types'
const props = defineProps<{ const props = defineProps<{
client: ChadClient client: ChadClient
stream: MediaStream consumer?: Consumer
producer?: Producer
}>() }>()
const { me } = useClients() const { me } = useClients()
@@ -30,8 +31,21 @@ const isMe = computed(() => {
return props.client.socketId === me.value?.socketId 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() { function watch() {
fullscreenVideo.show(props.stream) if (stream.value)
fullscreenVideo.show(stream.value)
} }
</script> </script>

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)
@@ -52,7 +52,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.pauseProducer(mediasoup.micProducer.value) 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() {
@@ -65,7 +65,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.resumeProducer(mediasoup.micProducer.value) 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() {
@@ -89,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() {
@@ -102,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() {
@@ -115,18 +115,22 @@ export const useApp = createGlobalState(() => {
async function toggleVideo() { async function toggleVideo() {
if (!mediasoup.videoProducer.value) { if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo() await mediasoup.enableVideo()
emit('video:enabled')
} }
else { else {
await mediasoup.disableProducer(mediasoup.videoProducer.value) 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(mediasoup.shareProducer.value)
emit('share:disabled')
} }
} }

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

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

@@ -7,8 +7,6 @@ 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' | 'video' | 'share'
interface SpeakingClient { interface SpeakingClient {
clientId: ChadClient['socketId'] clientId: ChadClient['socketId']
volume: number volume: number
@@ -28,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()
@@ -169,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(
@@ -210,16 +216,27 @@ export const useMediasoup = createSharedComposable(() => {
raw: markRaw(consumer), raw: markRaw(consumer),
} }
emit('consumer:added', consumers.value[consumer.id]!)
consumer.observer.on('resume', () => { consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false consumers.value[consumer.id]!.paused = false
emit('consumer:resumed', consumers.value[consumer.id]!)
}) })
consumer.observer.on('pause', () => { consumer.observer.on('pause', () => {
consumers.value[consumer.id]!.paused = true consumers.value[consumer.id]!.paused = true
emit('consumer:paused', consumers.value[consumer.id]!)
}) })
consumer.observer.on('close', () => { consumer.observer.on('close', () => {
const consumerData = consumers.value[consumer.id]
delete consumers.value[consumer.id] delete consumers.value[consumer.id]
if (consumerData)
emit('consumer:removed', consumerData)
}) })
consumer.on('trackended', () => { consumer.on('trackended', () => {
@@ -300,16 +317,27 @@ export const useMediasoup = createSharedComposable(() => {
raw: markRaw(producer), raw: markRaw(producer),
} }
emit('producer:added', producers.value[producer.id]!)
producer.observer.on('pause', () => { producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true producers.value[producer.id]!.paused = true
emit('producer:paused', producers.value[producer.id]!)
}) })
producer.observer.on('resume', () => { producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false producers.value[producer.id]!.paused = false
emit('producer:resumed', producers.value[producer.id]!)
}) })
producer.observer.on('close', () => { producer.observer.on('close', () => {
const producerData = producers.value[producer.id]
delete producers.value[producer.id] delete producers.value[producer.id]
if (producerData)
emit('producer:removed', producerData)
}) })
producer.on('trackended', () => { producer.on('trackended', () => {

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

@@ -40,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"
> >

View File

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

View File

@@ -74,7 +74,14 @@
<p class="text-sm mb-2 text-center"> <p class="text-sm mb-2 text-center">
FPS FPS
</p> </p>
<PrimeSelectButton v-model="shareFps" :options="[5, 30, 60]" fluid size="small" /> <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">
@@ -152,6 +159,13 @@ const {
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

@@ -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.18.6", "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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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