46 Commits

Author SHA1 Message Date
a37b2048fe cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 53s
2026-02-06 22:41:58 +06:00
e3ac3e003c cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 2m35s
2026-02-06 22:32:01 +06:00
6fa142f133 productName typo 2026-02-03 22:06:09 +06:00
8e0a08da05 icons
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 22:02:45 +06:00
0a3b2c3dc8 resize
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 17:05:43 +06:00
e5f1e6bbb3 показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-02-03 16:54:51 +06:00
1354ca3f7e показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 42s
2026-02-03 16:47:33 +06:00
269b19a5be вебкамера там, туда-сюда
All checks were successful
Deploy / publish-web (push) Successful in 1m16s
2026-02-02 14:39:16 +06:00
0922fc4f41 новый-старый clientrow
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-01-29 23:19:31 +06:00
9fc8f954e3 новый-старый clientrow 2026-01-29 23:18:47 +06:00
a645885cf2 client volumes
All checks were successful
Deploy / publish-web (push) Successful in 1m31s
2026-01-29 22:05:05 +06:00
4c8a0e791c client volumes 2026-01-29 22:04:40 +06:00
fbdceb2e55 client volumes 2026-01-29 21:59:41 +06:00
aeaea47609 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 33s
2026-01-29 21:40:56 +06:00
f4fd752448 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 34s
2026-01-29 21:34:46 +06:00
595354b7f0 Merge pull request 'shareFps' (#9) from shareFps into master
All checks were successful
Deploy / publish-web (push) Successful in 1m32s
Reviewed-on: #9
2026-01-12 07:23:51 +00:00
Nadar
d08b011596 shareFps 2026-01-12 10:22:56 +03:00
12ce381abd minor update
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-27 02:52:17 +06:00
2d30ac2863 minor update
All checks were successful
Deploy / publish-web (push) Successful in 44s
2025-12-27 02:49:39 +06:00
0f218c1519 button colors 2025-12-27 01:58:01 +06:00
4b1a563850 screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 1m25s
2025-12-27 01:49:25 +06:00
169d43f0db screen sharing 2025-12-27 01:48:49 +06:00
47a464f08f screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 48s
2025-12-26 18:22:22 +06:00
4d5db12e1b screen sharing
Some checks failed
Deploy / deploy (push) Successful in 35s
Deploy / publish-web (push) Failing after 22s
2025-12-26 17:36:30 +06:00
4f59cbcf65 screen sharing
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 17:21:59 +06:00
3b3f6b6e40 update 2025-12-26 01:44:16 +06:00
461cbc6f83 profile rest
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-26 01:25:14 +06:00
a5cda8828f refactor
All checks were successful
Deploy / deploy (push) Successful in 34s
2025-12-26 01:22:34 +06:00
778f0a5687 refactor
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 01:18:54 +06:00
2aca9bca08 refactor
All checks were successful
Deploy / deploy (push) Successful in 40s
2025-12-26 01:13:21 +06:00
7ed23df3e9 refactor
All checks were successful
Deploy / deploy (push) Successful in 39s
2025-12-26 01:08:44 +06:00
2ac88f1010 user preferences
All checks were successful
Deploy / publish-web (push) Successful in 2m14s
2025-12-25 22:53:12 +06:00
c2cffd18de user preferences 2025-12-25 22:51:02 +06:00
bf38267c37 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 22:50:56 +06:00
22c5fafb11 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:30:30 +06:00
37683c42a9 user preferences
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-25 21:27:54 +06:00
2cbc75d7e3 user preferences
All checks were successful
Deploy / deploy (push) Successful in 37s
2025-12-25 21:24:41 +06:00
6721f63d22 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:22:43 +06:00
b47643552f user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:08:14 +06:00
0ab3e15784 cors origins
All checks were successful
Deploy / deploy (push) Successful in 37s
2025-12-25 07:38:07 +06:00
28c64edaf8 update 2025-12-25 07:33:18 +06:00
67a8dc7782 update 2025-12-25 07:30:57 +06:00
43a8b98a6a update 2025-12-25 07:21:45 +06:00
0f9a7e39ce update 2025-12-25 07:21:30 +06:00
8265e2d719 update 2025-12-25 03:51:29 +06:00
4f91309f7f update 2025-12-24 06:29:44 +06:00
263 changed files with 2236 additions and 725 deletions

View File

@@ -1,82 +1,83 @@
name: Deploy name: Deploy
# on: on:
# push: workflow_dispatch:
# tags: push:
# - "v[0-9]+.[0-9]+.[0-9]+" tags:
- "v[0-9]+.[0-9]+.[0-9]+"
# paths: # paths:
# - ".gitea/workflows/deploy-client.yml" # - ".gitea/workflows/deploy-client.yml"
# - "client/**" # - "client/**"
jobs: jobs:
publish-windows: # publish-windows:
runs-on: ubuntu-latest # runs-on: ubuntu-latest
#
# steps:
# - name: Keyscan
# run: |
# ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
#
# - name: Checkout
# uses: actions/checkout@v4
# with:
# ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
# ssh-strict: false
# persist-credentials: false
#
# - name: Build
# run: |
# docker build \
# -t chad-client-windows-builder \
# -f ./client/Dockerfile.windows \
# ./client \
# --build-arg COMMIT_SHA=${{ gitea.sha }} \
# --build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \
# --build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
#
# docker create --name chad-client-windows-container chad-client-windows-builder
# mkdir -p artifacts
# docker cp chad-client-windows-container:/artifacts artifacts/
# docker rm chad-client-windows-container
# ls -la artifacts
#
# - name: Publish
# uses: akkuman/gitea-release-action@v1
# with:
# files: |
# artifacts/**
# draft: true
steps: publish-web:
- name: Keyscan runs-on: ubuntu-latest
run: |
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
- name: Checkout steps:
uses: actions/checkout@v4 - name: Keyscan
with: run: |
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
ssh-strict: false
persist-credentials: false
- name: Build - name: Checkout
run: | uses: actions/checkout@v4
docker build \ with:
-t chad-client-windows-builder \ ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
-f ./client/Dockerfile.windows \ ssh-strict: false
./client \ persist-credentials: false
--build-arg COMMIT_SHA=${{ gitea.sha }} \
--build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \
--build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
docker create --name chad-client-windows-container chad-client-windows-builder - name: Build
mkdir -p artifacts run: docker build -t chad-client -f ./client/Dockerfile.web ./client --build-arg COMMIT_SHA=${{ gitea.sha }} --build-arg API_BASE_URL=${{ vars.API_BASE_URL }}
docker cp chad-client-windows-container:/artifacts artifacts/
docker rm chad-client-windows-container
ls -la artifacts
- name: Publish - name: Stop old container
uses: akkuman/gitea-release-action@v1 run: docker rm -f chad-client || true
with:
files: |
artifacts/**
draft: true
# publish-web: - name: Run
# runs-on: ubuntu-latest run: |
docker run -d \
# steps: --name chad-client \
# - name: Keyscan --network traefik \
# run: | --label "traefik.enable=true" \
# ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts --label "traefik.http.routers.chad-client.rule=Host(\`chad.koptilnya.xyz\`)" \
--label "traefik.http.routers.chad-client.entrypoints=websecure" \
# - name: Checkout --label "traefik.http.routers.chad-client.tls.certresolver=myresolver" \
# uses: actions/checkout@v4 --label "traefik.http.services.chad-client.loadbalancer.server.port=80" \
# with: chad-client:latest
# ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
# ssh-strict: false
# persist-credentials: false
# - name: Build
# run: docker build -t chad-client -f ./client/Dockerfile.web ./client --build-arg COMMIT_SHA=${{ gitea.sha }} --build-arg API_BASE_URL=${{ vars.API_BASE_URL }}
# - name: Stop old container
# run: docker rm -f chad-client || true
# - name: Run
# run: |
# docker run -d \
# --name chad-client \
# --network traefik \
# --label "traefik.enable=true" \
# --label "traefik.http.routers.chad-client.rule=Host(\`chad.koptilnya.xyz\`)" \
# --label "traefik.http.routers.chad-client.entrypoints=websecure" \
# --label "traefik.http.routers.chad-client.tls.certresolver=myresolver" \
# --label "traefik.http.services.chad-client.loadbalancer.server.port=80" \
# chad-client:latest

Binary file not shown.

View File

@@ -2,12 +2,10 @@ FROM node:lts-alpine AS builder
WORKDIR /app WORKDIR /app
RUN corepack enable
RUN yarn set version stable
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn install RUN yarn install --immutable
COPY . . COPY . .
ARG COMMIT_SHA=unknown ARG COMMIT_SHA=unknown

View File

@@ -3,8 +3,6 @@ FROM node:lts AS builder
WORKDIR /app WORKDIR /app
# RUN corepack enable yarn && yarn set version stable
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn

View File

@@ -7,7 +7,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
console.group('Build Info') const route = useRoute()
console.log(`COMMIT_SHA: ${__COMMIT_SHA__}`)
console.groupEnd()
</script> </script>

View File

@@ -6,8 +6,42 @@ body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-image: radial-gradient(var(--p-surface-700), var(--p-surface-800));
background-size: 200% 200%;
background-position: left -100% top -100%;
} }
#__nuxt { #__nuxt {
--p-scrollpanel-bar-size: 5px;
--p-scrollpanel-bar-background: var(--p-surface-950);
--p-divider-horizontal-margin: 2rem 0 1rem;
height: 100%; height: 100%;
}
.p-divider {
&:first-child {
--p-divider-horizontal-margin: 1rem 0 1rem;
}
}
.p-scrollpanel-bar-y {
translate: -0.25rem;
}
.p-select-overlay {
/* Force dropdown width to match computed min-width from PrimeVue internals. */
width: 0 !important;
}
.p-select-label {
width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.p-select-option-label {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
} }

