6 Commits

Author SHA1 Message Date
e3ac3e003c cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 2m35s
2026-02-06 22:32:01 +06:00
6fa142f133 productName typo 2026-02-03 22:06:09 +06:00
8e0a08da05 icons
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 22:02:45 +06:00
0a3b2c3dc8 resize
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 17:05:43 +06:00
e5f1e6bbb3 показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-02-03 16:54:51 +06:00
1354ca3f7e показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 42s
2026-02-03 16:47:33 +06:00
221 changed files with 190 additions and 44 deletions

Binary file not shown.

View File

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

View File

@@ -111,7 +111,7 @@ const inputMuted = computed(() => {
}) })
const hasBadges = computed(() => { const hasBadges = computed(() => {
return shareConsumers.value.length > 0 return streaming.value
|| premuted.value || premuted.value
|| inputMuted.value || inputMuted.value
|| props.client.outputMuted || props.client.outputMuted

View File

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

View File

@@ -8,6 +8,7 @@ export const useApp = createGlobalState(() => {
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling() const signaling = useSignaling()
const toast = useToast() const toast = useToast()
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)
@@ -40,7 +41,10 @@ export const useApp = createGlobalState(() => {
}) })
const somebodyStreamingVideo = computed(() => { const somebodyStreamingVideo = computed(() => {
return mediasoup.videoConsumers.value.length > 0 || mediasoup.shareConsumers.value.length > 0 return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
}) })
async function muteInput() { async function muteInput() {
@@ -49,6 +53,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.pauseProducer(mediasoup.micProducer.value) await mediasoup.pauseProducer(mediasoup.micProducer.value)
sfx.play('/sfx/off_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
@@ -62,6 +67,7 @@ export const useApp = createGlobalState(() => {
await mediasoup.resumeProducer(mediasoup.micProducer.value) await mediasoup.resumeProducer(mediasoup.micProducer.value)
sfx.play('/sfx/on_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
@@ -112,18 +118,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()
await sfx.play('/sfx/on_trans.ogg', 0.03)
} }
else { else {
await mediasoup.disableProducer(mediasoup.videoProducer.value) await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
} }
} }
async function toggleShare() { async function toggleShare() {
if (!mediasoup.shareProducer.value) { if (!mediasoup.shareProducer.value) {
await mediasoup.enableShare() await mediasoup.enableShare()
await sfx.play('/sfx/on_trans.ogg', 0.03)
} }
else { else {
await mediasoup.disableProducer(mediasoup.shareProducer.value) await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
} }
} }

View File

@@ -14,10 +14,6 @@ export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId) return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId)
}) })
const producers = computed(() => {
return mediasoup.producers.value.values().filter(producer => producer.appData.socketId === client.value.socketId).toArray()
})
const audioConsumers = computed(() => { const audioConsumers = computed(() => {
return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId) return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
}) })
@@ -30,6 +26,10 @@ export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId) return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
}) })
const producers = computed(() => {
return Object.values(mediasoup.producers.value).filter(producer => producer.appData.socketId === client.value.socketId)
})
const streaming = computed(() => { const streaming = computed(() => {
return videoConsumers.value.length > 0 || shareConsumers.value.length > 0 return videoConsumers.value.length > 0 || shareConsumers.value.length > 0
}) })

View File

@@ -29,6 +29,7 @@ const ICE_SERVERS: RTCIceServer[] = [
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient } = useClients()
@@ -175,6 +176,7 @@ export const useMediasoup = createSharedComposable(() => {
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client) addClient(client)
}) })
@@ -384,6 +386,9 @@ export const useMediasoup = createSharedComposable(() => {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
deviceId: { exact: preferences.videoDeviceId.value }, deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
}, },
}) })
@@ -395,9 +400,9 @@ export const useMediasoup = createSharedComposable(() => {
await createProducer({ await createProducer({
track, track,
streamId: 'mic-video', streamId: 'mic-video',
codec: device.value.rtpCapabilities.codecs?.find( // codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1', // c => c.mimeType.toLowerCase() === 'video/AV1',
), // ),
// codecOptions: { // codecOptions: {
// videoGoogleStartBitrate: 1000, // videoGoogleStartBitrate: 1000,
// }, // },

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="grid grid-cols-[1fr_1.25fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full"> <div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
<div <div
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950" class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
> >
@@ -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,38 +1,64 @@
<template> <template>
<div class="grid grid-cols-[1fr_1fr] gap-2"> <div class="grid grid-cols-[1fr_1fr] gap-2">
<template v-for="(group, id) in groupedConsumers" :key="id"> <GalleryCard
<GalleryCard v-for="item in gallery"
v-for="consumer in group" :key="item.client.socketId"
:key="consumer.id" :client="item.client"
:consumer="consumer" :stream="item.stream"
@click="watch(consumer)" />
/>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Consumer } from '#shared/types' import type { ChadClient } from '#shared/types'
interface GalleryItem {
client: ChadClient
stream: MediaStream
}
definePageMeta({ definePageMeta({
name: 'Gallery', name: 'Gallery',
}) })
const { videoConsumers, shareConsumers } = useMediasoup() const { videoProducer, shareProducer } = useMediasoup()
const fullscreenVideo = useFullscreenVideo() const { clients, me } = useClients()
const groupedConsumers = computed<Partial<Record<string, Consumer[]>>>(() => { const gallery = computed(() => {
if (fullscreenVideo.visible.value) return clients.value.reduce<GalleryItem[]>(
return {} (acc, client) => {
const { streaming, videoConsumers, shareConsumers } = useClient(client.socketId)
const consumers = [...videoConsumers.value, ...shareConsumers.value] if (!streaming.value)
return acc
return Object.groupBy(consumers, (consumer) => { for (const consumer of [...videoConsumers.value, ...shareConsumers.value]) {
return consumer.appData.socketId! 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
}, []),
)
}) })
function watch(consumer: Consumer) { watch(gallery, (gallery) => {
fullscreenVideo.show(new MediaStream([consumer.raw.track])) if (gallery.length > 0)
} return
navigateTo({ name: 'Index' })
})
</script> </script>

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

@@ -19,6 +19,7 @@
"@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",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
@@ -37,6 +38,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.

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