Compare commits

...

21 Commits

Author SHA1 Message Date
ca8728c90c модалочки оп-оп 2026-06-01 05:07:42 +06:00
0dd9efb9fb обновОЧКИ 2026-05-29 04:28:09 +06:00
3c885edc46 brutalism design 2026-05-24 16:26:52 +06:00
12ae0ae839 brutalism design 2026-05-23 22:49:52 +06:00
d06892e990 brutalism design 2026-05-22 06:01:27 +06:00
5d45c9674f brutalism design 2026-05-22 05:27:41 +06:00
1ca73e786c brutalism design 2026-05-22 05:08:41 +06:00
e4ed785911 brutalism design 2026-05-22 05:08:02 +06:00
abf4d41c23 chat wip 2026-05-14 07:09:52 +06:00
edef0a70d2 brutalism design 2026-05-14 01:05:01 +06:00
6a2111092b working hard 2026-05-09 17:39:42 +06:00
0b148c6a7d работаем бля работаем 2026-05-09 03:21:44 +06:00
f845777bac последовательность запуска плагинов 2026-04-25 00:53:57 +06:00
ad477ee813 вложения, канальчики, бим-бим + бам-бам 2026-04-25 00:51:12 +06:00
0b75148a3f навалил фокуса 2026-04-16 15:24:49 +06:00
c966aa9c4b продолжаю чат 2026-04-16 14:02:59 +06:00
0915d3c64d начало чата
All checks were successful
Deploy / deploy (push) Successful in 3m47s
2026-04-16 02:21:54 +06:00
9f39ee6430 убрал из панели задач, скрыл от шаринга
All checks were successful
Deploy / publish-web (push) Successful in 1m28s
2026-04-15 15:43:43 +06:00
3658975b93 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 1m33s
2026-04-12 22:38:48 +06:00
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
273 changed files with 35268 additions and 2206 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.

54
client/.zed/settings.json Normal file
View File

@@ -0,0 +1,54 @@
{
// Use ESLint's --fix:
"code_actions_on_format": {
"source.fixAll.eslint": true,
},
"formatter": [],
// Enable eslint for all supported languages
// Defaults only include https://github.com/search?q=repo%3Azed-industries%2Fzed%20eslint_languages&type=code
"languages": {
"HTML": {
"language_servers": ["...", "eslint"],
},
"Markdown": {
"language_servers": ["...", "eslint"],
},
"Markdown-Inline": {
"language_servers": ["...", "eslint"],
},
"JSON": {
"language_servers": ["...", "eslint"],
},
"JSONC": {
"language_servers": ["...", "eslint"],
},
"YAML": {
"language_servers": ["...", "eslint"],
},
"CSS": {
"language_servers": ["...", "eslint"],
},
// Add other languages as needed
},
"lsp": {
"eslint": {
"settings": {
"workingDirectories": ["./"],
// Silent the stylistic rules in your IDE, but still auto fix them
"rulesCustomizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true },
],
},
},
},
}

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

@@ -15,6 +15,7 @@ declare module 'vue' {
PrimeCard: typeof import('primevue/card')['default'] PrimeCard: typeof import('primevue/card')['default']
PrimeDivider: typeof import('primevue/divider')['default'] PrimeDivider: typeof import('primevue/divider')['default']
PrimeFloatLabel: typeof import('primevue/floatlabel')['default'] PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputGroup: typeof import('primevue/inputgroup')['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'] PrimeProgressBar: typeof import('primevue/progressbar')['default']

View File

@@ -21,7 +21,7 @@
</PrimeAvatar> </PrimeAvatar>
<p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0"> <p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
{{ client.displayName || client.username }} {{ client.displayName || client.username || client.socketId }}
</p> </p>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" /> <Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />

View File

@@ -16,11 +16,12 @@
</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

@@ -1,5 +1,5 @@
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener'
import { computedAsync, createGlobalState } from '@vueuse/core' import { computedAsync, createGlobalState } from '@vueuse/core'
import { useClients } from '~/composables/use-clients' import { useClients } from '~/composables/use-clients'
@@ -7,8 +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 sfx = useSfx()
const ready = ref(false) const ready = ref(false)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
@@ -53,8 +52,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.pauseProducer(mediasoup.micProducer.value) await mediasoup.pauseProducer(mediasoup.micProducer.value)
sfx.playEvent('mic-off').then() emit('audio:muted')
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
async function unmuteInput() { async function unmuteInput() {
@@ -67,8 +65,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.resumeProducer(mediasoup.micProducer.value) await mediasoup.resumeProducer(mediasoup.micProducer.value)
sfx.playEvent('mic-on').then() emit('audio:unmuted')
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
async function toggleInput() { async function toggleInput() {
@@ -88,11 +85,11 @@ export const useApp = createGlobalState(() => {
await muteInput() await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', { await signaling.socket.value?.emitWithAck('update-client', {
outputMuted: true, outputMuted: true,
}) })
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 }) emit('output:muted')
} }
async function unmuteOutput() { async function unmuteOutput() {
@@ -101,11 +98,11 @@ export const useApp = createGlobalState(() => {
if (!previousInputMuted.value) if (!previousInputMuted.value)
await unmuteInput() await unmuteInput()
await signaling.socket.value?.emitWithAck('updateClient', { await signaling.socket.value?.emitWithAck('update-client', {
outputMuted: false, outputMuted: false,
}) })
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 }) emit('output:unmuted')
} }
async function toggleOutput() { async function toggleOutput() {
@@ -118,22 +115,31 @@ export const useApp = createGlobalState(() => {
async function toggleVideo() { async function toggleVideo() {
if (!mediasoup.videoProducer.value) { if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo() await mediasoup.enableVideo()
await sfx.playEvent('stream-on') emit('video:enabled')
} }
else { else {
await mediasoup.disableProducer(mediasoup.videoProducer.value) await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.playEvent('stream-off') emit('video:disabled')
} }
} }
async function toggleShare() { async function toggleShare() {
if (!mediasoup.shareProducer.value) { if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare() await mediasoup.enableShare()
await sfx.playEvent('stream-on') emit('share:enabled')
} }
else { else {
await mediasoup.disableProducer(mediasoup.shareProducer.value) await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.playEvent('stream-off') emit('share:disabled')
}
}
async function openUrl(href: string) {
if (isTauri.value) {
await tauriOpenUrl(href)
}
else {
window.open(href, '_blank', 'noopener noreferrer')
} }
} }
@@ -156,5 +162,6 @@ export const useApp = createGlobalState(() => {
videoEnabled, videoEnabled,
sharingEnabled, sharingEnabled,
somebodyStreamingVideo, somebodyStreamingVideo,
openUrl,
} }
}) })