View File

@@ -13,16 +13,22 @@ declare module 'vue' {
PrimeButton: typeof import('primevue/button')['default'] PrimeButton: typeof import('primevue/button')['default']
PrimeButtonGroup: typeof import('primevue/buttongroup')['default'] PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
PrimeCard: typeof import('primevue/card')['default'] PrimeCard: typeof import('primevue/card')['default']
PrimeDivider: typeof import('primevue/divider')['default']
PrimeFloatLabel: typeof import('primevue/floatlabel')['default'] PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
PrimeInputText: typeof import('primevue/inputtext')['default'] PrimeInputText: typeof import('primevue/inputtext')['default']
PrimeMenu: typeof import('primevue/menu')['default']
PrimePanel: typeof import('primevue/panel')['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']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default'] PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default'] PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default']
PrimeToast: typeof import('primevue/toast')['default'] PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
export interface ComponentCustomProperties {
Tooltip: typeof import('primevue/tooltip')['default']
}
} }

View File

@@ -1,25 +0,0 @@
<template>
<div
class="flex items-center justify-between gap-2 border-b-2 border-surface-800 px-3 py-3"
:class="{
'bg-surface-950': !secondary,
'bg-surface-900': secondary,
}"
style="height: 75px;"
>
<slot name="left">
<h1>
{{ title }}
</h1>
</slot>
<slot name="right" />
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
secondary?: boolean
}>()
</script>

View File

@@ -1,106 +1,142 @@
<template> <template>
<div class="py-3"> <div
<div class="flex items-center gap-3 "> class="overflow-hidden rounded-xl transition-[background-color]"
<PrimeAvatar :class="{
icon="pi pi-user" 'hover:bg-surface-800 cursor-pointer': !isMe,
size="small" 'bg-surface-800': expanded,
/> }"
>
<div class="p-3" @click="toggleExpand">
<div class="flex items-center gap-3">
<PrimeAvatar
size="small"
class="shrink-0"
:class="{
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<template #icon>
<User :size="20" />
</template>
</PrimeAvatar>
<div class="flex-1"> <p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
<div class="text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis"> {{ client.displayName || client.username }}
{{ client.displayName }} </p>
</div>
<div v-if="client.username !== client.displayName" class="mt-1 text-xs leading-5 text-muted-color"> <Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
{{ client.username }}
</div>
</div> </div>
<PrimeBadge v-if="audioConsumerPaused" severity="info" value="Muted" /> <div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-if="isMe" severity="secondary" value="You" /> <PrimeBadge v-if="streaming" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<template v-if="!isMe"> <PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeButton icon="pi pi-ellipsis-h" text size="small" severity="contrast" @click="menuRef?.toggle" /> <PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" size="small" />
<PrimeMenu ref="menu" popup :model="menuItems" style="translate: calc(-100% + 2rem) 0.5rem"> <PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
<template #start> </div>
<div class="px-4 py-3">
<div class="flex justify-between">
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" class="mt-4" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
</div>
</template>
</PrimeMenu>
</template>
</div> </div>
<CollapseTransition v-if="!isMe">
<div v-if="expanded">
<div class="px-3 pb-3">
<div class="flex justify-between text-sm mb-3">
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
<div class="mt-3 flex gap-1 justify-end">
<PrimeButton size="small" variant="text" @click="premuted = !premuted">
{{ premuted ? 'Unmute' : 'Mute' }}
</PrimeButton>
</div>
</div>
</div>
</CollapseTransition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ChadClient } from '#shared/types' import type { ChadClient } from '#shared/types'
import type { MenuItem } from 'primevue/menuitem' import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import CollapseTransition from '~/components/CollapseTransition.vue'
const props = defineProps<{ const props = defineProps<{
client: ChadClient client: ChadClient
}>() }>()
const { inputMuted, outputMuted } = useApp() const { outputMuted } = useApp()
const { getClientConsumers } = useMediasoup() const { consumers: allConsumers, micProducer } = useMediasoup()
const { me } = useClients() const { me } = useClients()
const { show } = useFullscreenVideo()
const menuRef = useTemplateRef<HTMLAudioElement>('menu') const expanded = ref(false)
const volume = ref(100) const {
volume,
const menuItems: MenuItem[] = [ premuted,
{ speaking,
label: 'Mute', audioConsumers,
icon: 'pi pi-headphones', videoConsumers,
}, shareConsumers,
{ streaming,
label: 'DM', } = useClient(toRef(() => props.client.socketId))
icon: 'pi pi-comment',
disabled: true,
},
]
const isMe = computed(() => { const isMe = computed(() => {
return me.value && props.client.userId === me.value.userId return me.value && props.client.userId === me.value.userId
}) })
const audioConsumer = computed(() => { const audioConsumer = computed(() => {
if (isMe.value) return audioConsumers.value[0]
return undefined
const consumers = getClientConsumers(props.client.socketId)
return consumers.find(consumer => consumer.track.kind === 'audio')
})
const audioConsumerPaused = computed(() => {
if (isMe.value)
return false
const consumers = getClientConsumers(props.client.socketId)
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused
}) })
const audioTrack = computed(() => { const audioTrack = computed(() => {
return audioConsumer.value?.track return audioConsumer.value?.raw.track
})
const audioConsumerPaused = computed(() => {
if (Object.keys(allConsumers.value).length === 0)
return false
return audioConsumer.value?.paused ?? false
})
const inputMuted = computed(() => {
if (isMe.value)
return micProducer.value?.paused ?? false
return premuted.value || audioConsumerPaused.value
})
const hasBadges = computed(() => {
return streaming.value
|| premuted.value
|| inputMuted.value
|| props.client.outputMuted
|| isMe.value
}) })
const { setGain } = useAudioContext(audioTrack) const { setGain } = useAudioContext(audioTrack)
watch(volume, (volume) => { watchEffect(() => {
if (outputMuted.value) setGain((outputMuted.value || premuted.value) ? 0 : (volume.value * 0.01))
})
function toggleExpand() {
if (isMe.value)
return return
setGain(volume * 0.01) expanded.value = !expanded.value
}) }
watch(outputMuted, (outputMuted) => { function watchStream() {
setGain(outputMuted ? 0 : (volume.value * 0.01)) if (!streaming.value)
}) return
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
show(new MediaStream([consumer.raw.track]))
}
</script> </script>

View File

@@ -0,0 +1,118 @@
<template>
<Transition name="collapse-transition" v-on="bindings">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
defineOptions({
name: 'CollapseTransition',
})
const emit = defineEmits<{
expanded: []
collapsed: []
}>()
const bindings = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.elExistsHeight = el.style.height ?? undefined
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow
if (el.dataset.elExistsHeight) {
el.style.maxHeight = el.dataset.elExistsHeight
}
else if (el.scrollHeight !== 0) {
el.style.maxHeight = `${el.scrollHeight}px`
}
else {
el.style.maxHeight = 0
}
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
el.style.overflow = 'hidden'
})
},
afterEnter(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
emit('expanded')
},
enterCancelled(el: RendererElement) {
reset(el)
},
beforeLeave(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.oldOverflow = el.style.overflow
el.style.maxHeight = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
}
},
afterLeave(el: RendererElement) {
reset(el)
emit('collapsed')
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
function reset(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
}
</script>
<style lang="scss">
.collapse-transition {
transition-property: height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition-property: opacity, max-height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-to,
.collapse-transition-enter-from {
opacity: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,76 +6,139 @@ import { useClients } from '~/composables/use-clients'
export const useApp = createGlobalState(() => { export const useApp = createGlobalState(() => {
const { clients } = useClients() const { clients } = useClients()
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling()
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const inputMuted = ref(false) const ready = ref(false)
const outputMuted = ref(false) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => {
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused
})
const previousInputMuted = ref(inputMuted.value) const previousInputMuted = ref(inputMuted.value)
const isTauri = computed(() => '__TAURI_INTERNALS__' in window) const outputMuted = ref(false)
const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-') const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
function muteInput() { const sharingEnabled = computed(() => {
inputMuted.value = true return !!mediasoup.shareProducer.value
})
const somebodyStreamingVideo = computed(() => {
return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value)
return
await mediasoup.pauseProducer(mediasoup.micProducer.value)
sfx.play('/sfx/off_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
function unmuteInput() { async function unmuteInput() {
inputMuted.value = false if (!inputMuted.value || !mediasoup.micProducer.value)
return
if (outputMuted.value) {
await unmuteOutput()
}
await mediasoup.resumeProducer(mediasoup.micProducer.value)
sfx.play('/sfx/on_micr.ogg').then()
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
} }
function toggleInput() { async function toggleInput() {
if (inputMuted.value) if (inputMuted.value)
unmuteInput() await unmuteInput()
else else
muteInput() await muteInput()
} }
function muteOutput() { async function muteOutput() {
outputMuted.value = true
}
function unmuteOutput() {
outputMuted.value = false
}
function toggleOutput() {
if (outputMuted.value) if (outputMuted.value)
unmuteOutput() return
else
muteOutput() outputMuted.value = true
previousInputMuted.value = inputMuted.value
await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: true,
})
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
} }
watch(inputMuted, async (inputMuted) => { async function unmuteOutput() {
if (inputMuted) { outputMuted.value = false
await mediasoup.pauseProducer('microphone')
if (!previousInputMuted.value)
await unmuteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
}
async function toggleOutput() {
if (outputMuted.value)
await unmuteOutput()
else
await muteOutput()
}
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
await sfx.play('/sfx/on_trans.ogg', 0.03)
} }
else { else {
if (outputMuted.value) { await mediasoup.disableProducer(mediasoup.videoProducer.value)
outputMuted.value = false await sfx.play('/sfx/off_trans.ogg', 0.03)
}
await mediasoup.resumeProducer('microphone')
} }
}
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated' async function toggleShare() {
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 }) if (!mediasoup.shareProducer.value) {
}) await mediasoup.enableShare()
await sfx.play('/sfx/on_trans.ogg', 0.03)
watch(outputMuted, (outputMuted) => {
if (outputMuted) {
previousInputMuted.value = inputMuted.value
muteInput()
} }
else { else {
inputMuted.value = previousInputMuted.value await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.play('/sfx/off_trans.ogg', 0.03)
} }
}
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed'
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
})
return { return {
ready,
clients, clients,
inputMuted, inputMuted,
muteInput, muteInput,
@@ -85,7 +148,13 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version, version,
isTauri, isTauri,
commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

@@ -4,13 +4,12 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
const ctx = new (window.AudioContext || window.webkitAudioContext)() const ctx = new (window.AudioContext || window.webkitAudioContext)()
const stream = new MediaStream() const stream = new MediaStream()
const audioEl = new Audio()
const sourceNode = shallowRef<MediaStreamAudioSourceNode>() const sourceNode = shallowRef<MediaStreamAudioSourceNode>()
const gainNode = ctx.createGain() const gainNode = ctx.createGain()
let hackExecuted = false watch(audioTrack, async (track, prevTrack) => {
watch(audioTrack, (track, prevTrack) => {
if (prevTrack) if (prevTrack)
stream.removeTrack(prevTrack) stream.removeTrack(prevTrack)
@@ -19,16 +18,14 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
stream.addTrack(track) stream.addTrack(track)
if (!hackExecuted) { if (!audioEl.srcObject) {
const audioEl = new Audio()
audioEl.srcObject = stream audioEl.srcObject = stream
audioEl.muted = true audioEl.muted = true
hackExecuted = true
} }
sourceNode.value = ctx.createMediaStreamSource(stream) sourceNode.value = ctx.createMediaStreamSource(stream)
connect() await connect()
}, { immediate: true }) }, { immediate: true })
useEventListener(document, 'click', async () => { useEventListener(document, 'click', async () => {
@@ -36,10 +33,16 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
await ctx.resume() await ctx.resume()
} }
connect() await connect()
}, { once: true }) }, { once: true })
function connect() { onScopeDispose(() => {
audioEl.pause()
audioEl.srcObject = null
ctx.close()
})
async function connect() {
if (!sourceNode.value || ctx.state === 'suspended') if (!sourceNode.value || ctx.state === 'suspended')
return return

View File

@@ -0,0 +1,52 @@
import type { ChadClient } from '#shared/types'
import { useLocalStorage } from '@vueuse/core'
export function useClient(socketId: MaybeRef<ChadClient['socketId']>) {
const mediasoup = useMediasoup()
const { getClient } = useClients()
const client = computed(() => getClient(unref(socketId))!)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${client.value.userId}`), 100, { writeDefaults: false })
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${client.value.userId}`), false, { writeDefaults: false })
const consumers = computed(() => {
return Object.values(mediasoup.consumers.value).filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const audioConsumers = computed(() => {
return mediasoup.audioConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const videoConsumers = computed(() => {
return mediasoup.videoConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const shareConsumers = computed(() => {
return mediasoup.shareConsumers.value.filter(consumer => consumer.appData.socketId === client.value.socketId)
})
const producers = computed(() => {
return Object.values(mediasoup.producers.value).filter(producer => producer.appData.socketId === client.value.socketId)
})
const streaming = computed(() => {
return videoConsumers.value.length > 0 || shareConsumers.value.length > 0
})
const speaking = computed(() => {
return mediasoup.speakingClients.value.some(speaker => speaker.clientId === client.value.socketId)
})
return {
volume,
premuted,
consumers,
producers,
audioConsumers,
videoConsumers,
shareConsumers,
streaming,
speaking,
}
}

View File

@@ -0,0 +1,37 @@
import { createGlobalState, useDevicesList } from '@vueuse/core'
export const useDevices = createGlobalState(() => {
const {
ensurePermissions,
permissionGranted,
videoInputs,
audioInputs,
audioOutputs,
} = useDevicesList()
async function getShareStream(fps = 30) {
return navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'monitor',
frameRate: { max: fps },
},
})
}
;(async () => {
if (permissionGranted.value)
return
await ensurePermissions()
})()
return {
ensurePermissions,
permissionGranted,
videoInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(videoInputs.value))),
audioInputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed<MediaDeviceInfo[]>(() => JSON.parse(JSON.stringify(audioOutputs.value))),
getShareStream,
}
})

View File

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

View File

@@ -0,0 +1,65 @@
import { createGlobalState, useEventListener } from '@vueuse/core'
export const useFullscreenVideo = createGlobalState(() => {
const videoEl = shallowRef<HTMLVideoElement>()
const visible = computed(() => !!videoEl.value)
async function show(stream: MediaStream) {
if (videoEl.value) {
videoEl.value.srcObject = stream
}
else {
const el = document.createElement('video')
el.srcObject = stream
el.autoplay = true
el.playsInline = true
el.controls = false
el.muted = true
// el.style.position = 'fixed'
// el.style.top = '0'
// el.style.left = '0'
// el.style.width = '1px'
// el.style.height = '1px'
// el.style.opacity = '0'
// el.style.pointerEvents = 'none'
document.body.appendChild(el)
videoEl.value = el
}
stream.getTracks().forEach(t =>
t.addEventListener('ended', hide),
)
videoEl.value.addEventListener('ended', hide)
await videoEl.value.requestFullscreen()
}
function hide() {
if (!videoEl.value)
return
(videoEl.value.srcObject as MediaStream).getTracks().forEach(t =>
t.removeEventListener('ended', hide),
)
videoEl.value.removeEventListener('ended', hide)
videoEl.value?.remove()
videoEl.value = undefined
}
useEventListener(document, 'fullscreenchange', () => {
if (!document.fullscreenElement && videoEl.value) {
videoEl.value?.remove()
videoEl.value = undefined
}
})
return {
visible,
show,
hide,
}
})

View File

@@ -1,10 +1,18 @@
import type { ChadClient } from '#shared/types' import type { ChadClient, Consumer, Producer } from '#shared/types'
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import * as mediasoupClient from 'mediasoup-client' import * as mediasoupClient from 'mediasoup-client'
import { shallowRef } from 'vue'
import { useDevices } from '~/composables/use-devices'
import { usePreferences } from '~/composables/use-preferences' import { usePreferences } from '~/composables/use-preferences'
import { useSignaling } from '~/composables/use-signaling' import { useSignaling } from '~/composables/use-signaling'
type ProducerType = 'microphone' | 'camera' | 'share' type ProducerType = 'microphone' | 'video' | 'share'
interface SpeakingClient {
clientId: ChadClient['socketId']
volume: number
}
const ICE_SERVERS: RTCIceServer[] = [ const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@@ -21,23 +29,54 @@ 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()
const preferences = usePreferences() const preferences = usePreferences()
const { me } = useAuth() const { getShareStream } = useDevices()
const device = shallowRef<mediasoupClient.Device>() const device = shallowRef<mediasoupClient.Device>()
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>() const rtpCapabilities = 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>()
const micProducer = shallowRef<mediasoupClient.types.Producer>() const consumers = ref<Record<Consumer['id'], Consumer>>({})
const cameraProducer = shallowRef<mediasoupClient.types.Producer>() const producers = ref<Record<Producer['id'], Producer>>({})
const shareProducer = shallowRef<mediasoupClient.types.Producer>()
const consumers = shallowRef<Map<string, mediasoupClient.types.Consumer>>(new Map()) const consumersArray = computed(() => {
const producers = shallowRef<Map<string, mediasoupClient.types.Producer>>(new Map()) return Object.values(consumers.value)
})
const audioConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'audio')
})
const videoConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source !== 'share')
})
const shareConsumers = computed(() => {
return consumersArray.value.filter(consumer => consumer.raw.kind === 'video' && consumer.appData.source === 'share')
})
const producersArray = computed(() => {
return Object.values(producers.value)
})
const micProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'audio' && producer.raw.appData.source === 'mic-video')
})
const videoProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source !== 'share')
})
const shareProducer = computed(() => {
return producersArray.value.find(producer => producer.raw.kind === 'video' && producer.raw.appData.source === 'share')
})
const speakingClients = shallowRef<SpeakingClient[]>([])
watch(signaling.socket, (socket) => { watch(signaling.socket, (socket) => {
if (!socket) if (!socket)
@@ -137,6 +176,7 @@ export const useMediasoup = createSharedComposable(() => {
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client) addClient(client)
}) })
@@ -158,20 +198,38 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${socketId}-${appData.share ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, socketId }, appData: { ...appData, socketId },
}) })
if (kind === 'video')
sfx.play('/sfx/on_trans.ogg', 0.03).then()
if (producerPaused) if (producerPaused)
consumer.pause() consumer.pause()
consumer.on('transportclose', () => { consumers.value[consumer.id] = {
if (consumers.value.delete(consumer.id)) id: consumer.id,
triggerRef(consumers) paused: consumer.paused,
appData: consumer.appData,
raw: markRaw(consumer),
}
consumer.observer.on('resume', () => {
consumers.value[consumer.id]!.paused = false
}) })
consumers.value.set(consumer.id, consumer) consumer.observer.on('pause', () => {
triggerRef(consumers) consumers.value[consumer.id]!.paused = true
})
consumer.observer.on('close', () => {
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -182,11 +240,37 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
if (consumers.value.delete(consumerId)) const consumer = consumers.value[consumerId]
triggerRef(consumers)
if (!consumer)
return
consumer.raw.close()
}, },
) )
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.pause()
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value[consumerId]
if (!consumer)
return
consumer.raw.resume()
})
socket.on('speakingPeers', (value: SpeakingClient[]) => {
speakingClients.value = value
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
device.value = undefined device.value = undefined
rtpCapabilities.value = undefined rtpCapabilities.value = undefined
@@ -197,180 +281,237 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
micProducer.value = undefined consumers.value = {}
cameraProducer.value = undefined producers.value = {}
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
})
socket.on('consumerPaused', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.pause()
console.log(consumerId)
triggerRef(consumers)
})
socket.on('consumerResumed', ({ consumerId }) => {
const consumer = consumers.value.get(consumerId)
if (!consumer)
return
consumer.resume()
triggerRef(consumers)
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
function getClientConsumers(socketId: ChadClient['socketId']) { async function createProducer(options: ProducerOptions) {
return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId) if (!device.value || !sendTransport.value)
return
if (!options.track)
return
if (!device.value.canProduce(options.track.kind as MediaKind))
return
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
producers.value[producer.id] = {
id: producer.id,
paused: producer.paused,
appData: producer.appData,
raw: markRaw(producer),
}
producer.observer.on('pause', () => {
producers.value[producer.id]!.paused = true
})
producer.observer.on('resume', () => {
producers.value[producer.id]!.paused = false
})
producer.observer.on('close', () => {
delete producers.value[producer.id]
})
producer.on('trackended', () => {
disableProducer(producers.value[producer.id]!)
})
}
async function disableProducer(producer: Producer) {
if (!signaling.socket.value)
return
try {
producer.raw.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: producer.id,
})
}
catch {
}
finally {
delete producers.value[producer.id]
}
} }
async function enableMic() { async function enableMic() {
if (micProducer.value) if (micProducer.value)
return return
if (!device.value || !sendTransport.value)
return
if (!device.value.canProduce('audio'))
return
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
autoGainControl: false, deviceId: { exact: preferences.inputDeviceId.value },
noiseSuppression: true, autoGainControl: { exact: preferences.autoGainControl.value },
echoCancellation: false, echoCancellation: { exact: preferences.echoCancellation.value },
channelCount: 2, noiseSuppression: { exact: preferences.noiseSuppression.value },
}, },
}) })
const track = stream.getAudioTracks()[0] const track = stream.getAudioTracks()[0]
if (!track) if (!track)
return return
micProducer.value = await sendTransport.value.produce({ await createProducer({
track, track,
streamId: 'mic-video',
codecOptions: { codecOptions: {
opusStereo: true, opusStereo: true,
opusDtx: true, // Меньше пакетов летит когда тишина opusDtx: true, // Меньше пакетов летит когда тишина
opusFec: false, // Фиксит пакет лос opusFec: false, // Фиксит пакет лос
}, },
}) appData: {
source: 'mic-video',
producers.value.set(micProducer.value.id, micProducer.value) },
triggerRef(producers)
micProducer.value.on('transportclose', () => {
micProducer.value = undefined
})
micProducer.value.on('trackended', () => {
disableMic()
}) })
} }
async function disableMic() { async function disableMic() {
if (!signaling.socket.value || !micProducer.value) if (!micProducer.value)
return return
producers.value.delete(micProducer.value.id) await disableProducer(micProducer.value)
triggerRef(producers)
try {
micProducer.value.close()
await signaling.socket.value.emitWithAck('closeProducer', {
producerId: micProducer.value.id,
})
}
catch {
}
micProducer.value = undefined
} }
async function pauseProducer(type: ProducerType) { async function enableVideo() {
if (!signaling.socket.value) if (videoProducer.value)
return return
const producer = getProducerByType(type) if (!device.value)
return
if (!producer) const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
})
}
async function enableShare() {
if (shareProducer.value)
return
if (!device.value)
return
const stream = await getShareStream(preferences.shareFps.value)
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'share',
codec: device.value.rtpCapabilities.codecs?.find(
c => c.mimeType.toLowerCase() === 'video/AV1',
),
codecOptions: {
videoGoogleStartBitrate: 1000,
},
zeroRtpOnPause: true,
appData: {
source: 'share',
},
})
}
async function pauseProducer(producer: Producer) {
if (!signaling.socket.value)
return return
if (producer.paused) if (producer.paused)
return return
try { try {
producer.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.resume() producer.raw.resume()
} }
} }
async function resumeProducer(type: ProducerType) { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer)
return
try { try {
producer.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.pause() producer.raw.pause()
} }
} }
async function init() { watch([
signaling.connect() preferences.inputDeviceId,
} preferences.echoCancellation,
preferences.autoGainControl,
preferences.noiseSuppression,
], async ([inputDeviceId]) => {
await disableMic()
function getProducerByType(type: ProducerType) { if (!inputDeviceId)
switch (type) { return
case 'microphone':
return micProducer.value await enableMic()
case 'camera': })
return cameraProducer.value
case 'share':
return shareProducer.value
}
}
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
cameraProducer, videoProducer,
shareProducer, shareProducer,
getClientConsumers,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
enableVideo,
enableShare,
disableProducer,
} }
}) })