View File

@@ -16,7 +16,7 @@ export const useAuth = createGlobalState(() => {
async function login(username: string, password: string): Promise<void> { async function login(username: string, password: string): Promise<void> {
try { try {
const result = await chadApi<Me>('/login', { const result = await chadApi<Me>('/auth/login', {
method: 'POST', method: 'POST',
body: { body: {
username, username,
@@ -33,7 +33,7 @@ export const useAuth = createGlobalState(() => {
async function register(username: string, password: string): Promise<void> { async function register(username: string, password: string): Promise<void> {
try { try {
const result = await chadApi<Me>('/register', { const result = await chadApi<Me>('/auth/register', {
method: 'POST', method: 'POST',
body: { body: {
username, username,
@@ -50,7 +50,7 @@ export const useAuth = createGlobalState(() => {
async function logout(): Promise<void> { async function logout(): Promise<void> {
try { try {
await chadApi('/logout', { method: 'POST' }) await chadApi('/auth/logout', { method: 'POST' })
setMe(undefined) setMe(undefined)

View File

@@ -0,0 +1,63 @@
import chadApi from '#shared/chad-api'
import { createGlobalState } from '@vueuse/core'
export interface ChatClientMessage {
text: string
// replyTo?: {
// messageId: string
// }
}
export interface ChatMessage {
id: string
senderId: string
text: string
createdAt: string
updatedAt: string
attachments: string[]
// replyTo?: {
// messageId: string
// sender: string
// text: string
// }
}
export const useChat = createGlobalState(() => {
const signaling = useSignaling()
const { emit } = useEventBus()
const messages = shallowRef<ChatMessage[]>([])
watch(signaling.socket, (socket) => {
if (!socket)
return
socket.on('chat:new-message', (message: ChatMessage) => {
messages.value.push(message)
triggerRef(messages)
emit('chat:new-message')
})
socket.on('disconnect', () => {
messages.value = []
})
}, { immediate: true, flush: 'sync' })
async function sendMessage(message: ChatClientMessage) {
message.text = message.text.trim()
if (!message.text.length)
return
await chadApi<ChatMessage>('/chat/send', {
method: 'POST',
body: message,
})
}
return {
messages,
sendMessage,
}
})

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[]>([])
@@ -14,12 +14,19 @@ export const useClients = createGlobalState(() => {
if (!socket) if (!socket)
return return
socket.on('clientChanged', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => { socket.on('client-updated', (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,44 @@
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
'chat:new-message': 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,16 +26,15 @@ const ICE_SERVERS: RTCIceServer[] = [
] ]
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const toast = useToast() const eventBus = useEventBus()
const sfx = useSfx()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient, me, clients, updateClient } = useClients()
const preferences = usePreferences() const preferences = usePreferences()
const { getShareStream } = useDevices() const { getShareStream } = useDevices()
const device = shallowRef<mediasoupClient.Device>() const device = shallowRef<mediasoupClient.Device>()
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>() const routerRtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
const sendTransport = shallowRef<mediasoupClient.types.Transport>() const sendTransport = shallowRef<mediasoupClient.types.Transport>()
const recvTransport = shallowRef<mediasoupClient.types.Transport>() const recvTransport = shallowRef<mediasoupClient.types.Transport>()
@@ -82,18 +79,30 @@ export const useMediasoup = createSharedComposable(() => {
if (!socket) if (!socket)
return return
socket.on('authenticated', async () => { socket.on('new-client', (client) => {
addClient(client)
eventBus.emit('client:added', client)
})
socket.on('client-switched-channel', (client) => {
updateClient(client.socketId, client)
})
socket.on('initialized', async (initData) => {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
device.value = new mediasoupClient.Device() device.value = new mediasoupClient.Device()
rtpCapabilities.value = await signaling.socket.value.emitWithAck('getRtpCapabilities') routerRtpCapabilities.value = initData.rtpCapabilities
await device.value.load({ routerRtpCapabilities: rtpCapabilities.value! }) clients.value = initData.clients
await device.value.load({ routerRtpCapabilities: routerRtpCapabilities.value! })
// Send transport // Send transport
{ {
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: true, consuming: false }) const transportInfo = await signaling.socket.value.emitWithAck('create-transport', { producing: true, consuming: false })
sendTransport.value = device.value.createSendTransport({ sendTransport.value = device.value.createSendTransport({
...transportInfo, ...transportInfo,
iceServers: [ iceServers: [
@@ -104,7 +113,7 @@ export const useMediasoup = createSharedComposable(() => {
sendTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => { sendTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
try { try {
await signaling.socket.value!.emitWithAck('connectTransport', { await signaling.socket.value!.emitWithAck('connect-transport', {
transportId: sendTransport.value!.id, transportId: sendTransport.value!.id,
dtlsParameters, dtlsParameters,
}) })
@@ -138,7 +147,7 @@ export const useMediasoup = createSharedComposable(() => {
// Recv Transport // Recv Transport
{ {
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: false, consuming: true }) const transportInfo = await signaling.socket.value.emitWithAck('create-transport', { producing: false, consuming: true })
recvTransport.value = device.value.createRecvTransport({ recvTransport.value = device.value.createRecvTransport({
...transportInfo, ...transportInfo,
iceServers: [ iceServers: [
@@ -149,7 +158,7 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => { recvTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
try { try {
await signaling.socket.value!.emitWithAck('connectTransport', { await signaling.socket.value!.emitWithAck('connect-transport', {
transportId: recvTransport.value!.id, transportId: recvTransport.value!.id,
dtlsParameters, dtlsParameters,
}) })
@@ -163,29 +172,33 @@ export const useMediasoup = createSharedComposable(() => {
} }
}) })
} }
//
// const joinedClients = (await signaling.socket.value.emitWithAck('join', {
// rtpCapabilities: routerRtpCapabilities.value,
// }))
//
// addClient(...joinedClients)
//
// if (me.value)
// eventBus.emit('socket:authenticated', { socketId: me.value.socketId })
//
const joinedClients = (await signaling.socket.value.emitWithAck('join', { // TODO: при переподключении проверять inputMuted
rtpCapabilities: rtpCapabilities.value,
}))
addClient(...joinedClients)
toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 })
await enableMic() await enableMic()
}) })
socket.on('newPeer', (client) => { socket.on('client-disconnected', (id) => {
sfx.playRandomConnectionSound(client.socketId).then() const { getClient } = useClients()
addClient(client) const client = getClient(id)
})
socket.on('peerClosed', (id) => {
removeClient(id) removeClient(id)
if (client)
eventBus.emit('client:removed', client)
}) })
socket.on( socket.on(
'newConsumer', 'new-consumer',
async ( async (
{ id, producerId, kind, rtpParameters, socketId, appData, producerPaused }, { id, producerId, kind, rtpParameters, socketId, appData, producerPaused },
cb, cb,
@@ -202,9 +215,6 @@ export const useMediasoup = createSharedComposable(() => {
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (kind === 'video')
sfx.playEvent('stream-on').then()
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
@@ -215,19 +225,27 @@ export const useMediasoup = createSharedComposable(() => {
raw: markRaw(consumer), raw: markRaw(consumer),
} }
eventBus.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
eventBus.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
eventBus.emit('consumer:paused', consumers.value[consumer.id]!)
}) })
consumer.observer.on('close', () => { consumer.observer.on('close', () => {
if (kind === 'video') const consumerData = consumers.value[consumer.id]
sfx.playEvent('stream-off').then()
delete consumers.value[consumer.id] delete consumers.value[consumer.id]
if (consumerData)
eventBus.emit('consumer:removed', consumerData)
}) })
consumer.on('trackended', () => { consumer.on('trackended', () => {
@@ -239,7 +257,7 @@ export const useMediasoup = createSharedComposable(() => {
) )
socket.on( socket.on(
'consumerClosed', 'consumer-closed',
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
@@ -252,7 +270,7 @@ export const useMediasoup = createSharedComposable(() => {
}, },
) )
socket.on('consumerPaused', ({ consumerId }) => { socket.on('consumer-paused', ({ consumerId }) => {
const consumer = consumers.value[consumerId] const consumer = consumers.value[consumerId]
if (!consumer) if (!consumer)
@@ -261,7 +279,7 @@ export const useMediasoup = createSharedComposable(() => {
consumer.raw.pause() consumer.raw.pause()
}) })
socket.on('consumerResumed', ({ consumerId }) => { socket.on('consumer-resumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId] const consumer = consumers.value[consumerId]
if (!consumer) if (!consumer)
@@ -270,13 +288,13 @@ export const useMediasoup = createSharedComposable(() => {
consumer.raw.resume() consumer.raw.resume()
}) })
socket.on('speakingPeers', (value: SpeakingClient[]) => { socket.on('speaking-clients', (value: SpeakingClient[]) => {
speakingClients.value = value speakingClients.value = value
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined routerRtpCapabilities.value = undefined
sendTransport.value?.close() sendTransport.value?.close()
sendTransport.value = undefined sendTransport.value = undefined
@@ -308,16 +326,28 @@ export const useMediasoup = createSharedComposable(() => {
raw: markRaw(producer), raw: markRaw(producer),
} }
eventBus.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
eventBus.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
eventBus.emit('producer:resumed', producers.value[producer.id]!)
}) })
producer.observer.on('close', () => { producer.observer.on('close', () => {
console.log('producer closed')
const producerData = producers.value[producer.id]
delete producers.value[producer.id] delete producers.value[producer.id]
if (producerData)
eventBus.emit('producer:removed', producerData)
}) })
producer.on('trackended', () => { producer.on('trackended', () => {
@@ -332,7 +362,7 @@ export const useMediasoup = createSharedComposable(() => {
try { try {
producer.raw.close() producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', { await signaling.socket.value.emitWithAck('close-producer', {
producerId: producer.id, producerId: producer.id,
}) })
} }
@@ -435,7 +465,7 @@ export const useMediasoup = createSharedComposable(() => {
await createProducer({ await createProducer({
track, track,
streamId: 'share', streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find( codec: device.value.sendRtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1', c => c.mimeType.toLowerCase() === 'video/AV1',
), ),
codecOptions: { codecOptions: {
@@ -458,7 +488,7 @@ export const useMediasoup = createSharedComposable(() => {
try { try {
producer.raw.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pause-producer', {
producerId: producer.id, producerId: producer.id,
}) })
} }
@@ -474,7 +504,7 @@ export const useMediasoup = createSharedComposable(() => {
try { try {
producer.raw.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resume-producer', {
producerId: producer.id, producerId: producer.id,
}) })
} }
@@ -506,7 +536,7 @@ export const useMediasoup = createSharedComposable(() => {
speakingClients, speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities: routerRtpCapabilities,
device, device,
micProducer, micProducer,
videoProducer, videoProducer,
@@ -516,5 +546,7 @@ export const useMediasoup = createSharedComposable(() => {
enableVideo, enableVideo,
enableShare, enableShare,
disableProducer, disableProducer,
consumersArray,
producersArray,
} }
}) })

View File

@@ -42,7 +42,7 @@ export const usePreferences = createGlobalState(() => {
async ([toggleInputHotkey, toggleOutputHotkey]) => { async ([toggleInputHotkey, toggleOutputHotkey]) => {
try { try {
await chadApi( await chadApi(
'/preferences', '/user/preferences',
{ {
method: 'PATCH', method: 'PATCH',
body: { body: {

View File

@@ -15,7 +15,7 @@ function hashStringToNumber(str: string, cap: number): number {
const oneShots: Howl[] = [] const oneShots: Howl[] = []
type SfxEvent = 'mic-on' | 'mic-off' | 'stream-on' | 'stream-off' | 'connection' type SfxEvent = 'mic-on' | 'mic-off' | 'stream-on' | 'stream-off' | 'connection' | 'message'
const EVENT_VOLUME: Record<SfxEvent, number> = { const EVENT_VOLUME: Record<SfxEvent, number> = {
'mic-on': 0.2, 'mic-on': 0.2,
@@ -25,7 +25,10 @@ const EVENT_VOLUME: Record<SfxEvent, number> = {
'connection': 0.1, 'connection': 0.1,
} }
// TODO: refactor this shit
export const useSfx = createSharedComposable(() => { export const useSfx = createSharedComposable(() => {
const { outputMuted } = useApp()
async function play(src: string, volume = 0.2): Promise<void> { async function play(src: string, volume = 0.2): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const howl = new Howl({ const howl = new Howl({
@@ -74,7 +77,11 @@ export const useSfx = createSharedComposable(() => {
async function playRandomConnectionSound(seed: string) { async function playRandomConnectionSound(seed: string) {
await playEvent('stream-on') await playEvent('stream-on')
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length + 1)]!, 0.1)
if (outputMuted.value)
return
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length)]!, 0.1)
} }
return { return {

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) => {
@@ -66,7 +66,7 @@ export const useSignaling = createSharedComposable(() => {
const uri = host ? `${protocol}//${host}` : `` const uri = host ? `${protocol}//${host}` : ``
socket.value = io(`${uri}/webrtc`, { socket.value = io(uri, {
path: `${pathname}/ws`, path: `${pathname}/ws`,
transports: ['websocket'], transports: ['websocket'],
withCredentials: true, withCredentials: true,

View File

@@ -52,21 +52,48 @@
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" 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" /> <template v-for="channel in channels" :key="channel.id">
<PrimeDivider>
<PrimeButton size="small" variant="text" @click="joinChannel(channel)">
{{ channel.name }}
</PrimeButton>
</PrimeDivider>
<ClientRow v-for="client in clients.filter(_client => _client.channelId === channel.id)" :key="client.socketId" :client="client">
{{ client.userId }}
</ClientRow>
</template>
<!-- <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"> <div class="bg-surface-900 rounded-xl overflow-hidden p-3 flex flex-col min-h-full">
<div class="p-3"> <dl>
<slot /> <dt>Socket ID</dt>
</div> <dd>{{ socket?.id }}</dd>
</PrimeScrollPanel>
<br>
<dt>Producers</dt>
<dd v-for="producer in producersArray" :key="producer.id">
{{ producer.id }}
{{ producer.appData }}
</dd>
<br>
<dl>Consumers</dl>
<dd v-for="consumer in consumersArray" :key="consumer.id">
{{ consumer.id }}
{{ consumer.appData }}
</dd>
</dl>
<slot />
</div>
</div> </div>
<FullscreenGallery /> <FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import chadApi from '#shared/chad-api'
import { import {
Camera, Camera,
CameraOff, CameraOff,
@@ -82,6 +109,13 @@ import {
VolumeOff, VolumeOff,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const channels = shallowRef<any[]>([])
;(async () => {
channels.value = await chadApi<any[]>('/channels', { method: 'GET' })
})()
const { me } = useClients()
const { const {
version, version,
clients, clients,
@@ -95,7 +129,8 @@ const {
toggleVideo, toggleVideo,
toggleShare, toggleShare,
} = useApp() } = useApp()
const { connect, connected } = useSignaling() const { connect, connected, socket } = useSignaling()
const { consumersArray, producersArray } = useMediasoup()
interface Tab { interface Tab {
id: string id: string
@@ -152,4 +187,30 @@ watch(activeTab, (activeTab) => {
}) })
connect() connect()
async function joinChannel(channel) {
socket.value?.emit('join-channel', { channelId: channel.id })
}
watch(socket, (socket) => {
if (!socket)
return
socket.on('channel-removed', (channelId) => {
const idx = channels.value.findIndex(channel => channel.id === channelId)
if (idx === -1)
return
channels.value.splice(idx, 1)
triggerRef(channels)
})
socket.on('channel-created', (channel) => {
channels.value.push(channel)
triggerRef(channels)
})
}, { immediate: true })
</script> </script>

View File

@@ -5,7 +5,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!me.value) { if (!me.value) {
try { try {
setMe(await chadApi('/me', { method: 'GET' })) setMe(await chadApi('/auth/me', { method: 'GET' }))
if (to.meta.auth !== false) if (to.meta.auth !== false)
return navigateTo({ name: 'Index' }) return navigateTo({ name: 'Index' })

View File

@@ -13,7 +13,7 @@ export default defineNuxtRouteMiddleware(async () => {
return return
try { try {
const preferences = await chadApi<SyncedPreferences>('/preferences', { method: 'GET' }) const preferences = await chadApi<SyncedPreferences>('/user/preferences', { method: 'GET' })
if (!preferences) if (!preferences)
return return

View File

@@ -1,21 +1,23 @@
<template> <template>
<div class="grid grid-cols-[1fr_1fr] gap-2"> <PrimeScrollPanel class="grid grid-cols-[1fr_1fr] gap-2 min-h-0">
<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"
/> />
</div>
<GalleryCard
v-for="consumer in consumers"
:key="`consumer-${consumer.consumer.id}`"
:client="consumer.client"
:consumer="consumer.consumer"
/>
</PrimeScrollPanel>
</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

@@ -1,17 +1,182 @@
<template> <template>
<div> <p v-if="!messages.length" class="text-muted-color text-center m-auto">
<div class="flex items-center justify-center"> Chat is empty
<PrimeCard> </p>
<template #content>
The chat is under development. <PrimeScrollPanel v-else ref="scroll" class="flex-1 min-h-0 overflow-x-hidden">
</template> <div class="flex flex-col gap-3">
</PrimeCard> <div
v-for="message in messages"
:key="message.id"
class="w-fit max-w-[60%]"
:class="{
'ml-auto': message.senderId === me?.userId,
}"
>
<p
v-if="message.senderId !== me?.userId"
class="text-sm text-muted-color mb-1"
>
{{ message.senderId }}
</p>
<div
class="px-3 py-2 rounded-lg"
:class="{
'bg-surface-800 rounded-tl': message.senderId !== me?.userId,
'bg-surface-700 rounded-tr': message.senderId === me?.userId,
}"
>
<p class="[&>a]:break-all" @click="handleMessageClick" v-html="parseMessageText(message.text)" />
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2 mt-2">
<img
v-for="attachmentId in message.attachments"
:key="attachmentId"
class="rounded-xl max-w-60"
:src="`http://localhost:4000/chad/attachment/${attachmentId}`"
:alt="attachmentId"
>
</div>
<p class="mt-1 text-right text-sm text-muted-color" :title="formatDate(message.createdAt, 'dd.MM.yyyy, HH:mm')">
{{ formatDate(message.createdAt) }}
</p>
</div>
</div>
</div> </div>
</PrimeScrollPanel>
<div class="mt-3 shrink-0">
<PrimeInputGroup>
<!-- <PrimeInputGroupAddon> -->
<!-- <PrimeButton severity="secondary" class="shrink-0" disabled> -->
<!-- <template #icon> -->
<!-- <Paperclip /> -->
<!-- </template> -->
<!-- </PrimeButton> -->
<!-- </PrimeInputGroupAddon> -->
<PrimeInputText
ref="input"
v-model="text"
placeholder="Write a message..."
fluid
autocomplete="off"
@keydown.enter.exact="sendMessage"
@vue:mounted="onInputMounted"
/>
<PrimeButton class="shrink-0" label="Send" severity="contrast" :disabled="!text" @click="sendMessage" />
</PrimeInputGroup>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useEventBus } from '#imports'
import { onStartTyping, unrefElement, useEventListener } from '@vueuse/core'
import { format } from 'date-fns'
import linkifyStr from 'linkify-string'
import { useChat } from '~/composables/use-chat'
definePageMeta({ definePageMeta({
name: 'Index', name: 'Index',
}) })
const { openUrl } = useApp()
const { me } = useClients()
const chat = useChat()
const eventBus = useEventBus()
const { messages } = chat
const scrollRef = useTemplateRef('scroll')
const inputRef = useTemplateRef('input')
const contentRef = computed(() => scrollRef.value?.$refs.content)
const text = ref('')
eventBus.on('chat:new-message', onNewMessage)
onScopeDispose(() => {
eventBus.off('chat:new-message', onNewMessage)
})
function parseMessageText(text: string) {
return linkifyStr(
text,
{
className: 'underline',
rel: 'noopener noreferrer',
target: '_blank',
},
).replaceAll('\n', '<br>')
}
function formatDate(date: string, formatStr = 'HH:mm') {
return format(date, formatStr)
}
function sendMessage() {
if (!text.value)
return
chat.sendMessage({
text: text.value,
})
text.value = ''
}
function onInputMounted(ref: VNode) {
ref.el?.focus()
}
useEventListener(window, 'focus', async (evt) => {
unrefElement(inputRef.value)?.focus()
})
onStartTyping(() => {
unrefElement(inputRef.value)?.focus()
})
const ARRIVED_STATE_THRESHOLD_PIXELS = 1
async function onNewMessage() {
if (!contentRef.value)
return
const arrivedBottom = contentRef.value.scrollTop + contentRef.value.clientHeight
>= contentRef.value.scrollHeight - ARRIVED_STATE_THRESHOLD_PIXELS
const scrollable = contentRef.value.scrollHeight > contentRef.value.clientHeight
await nextTick()
if (scrollable && !arrivedBottom)
return
contentRef.value.scrollTo({
behavior: 'smooth',
top: contentRef.value.scrollHeight,
})
}
function handleMessageClick({ target }: MouseEvent) {
if (!target)
return
if (target instanceof HTMLElement) {
if (target.tagName === 'A') {
target.addEventListener('click', onAnchorClick)
}
}
}
function onAnchorClick(event: MouseEvent) {
event.preventDefault()
const target = event.target as HTMLAnchorElement
console.log('yo')
openUrl(target.href)
}
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <PrimeScrollPanel class="min-h-0">
<PrimeDivider align="left"> <PrimeDivider align="left">
Audio Audio
</PrimeDivider> </PrimeDivider>
@@ -132,7 +132,7 @@
@click="checkForUpdates" @click="checkForUpdates"
/> />
</template> </template>
</div> </PrimeScrollPanel>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -52,7 +52,7 @@ async function save() {
saving.value = true saving.value = true
const updatedMe = await chadApi('/profile', { const updatedMe = await chadApi('/user/profile', {
method: 'PATCH', method: 'PATCH',
body: { body: {
displayName: displayName.value, displayName: displayName.value,

View File

@@ -0,0 +1,61 @@
export default defineNuxtPlugin(() => {
const { on } = useEventBus()
const sfx = useSfx()
// Connection sounds
on('socket:authenticated', ({ socketId }) => {
sfx.playEvent('stream-on')
})
// Client events
on('client:added', (client) => {
sfx.playEvent('stream-on')
})
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')
}
})
on('chat:new-message', () => {
sfx.playEvent('message')
})
})

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

@@ -14,14 +14,19 @@
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@primeuix/themes": "^1.2.5", "@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tauri-apps/plugin-global-shortcut": "~2", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-opener": "~2",
"@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"date-fns": "^4.1.0",
"hotkeys-js": "^4.0.0", "hotkeys-js": "^4.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"linkify-string": "^4.3.2",
"linkifyjs": "^4.3.2",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.18.6", "mediasoup-client": "^3.19.0",
"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",

View File

@@ -3,8 +3,7 @@ import type { Consumer as MediasoupConsumer, Producer as MediasoupProducer } fro
export interface ChadClient { export interface ChadClient {
socketId: string socketId: string
userId: string userId: string
username: string channelId: string
displayName: string
inputMuted?: boolean inputMuted?: boolean
outputMuted?: boolean outputMuted?: boolean

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ log = "0.4"
tauri = { version = "2.8.5", features = [] } tauri = { version = "2.8.5", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
windows = { version = "0.52", features = ["Win32_UI_Shell"] }
tauri-plugin-opener = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"

View File

@@ -13,6 +13,8 @@
"global-shortcut:allow-is-registered", "global-shortcut:allow-is-registered",
"global-shortcut:allow-register", "global-shortcut:allow-register",
"global-shortcut:allow-unregister", "global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all" "global-shortcut:allow-unregister-all",
"opener:allow-default-urls",
"opener:allow-open-url"
] ]
} }

View File

@@ -1,10 +1,11 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|_, _, _| {})) // .plugin(tauri_plugin_single_instance::init(|_, _, _| {}))
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(

View File

@@ -1,6 +1,21 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "windows")]
fn set_app_user_model_id() {
use windows::core::HSTRING;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
unsafe {
SetCurrentProcessExplicitAppUserModelID(&HSTRING::from("xyz.koptilnya.chad"))
.ok()
.expect("Failed to set AppUserModelID");
}
}
fn main() { fn main() {
#[cfg(target_os = "windows")]
set_app_user_model_id();
app_lib::run(); app_lib::run();
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Chad", "productName": "Chad",
"version": "0.2.32", "version": "0.3.0-rc.4",
"identifier": "xyz.koptilnya.chad", "identifier": "xyz.koptilnya.chad",
"build": { "build": {
"frontendDist": "../.output/public", "frontendDist": "../.output/public",
@@ -23,7 +23,7 @@
"fullscreen": false, "fullscreen": false,
"center": true, "center": true,
"theme": "Dark", "theme": "Dark",
"additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required", "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --lang=en",
"incognito": false "incognito": false
} }
], ],

View File

@@ -2824,10 +2824,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/api@npm:^2.6.0": "@tauri-apps/api@npm:^2.10.1":
version: 2.8.0 version: 2.10.1
resolution: "@tauri-apps/api@npm:2.8.0" resolution: "@tauri-apps/api@npm:2.10.1"
checksum: 10c0/fb111e4d7572372997b440ebe6879543fa8c4765151878e3fddfbfe809b18da29eed142ce83061d14a9ca6d896b3266dc8a4927c642d71cdc0b4277dc7e3aabf checksum: 10c0/f3c0b2ba67a0b887440a7faa1e0589e847760ee30ec29b964f22573a46b817cb3af2199d6f5f7dfdda54d65b465ebaaa280454c610a5c53d808a0911fa15e45d
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2959,7 +2959,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/plugin-global-shortcut@npm:~2": "@tauri-apps/plugin-global-shortcut@npm:^2.3.1":
version: 2.3.1 version: 2.3.1
resolution: "@tauri-apps/plugin-global-shortcut@npm:2.3.1" resolution: "@tauri-apps/plugin-global-shortcut@npm:2.3.1"
dependencies: dependencies:
@@ -2968,21 +2968,30 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/plugin-process@npm:~2": "@tauri-apps/plugin-opener@npm:~2":
version: 2.3.0 version: 2.5.3
resolution: "@tauri-apps/plugin-process@npm:2.3.0" resolution: "@tauri-apps/plugin-opener@npm:2.5.3"
dependencies: dependencies:
"@tauri-apps/api": "npm:^2.6.0" "@tauri-apps/api": "npm:^2.8.0"
checksum: 10c0/ef50344a7436d92278c2ef4526f72daaf3171c4d65743bbc1f7a00fa581644a8583bb8680f637a34af5c7e6a0e8722c22189290e903584fef70ed83b64b6e9c0 checksum: 10c0/9ef2fae01e03f3bb16d8e55bfd921cf7c1d284e6459bd5b45777806304eb70ab0b50cbf03be76fc05e64ef70a37493e0cd90b0acc16eaee4a4fc2cfff7e43b71
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/plugin-updater@npm:~2": "@tauri-apps/plugin-process@npm:^2.3.1":
version: 2.9.0 version: 2.3.1
resolution: "@tauri-apps/plugin-updater@npm:2.9.0" resolution: "@tauri-apps/plugin-process@npm:2.3.1"
dependencies: dependencies:
"@tauri-apps/api": "npm:^2.6.0" "@tauri-apps/api": "npm:^2.8.0"
checksum: 10c0/72ce83d1c241308a13b9929f0900e4d33453875877009166e3998e3e75a1003ac48c3641086b4d3230f0f18c64f475ad6c3556d1603fc641ca50dc9c18d61866 checksum: 10c0/2e5086898f1c9f25f6426a752404c788727237142bbb7c8f418b97c76c5360874d06203150d136e51114df9e720022e4fa3681fd1d4cb6f777dc83c3553f8670
languageName: node
linkType: hard
"@tauri-apps/plugin-updater@npm:^2.10.1":
version: 2.10.1
resolution: "@tauri-apps/plugin-updater@npm:2.10.1"
dependencies:
"@tauri-apps/api": "npm:^2.10.1"
checksum: 10c0/5d3813851ccbbf90253ad4647dbd97501c2bb75db864175693322fa6eb062b6e1bae03890810ad0bbe7f6da4e020af35e8c787e6999f8c7c645121164587dc29
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2995,7 +3004,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.12": "@types/debug@npm:^4.0.0":
version: 4.1.12 version: 4.1.12
resolution: "@types/debug@npm:4.1.12" resolution: "@types/debug@npm:4.1.12"
dependencies: dependencies:
@@ -3004,6 +3013,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/debug@npm:^4.1.13":
version: 4.1.13
resolution: "@types/debug@npm:4.1.13"
dependencies:
"@types/ms": "npm:*"
checksum: 10c0/e5e124021bbdb23a82727eee0a726ae0fc8a3ae1f57253cbcc47497f259afb357de7f6941375e773e1abbfa1604c1555b901a409d762ec2bb4c1612131d4afb7
languageName: node
linkType: hard
"@types/estree@npm:*, @types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": "@types/estree@npm:*, @types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8":
version: 1.0.8 version: 1.0.8
resolution: "@types/estree@npm:1.0.8" resolution: "@types/estree@npm:1.0.8"
@@ -4059,17 +4077,22 @@ __metadata:
"@primevue/nuxt-module": "npm:^4.4.0" "@primevue/nuxt-module": "npm:^4.4.0"
"@tailwindcss/vite": "npm:^4.1.14" "@tailwindcss/vite": "npm:^4.1.14"
"@tauri-apps/cli": "npm:^2.8.4" "@tauri-apps/cli": "npm:^2.8.4"
"@tauri-apps/plugin-global-shortcut": "npm:~2" "@tauri-apps/plugin-global-shortcut": "npm:^2.3.1"
"@tauri-apps/plugin-process": "npm:~2" "@tauri-apps/plugin-opener": "npm:~2"
"@tauri-apps/plugin-updater": "npm:~2" "@tauri-apps/plugin-process": "npm:^2.3.1"
"@tauri-apps/plugin-updater": "npm:^2.10.1"
"@types/howler": "npm:^2" "@types/howler": "npm:^2"
"@vueuse/core": "npm:^13.9.0" "@vueuse/core": "npm:^13.9.0"
date-fns: "npm:^4.1.0"
eslint: "npm:^9.36.0" eslint: "npm:^9.36.0"
eslint-plugin-format: "npm:^1.0.2" eslint-plugin-format: "npm:^1.0.2"
hotkeys-js: "npm:^4.0.0" hotkeys-js: "npm:^4.0.0"
howler: "npm:^2.2.4" howler: "npm:^2.2.4"
linkify-string: "npm:^4.3.2"
linkifyjs: "npm:^4.3.2"
lucide-vue-next: "npm:^0.562.0" lucide-vue-next: "npm:^0.562.0"
mediasoup-client: "npm:^3.18.6" mediasoup-client: "npm:^3.19.0"
mitt: "npm:^3.0.1"
nuxt: "npm:^4.2.2" nuxt: "npm:^4.2.2"
postcss: "npm:^8.5.6" postcss: "npm:^8.5.6"
primeicons: "npm:^7.0.0" primeicons: "npm:^7.0.0"
@@ -4565,6 +4588,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"date-fns@npm:^4.1.0":
version: 4.1.0
resolution: "date-fns@npm:4.1.0"
checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8
languageName: node
linkType: hard
"db0@npm:^0.3.4": "db0@npm:^0.3.4":
version: 0.3.4 version: 0.3.4
resolution: "db0@npm:0.3.4" resolution: "db0@npm:0.3.4"
@@ -6952,6 +6982,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"linkify-string@npm:^4.3.2":
version: 4.3.2
resolution: "linkify-string@npm:4.3.2"
peerDependencies:
linkifyjs: ^4.0.0
checksum: 10c0/674e908b46aa6da3ee7e5c0749464d8de55f4d44933d7e9dea4d2f9bb5af0137d45142494288cd120335e21d8298c76ec4524ab6e67edede4613adb5f17f7dc6
languageName: node
linkType: hard
"linkifyjs@npm:^4.3.2":
version: 4.3.2
resolution: "linkifyjs@npm:4.3.2"
checksum: 10c0/1a85e6b368304a4417567fe5e38651681e3e82465590836942d1b4f3c834cc35532898eb1e2479f6337d9144b297d418eb708b6be8ed0b3dc3954a3588e07971
languageName: node
linkType: hard
"listhen@npm:^1.9.0": "listhen@npm:^1.9.0":
version: 1.9.0 version: 1.9.0
resolution: "listhen@npm:1.9.0" resolution: "listhen@npm:1.9.0"
@@ -7318,11 +7364,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mediasoup-client@npm:^3.18.6": "mediasoup-client@npm:^3.19.0":
version: 3.18.6 version: 3.19.0
resolution: "mediasoup-client@npm:3.18.6" resolution: "mediasoup-client@npm:3.19.0"
dependencies: dependencies:
"@types/debug": "npm:^4.1.12" "@types/debug": "npm:^4.1.13"
"@types/events-alias": "npm:@types/events@^3.0.3" "@types/events-alias": "npm:@types/events@^3.0.3"
awaitqueue: "npm:^3.3.0" awaitqueue: "npm:^3.3.0"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
@@ -7331,7 +7377,7 @@ __metadata:
h264-profile-level-id: "npm:^2.3.2" h264-profile-level-id: "npm:^2.3.2"
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^3.0.0"
supports-color: "npm:^10.2.2" supports-color: "npm:^10.2.2"
checksum: 10c0/f5baff9139afccf88de5db767c1139efa5cdd68f4871e2fa9d6ff94d2e71d2365dc40e9ba6e903cde5fbb51a2d82972e738da656be9f6fc7006640fdd82dd5da checksum: 10c0/9fde5ec5daec91d43a88796f49e2b1b7a018c8100a3f99786966678a0e0b5328e88f6e6af36d50f9eed93889b84f23a164865c7177c0767ee805c7a8c7a51eb2
languageName: node languageName: node
linkType: hard linkType: hard

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import sys,json; p=json.load\\(sys.stdin\\); deps={**p.get\\('dependencies',{}\\), **p.get\\('devDependencies',{}\\)}; [print\\(k,v\\) for k in sorted\\(deps\\) if any\\(x in k for x in ['mediasoup','socket','vueuse','vue']\\)]\")"
]
}
}

3
new-client/.env Normal file
View File

@@ -0,0 +1,3 @@
; API_BASE_URL=/api
; API_BASE_URL=https://api.koptilnya.xyz/chad
API_BASE_URL=http://127.0.0.1:4000

24
new-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
new-client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -0,0 +1,54 @@
{
// Use ESLint's --fix:
"code_actions_on_format": {
"source.fixAll.eslint": true,
},
"formatter": [],
// Enable eslint for all supported languages
// Defaults only include https://github.com/search?q=repo%3Azed-industries%2Fzed%20eslint_languages&type=code
"languages": {
"HTML": {
"language_servers": ["...", "eslint"],
},
"Markdown": {
"language_servers": ["...", "eslint"],
},
"Markdown-Inline": {
"language_servers": ["...", "eslint"],
},
"JSON": {
"language_servers": ["...", "eslint"],
},
"JSONC": {
"language_servers": ["...", "eslint"],
},
"YAML": {
"language_servers": ["...", "eslint"],
},
"CSS": {
"language_servers": ["...", "eslint"],
},
// Add other languages as needed
},
"lsp": {
"eslint": {
"settings": {
"workingDirectories": ["./"],
// Silent the stylistic rules in your IDE, but still auto fix them
"rulesCustomizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true },
],
},
},
},
}

1
new-client/AGENTS.md Normal file
View File

@@ -0,0 +1 @@
use context7

View File

@@ -0,0 +1,17 @@
import antfu from '@antfu/eslint-config'
export default antfu({
formatters: {
css: true,
},
overrides: {
typescript: {
'no-console': 'off',
},
vue: {
'vue/block-order': ['error', {
order: ['template', 'script', 'style'],
}],
},
},
})

24
new-client/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<title>Chad</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=Unbounded:wght@200..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/src/shared/styles/reset.css" />
<link rel="stylesheet" href="/src/shared/styles/sanitize.css" />
<link rel="stylesheet" href="/src/shared/styles/main.scss" />
</head>
<body>
<div data-mount-point id="app"></div>
<div data-mount-point id="updater"></div>
<div data-mount-point id="preloader"></div>
<script type="module" src="/src/app/entry.ts"></script>
</body>
</html>

50
new-client/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "new-client",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typegen": "npx swagger-typescript-api generate --path http://localhost:4000/reference/openapi.yaml --output ./src/shared/api --name generated-chad-api.ts"
},
"dependencies": {
"@lucide/vue": "^1.14.0",
"@tanstack/query-persist-client-core": "^5.100.10",
"@tanstack/vue-query": "^5.100.10",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "~2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@vueuse/core": "^14.3.0",
"@zag-js/avatar": "^1.40.0",
"@zag-js/collapsible": "^1.40.0",
"@zag-js/dialog": "^1.41.1",
"@zag-js/file-upload": "^1.41.0",
"@zag-js/file-utils": "^1.40.0",
"@zag-js/password-input": "^1.40.0",
"@zag-js/toggle": "^1.40.0",
"@zag-js/vue": "^1.40.0",
"date-fns": "^4.1.0",
"mediasoup-client": "^3.20.0",
"mitt": "^3.0.1",
"primevue": "^4.5.5",
"socket.io-client": "^4.8.3",
"vue": "^3.5.32",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@antfu/eslint-config": "^8.2.0",
"@tauri-apps/cli": "^2.8.4",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.3.0",
"eslint-plugin-format": "^2.0.1",
"sass-embedded": "^1.99.0",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vue-tsc": "^3.2.7"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

4
new-client/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

6228
new-client/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
[package]
name = "app"
version = "0.1.0"
description = "WW додепчик"
authors = ["KPTL"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.4.1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.8.5", features = [] }
tauri-plugin-log = "2"
tauri-plugin-process = "2"
windows = { version = "0.52", features = ["Win32_UI_Shell"] }
tauri-plugin-opener = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
</dict>
</plist>

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

View File

@@ -0,0 +1,20 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"opener:allow-default-urls",
"opener:allow-open-url"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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