View File

@@ -1,22 +1,75 @@
import { createGlobalState, useDevicesList } from '@vueuse/core' import chadApi from '#shared/chad-api'
import { createGlobalState, useLocalStorage, watchDebounced } from '@vueuse/core'
export interface SyncedPreferences {
toggleInputHotkey: string
toggleOutputHotkey: string
volumes: Record<Client['id'], number>
}
export const usePreferences = createGlobalState(() => { export const usePreferences = createGlobalState(() => {
const inputDeviceId = shallowRef<MediaDeviceInfo['deviceId']>() const { videoInputs, audioInputs, audioOutputs } = useDevices()
const outputDeviceId = shallowRef<MediaDeviceInfo['deviceId']>()
const { const synced = ref(false)
ensurePermissions,
permissionGranted, const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
videoInputs, const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
audioInputs, const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
audioOutputs,
} = useDevicesList() const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
const shareFps = useLocalStorage('SHARE_FPS', 30)
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
const inputDeviceExist = computed(() => {
return audioInputs.value.some(device => device.deviceId === inputDeviceId.value)
})
const outputDeviceExist = computed(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
})
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced(
[toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => {
try {
await chadApi(
'/preferences',
{
method: 'PATCH',
body: {
toggleInputHotkey,
toggleOutputHotkey,
},
},
)
}
catch {}
},
{ debounce: 1000 },
)
return { return {
synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoInputs, videoDeviceId,
audioInputs, autoGainControl,
audioOutputs, noiseSuppression,
echoCancellation,
shareFps,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
} }
}) })

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

@@ -66,11 +66,9 @@ export const useSignaling = createSharedComposable(() => {
const uri = host ? `${protocol}//${host}` : `` const uri = host ? `${protocol}//${host}` : ``
// socket.value = io(`http://localhost:4000/webrtc`, {
// path: `/chad/ws`,
// transports: ['websocket'],
socket.value = io(`${uri}/webrtc`, { socket.value = io(`${uri}/webrtc`, {
path: `${pathname}/ws`, path: `${pathname}/ws`,
transports: ['websocket'],
withCredentials: true, withCredentials: true,
auth: { auth: {
userId: me.value.id, userId: me.value.id,

View File

@@ -22,6 +22,7 @@ export const useUpdater = createGlobalState(() => {
return { return {
lastUpdate, lastUpdate,
checking,
checkForUpdates, checkForUpdates,
} }
}) })

View File

@@ -27,7 +27,6 @@ const options = computed(() => {
{ {
label: 'Register', label: 'Register',
routeName: 'Register', routeName: 'Register',
}, },
] ]
}) })

View File

@@ -1,52 +1,155 @@
<template> <template>
<div class="grid grid-cols-2 h-screen"> <div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
<div class="flex flex-col shadow-xl shadow-surface-950 overflow-y-hidden"> <div
<AppHeader title="Надарики"> class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
<template #right> >
<PrimeButtonGroup class="ml-auto"> <div class="inline-flex items-center gap-3">
<PrimeButton <PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
icon="pi pi-microphone" size="large" :severity="inputMuted ? 'contrast' : 'secondary'" <PrimeBadge :severity="connected ? 'success' : 'danger' " />
:outlined="!inputMuted" @click="toggleInput"
/>
<PrimeButton
icon="pi pi-headphones" size="large" :severity="outputMuted ? 'contrast' : 'secondary'"
:outlined="!outputMuted" @click="toggleOutput"
/>
</PrimeButtonGroup>
<PrimeButton icon="pi pi-cog" size="large" :text="!inPreferences" :severity="inPreferences ? 'contrast' : 'secondary'" @click="onClickPreferences" />
</template>
</AppHeader>
<div v-auto-animate class="p-3 overflow-y-auto flex-1 bg-surface-900 overflow-hidden divide-y divide-surface-800">
<ClientRow v-for="client of clients" :key="client.id" :client="client" />
</div> </div>
<PrimeButtonGroup class="ml-auto">
<PrimeButton :severity="inputMuted ? 'info' : undefined" outlined @click="toggleInput">
<template #icon>
<Component :is="inputMuted ? MicOff : Mic" />
</template>
</PrimeButton>
<PrimeButton :severity="outputMuted ? 'info' : undefined" outlined @click="toggleOutput">
<template #icon>
<Component :is="outputMuted ? VolumeOff : Volume2" />
</template>
</PrimeButton>
</PrimeButtonGroup>
<PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
<template #icon>
<Component :is="videoEnabled ? CameraOff : Camera" />
</template>
</PrimeButton>
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
</template>
</PrimeButton>
</div> </div>
<div class="overflow-y-auto"> <div
<slot /> class="flex items-center justify-center rounded-xl p-3 bg-surface-950"
>
<PrimeSelectButton
v-model="activeTab"
:options="tabs"
option-label="id"
:allow-empty="false"
style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
>
<template #option="{ option }">
<Component :is="option.icon" size="24" />
</template>
</PrimeSelectButton>
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div v-auto-animate class="p-3 space-y-1">
<ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div>
</PrimeScrollPanel>
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div class="p-3">
<slot />
</div>
</PrimeScrollPanel>
</div> </div>
<div class="fixed top-3 right-3 inline-flex items-center gap-3"> <FullscreenGallery />
<PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp() import {
Camera,
CameraOff,
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
TvMinimalPlay,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const {
version,
clients,
inputMuted,
outputMuted,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
toggleInput,
toggleOutput,
toggleVideo,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling() const { connect, connected } = useSignaling()
interface Tab {
id: string
icon: Component
onClick: () => void | Promise<void>
}
const route = useRoute() const route = useRoute()
const inPreferences = computed(() => { const tabs = computed<Tab[]>(() => {
return route.name === 'Preferences' const result = []
if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
{
id: 'Index',
icon: MessageCircle,
onClick: () => {
navigateTo({ name: 'Index' })
},
},
{
id: 'Profile',
icon: UserPen,
onClick: () => {
navigateTo({ name: 'Profile' })
},
},
{
id: 'Preferences',
icon: Settings,
onClick: () => {
navigateTo({ name: 'Preferences' })
},
},
)
return result
}) })
function onClickPreferences() { const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
navigateTo(!inPreferences.value ? { name: 'Preferences' } : '/')
} watch(activeTab, (activeTab) => {
activeTab.onClick()
})
connect() connect()
</script> </script>

View File

@@ -3,9 +3,12 @@ import chadApi from '#shared/chad-api'
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
const { me, setMe } = useAuth() const { me, setMe } = useAuth()
if (!me.value && !from?.name) { if (!me.value) {
try { try {
setMe(await chadApi('/me')) setMe(await chadApi('/me', { method: 'GET' }))
if (to.meta.auth !== false)
return navigateTo({ name: 'Index' })
} }
catch { catch {
if (to.meta.auth !== 'guest') { if (to.meta.auth !== 'guest') {
@@ -15,6 +18,6 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
} }
if (me.value && to.meta.auth === 'guest') { if (me.value && to.meta.auth === 'guest') {
return navigateTo('/') return navigateTo({ name: 'Index' })
} }
}) })

View File

@@ -0,0 +1,26 @@
import type { SyncedPreferences } from '~/composables/use-preferences'
import chadApi from '#shared/chad-api'
export default defineNuxtRouteMiddleware(async () => {
const { me } = useAuth()
if (!me.value)
return
const { synced, toggleInputHotkey, toggleOutputHotkey } = usePreferences()
if (synced.value)
return
try {
const preferences = await chadApi<SyncedPreferences>('/preferences', { method: 'GET' })
if (!preferences)
return
toggleInputHotkey.value = preferences.toggleInputHotkey ?? toggleInputHotkey.value
toggleOutputHotkey.value = preferences.toggleOutputHotkey ?? toggleOutputHotkey.value
synced.value = true
}
catch {}
})

View File

@@ -0,0 +1,64 @@
<template>
<div class="grid grid-cols-[1fr_1fr] gap-2">
<GalleryCard
v-for="item in gallery"
:key="item.client.socketId"
:client="item.client"
:stream="item.stream"
/>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
interface GalleryItem {
client: ChadClient
stream: MediaStream
}
definePageMeta({
name: 'Gallery',
})
const { videoProducer, shareProducer } = useMediasoup()
const { clients, me } = useClients()
const gallery = computed(() => {
return clients.value.reduce<GalleryItem[]>(
(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) => {
if (gallery.length > 0)
return
navigateTo({ name: 'Index' })
})
</script>

View File

@@ -1,62 +1,17 @@
<template> <template>
<div class="flex flex-col gap-3 p-3 pt-12"> <div>
<PrimePanel header="Clients" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }"> <div class="flex items-center justify-center">
<dl v-for="client in clients" :key="client.socketId" class=""> <PrimeCard>
<div v-for="(value, key) in client" :key="key" class="py-2"> <template #content>
<dt class="font-bold"> The chat is under development.
{{ key }} </template>
</dt> </PrimeCard>
<dd class="pl-8"> </div>
{{ value }}
</dd>
</div>
</dl>
</PrimePanel>
<PrimePanel header="Producers" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }">
<dl v-for="[_, producer] in Array.from(producers)" :key="producer.id" class="">
<div v-for="key in ['id', 'paused']" :key="key" class="py-2">
<dt class="font-bold">
{{ key }}
</dt>
<dd class="pl-8">
{{ producer[key] }}
</dd>
</div>
</dl>
</PrimePanel>
<PrimePanel header="Consumers" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }">
<dl v-for="[_, consumer] in Array.from(consumers)" :key="consumer.id" class="">
<div v-for="key in ['id', 'paused']" :key="key" class="py-2">
<dt class="font-bold">
{{ key }}
</dt>
<dd class="pl-8">
{{ consumer[key] }}
</dd>
</div>
</dl>
</PrimePanel>
</div> </div>
<!-- <div class="flex items-center justify-center p-3"> -->
<!-- <PrimeCard> -->
<!-- <template #subtitle> -->
<!-- Important information -->
<!-- </template> -->
<!-- <template #content> -->
<!-- The chat is under development. -->
<!-- </template> -->
<!-- </PrimeCard> -->
<!-- </div> -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { clients } = useClients()
const { producers, consumers } = useMediasoup()
definePageMeta({ definePageMeta({
name: 'Index',
}) })
</script> </script>

View File

@@ -1,95 +1,208 @@
<template> <template>
<div> <div>
<AppHeader title="Preferences" secondary /> <PrimeDivider align="left">
Audio
</PrimeDivider>
<PrimeFloatLabel variant="on">
<PrimeSelect
v-model="inputDeviceId"
:options="audioInputs"
option-label="label"
option-value="deviceId"
input-id="inputDevice"
placeholder="No input device"
fluid
:invalid="!inputDeviceExist"
/>
<label for="inputDevice">Input device</label>
</PrimeFloatLabel>
<div class="flex items-center gap-2 mt-3">
<PrimeToggleSwitch v-model="autoGainControl" input-id="autoGainControl" />
<label for="autoGainControl">Auto Gain Control</label>
</div>
<div class="flex items-center gap-2 mt-3">
<PrimeToggleSwitch v-model="echoCancellation" input-id="echoCancellation" />
<label for="echoCancellation">Echo Cancellation</label>
</div>
<div class="flex items-center gap-2 mt-3">
<PrimeToggleSwitch v-model="noiseSuppression" input-id="noiseSuppression" />
<label for="noiseSuppression">Noise Suppression</label>
</div>
<!-- <PrimeFloatLabel variant="on"> -->
<!-- <PrimeSelect -->
<!-- v-model="outputDeviceId" -->
<!-- :options="audioOutputs" -->
<!-- option-label="label" -->
<!-- option-value="deviceId" -->
<!-- fluid -->
<!-- class="mt-6" -->
<!-- input-id="outputDevice" -->
<!-- :invalid="!outputDeviceExist" -->
<!-- -->
<!-- /> -->
<!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> -->
<PrimeDivider align="left">
Video
</PrimeDivider>
<PrimeFloatLabel variant="on">
<PrimeSelect
v-model="videoDeviceId"
:options="videoInputs"
option-label="label"
option-value="deviceId"
input-id="videoDevice"
placeholder="No video device"
fluid
:invalid="!videoDeviceExist"
/>
<label for="inputDevice">Input device</label>
</PrimeFloatLabel>
<PrimeDivider align="left">
Screen sharing
</PrimeDivider>
<div>
<p class="text-sm mb-2 text-center">
FPS
</p>
<PrimeSelectButton
v-model="shareFps"
:options="shareFpsOptions"
fluid
size="small"
option-label="label"
option-value="value"
/>
</div>
<template v-if="isTauri">
<PrimeDivider align="left">
Hotkeys
</PrimeDivider>
<form class="flex flex-col gap-3 p-3" @submit.prevent="save">
<PrimeFloatLabel variant="on"> <PrimeFloatLabel variant="on">
<PrimeInputText id="username" v-model="displayName" size="large" fluid autocomplete="off" /> <PrimeInputText id="microphoneToggle" :model-value="toggleInputHotkey" fluid @keydown="setupToggleInputHotkey" />
<label for="username">Username</label> <label for="microphoneToggle">Toggle microphone</label>
</PrimeFloatLabel> </PrimeFloatLabel>
<PrimeButton label="Save" type="submit" :disabled="!valid" /> <PrimeFloatLabel variant="on" class="mt-3">
</form> <PrimeInputText id="soundToggle" :model-value="toggleOutputHotkey" fluid @keydown="setupToggleOutputHotkey" />
<label for="soundToggle">Toggle sound</label>
<div v-if="isTauri" class="p-3"> </PrimeFloatLabel>
<PrimeButton label="Check for Updates" fluid severity="info" @click="onCheckForUpdates" />
</div>
<div class="p-3">
<PrimeButton label="Logout" fluid severity="danger" @click="logout()" />
</div>
</div>
<PrimeToast position="bottom-center" group="updater">
<template #container="slotProps">
<div class="p-3">
<div class="font-medium text-lg mb-4">
{{ slotProps.message.detail }}
</div>
<div class="flex gap-3">
<PrimeButton size="small" label="Update now" @click="() => {}" />
<PrimeButton size="small" label="Later" severity="secondary" outlined @click="slotProps.closeCallback()" />
</div>
</div>
</template> </template>
</PrimeToast>
<PrimeDivider align="left">
About
</PrimeDivider>
<p v-if="version" class="text-muted-color text-sm">
VERSION: {{ version }}
</p>
<p class="text-muted-color text-sm mt-2">
COMMIT_SHA: {{ commitSha }}
</p>
<template v-if="isTauri">
<PrimeButton
v-if="lastUpdate"
class="mt-3"
size="small"
label="Install new version"
fluid
severity="success"
@click="navigateTo({ name: 'Updater' })"
/>
<PrimeButton
v-else
class="mt-3"
size="small"
label="Check for Updates"
fluid
severity="info"
:loading="checking"
@click="checkForUpdates"
/>
</template>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
definePageMeta({ definePageMeta({
name: 'Preferences', name: 'Preferences',
}) })
const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const {
inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
shareFps,
} = usePreferences()
const { isTauri } = useApp() const shareFpsOptions = [5, 30, 60].map((value) => {
const { checkForUpdates } = useUpdater() return {
const { me, setMe, logout } = useAuth() label: value.toString(),
value,
const signaling = useSignaling() }
const toast = useToast()
const displayName = ref(me.value?.displayName || '')
const valid = computed(() => {
if (!displayName.value || !me.value)
return false
if (displayName.value === me.value.displayName)
return false
return true
}) })
async function onCheckForUpdates() { const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
const update = await checkForUpdates() const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
toast.removeGroup('updater') function setupHotkey(event: KeyboardEvent, model: RemovableRef<string>) {
if (event.key === 'Tab' || event.key === 'Enter') {
return
}
if (!update) { event.preventDefault()
toast.add({ severity: 'success', summary: 'You are up to date', closable: false, life: 1000 })
const hotkey = []
if (event.ctrlKey || event.metaKey)
hotkey.push('CommandOrControl')
if (event.altKey)
hotkey.push('Alt')
if (event.shiftKey)
hotkey.push('Shift')
const modifierApplied = hotkey.length > 0
if (!modifierApplied && ['Escape', 'Backspace', 'Delete'].includes(event.key)) {
model.value = ''
return return
} }
toast.add({ if (!['Control', 'Shift', 'Alt'].includes(event.key)) {
group: 'updater', hotkey.push(event.key.toUpperCase())
severity: 'info', }
detail: `Version ${update?.version ?? '1.0.1'} is available!`,
closable: false,
})
// asdasd if (modifierApplied && hotkey.length === 1) {
} model.value = ''
async function save() {
if (!valid.value)
return return
}
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { model.value = hotkey.join('+')
displayName: displayName.value,
})
setMe({ ...me.value, displayName: updatedMe.displayName })
toast.add({ severity: 'success', summary: 'Saved', life: 1000, closable: false })
} }
</script> </script>

View File

@@ -0,0 +1,68 @@
<template>
<form @submit.prevent="save()">
<PrimeDivider align="left">
General
</PrimeDivider>
<PrimeFloatLabel variant="on">
<PrimeInputText id="displayName" v-model="displayName" fluid autocomplete="off" />
<label for="displayName">Display name</label>
</PrimeFloatLabel>
<div class="flex items-center gap-3 mt-6">
<PrimeButton label="Save" :disabled="!valid" :loading="saving" fluid type="submit" />
<PrimeButton severity="danger" class="shrink-0" @click="logout()">
<template #icon>
<LogOut />
</template>
</PrimeButton>
</div>
</form>
</template>
<script setup lang="ts">
import chadApi from '#shared/chad-api'
import { LogOut } from 'lucide-vue-next'
definePageMeta({
name: 'Profile',
})
const { me, setMe, logout } = useAuth()
const signaling = useSignaling()
const toast = useToast()
const displayName = ref(me.value?.displayName || '')
const saving = ref(false)
const valid = computed(() => {
if (!me.value)
return false
if (displayName.value === me.value.displayName)
return false
return true
})
async function save() {
if (!valid.value)
return
saving.value = true
const updatedMe = await chadApi('/profile', {
method: 'PATCH',
body: {
displayName: displayName.value,
},
})
setMe({ ...me.value!, displayName: (updatedMe.displayName as string) })
toast.add({ severity: 'success', summary: 'Saved', life: 1000, closable: false })
saving.value = false
}
</script>

View File

@@ -0,0 +1,7 @@
export default defineNuxtPlugin({
setup() {
console.group('Build Info')
console.log(`COMMIT_SHA: ${__COMMIT_SHA__}`)
console.groupEnd()
},
})

View File

@@ -0,0 +1,45 @@
import { isRegistered, register, unregister, unregisterAll } from '@tauri-apps/plugin-global-shortcut'
export default defineNuxtPlugin({
async setup() {
const { isTauri, toggleInput, toggleOutput } = useApp()
const preferences = usePreferences()
if (!isTauri.value)
return
await unregisterAll()
watch(preferences.toggleInputHotkey, async (shortcut, prevShortcut) => {
await registerHotkey(shortcut, prevShortcut, toggleInput)
}, {
immediate: true,
})
watch(preferences.toggleOutputHotkey, async (shortcut, prevShortcut) => {
await registerHotkey(shortcut, prevShortcut, toggleOutput)
}, {
immediate: true,
})
async function registerHotkey(shortcut: string, prevShortcut: string | undefined, cb: () => void) {
if (!!prevShortcut && await isRegistered(prevShortcut)) {
await unregister(prevShortcut)
}
if (!shortcut)
return
if (await isRegistered(shortcut)) {
await unregister(shortcut)
}
await register(shortcut, ({ state }) => {
if (state !== 'Released')
return
cb()
})
}
},
})

View File

@@ -86,8 +86,8 @@ export default defineNuxtConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:4000/chad', // target: 'http://localhost:4000/chad',
// target: 'https://api.koptilnya.xyz/chad', target: 'https://api.koptilnya.xyz/chad',
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => { rewrite: (path) => {
@@ -97,7 +97,7 @@ export default defineNuxtConfig({
}, },
}, },
define: { define: {
__API_BASE_URL__: JSON.stringify(import.meta.env.API_BASE_URL || '/api'), __API_BASE_URL__: JSON.stringify(import.meta.env.API_BASE_URL || 'http://localhost:4000/chad'),
__COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'), __COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'),
}, },
}, },

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
@@ -14,10 +14,14 @@
"@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-process": "~2", "@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-updater": "~2",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"mediasoup-client": "^3.16.7", "hotkeys-js": "^4.0.0",
"howler": "^2.2.4",
"lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -34,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.

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