39 Commits

Author SHA1 Message Date
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
102 changed files with 1939 additions and 663 deletions

View File

@@ -1,16 +1,55 @@
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
#
# 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
publish-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -26,57 +65,19 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Build - 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: | run: |
docker build \ docker run -d \
-t chad-client-windows-builder \ --name chad-client \
-f ./client/Dockerfile.windows \ --network traefik \
./client \ --label "traefik.enable=true" \
--build-arg COMMIT_SHA=${{ gitea.sha }} \ --label "traefik.http.routers.chad-client.rule=Host(\`chad.koptilnya.xyz\`)" \
--build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \ --label "traefik.http.routers.chad-client.entrypoints=websecure" \
--build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} --label "traefik.http.routers.chad-client.tls.certresolver=myresolver" \
--label "traefik.http.services.chad-client.loadbalancer.server.port=80" \
docker create --name chad-client-windows-container chad-client-windows-builder chad-client:latest
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
# publish-web:
# 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 -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,21 +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']
PrimeCheckbox: typeof import('primevue/checkbox')['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']
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'] PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
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']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
export interface ComponentCustomProperties {
Tooltip: typeof import('primevue/tooltip')['default']
}
} }

View File

@@ -1,30 +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 v-if="!!title">
{{ title }}
</h1>
</slot>
<slot name="right" />
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
secondary?: boolean
}>()
defineSlots<{
left: () => unknown
right: () => unknown
}>()
</script>

View File

@@ -1,109 +1,142 @@
<template> <template>
<div class="py-3"> <div
class="overflow-hidden rounded-xl transition-[background-color]"
:class="{
'hover:bg-surface-800 cursor-pointer': !isMe,
'bg-surface-800': expanded,
}"
>
<div class="p-3" @click="toggleExpand">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<PrimeAvatar size="small"> <PrimeAvatar
size="small"
class="shrink-0"
:class="{
'outline-1 outline-primary outline-offset-2': speaking,
}"
>
<template #icon> <template #icon>
<User :size="20" /> <User :size="20" />
</template> </template>
</PrimeAvatar> </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>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
</div> </div>
<div v-if="client.username !== client.displayName" class="mt-1 text-xs leading-5 text-muted-color">
{{ client.username }} <div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
<PrimeBadge v-if="streaming" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" size="small" />
<PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
</div> </div>
</div> </div>
<PrimeBadge v-if="inputMuted" severity="info" value="Muted" /> <CollapseTransition v-if="!isMe">
<!-- <PrimeBadge v-if="outputMuted" severity="info" value="No sound" /> --> <div v-if="expanded">
<PrimeBadge v-if="isMe" severity="secondary" value="You" /> <div class="px-3 pb-3">
<div class="flex justify-between text-sm mb-3">
<template v-if="!isMe">
<PrimeButton icon="pi pi-ellipsis-h" text size="small" severity="contrast" @click="menuRef?.toggle" />
<PrimeMenu ref="menu" popup :model="menuItems" style="translate: calc(-100% + 2rem) 0.5rem">
<template #start>
<div class="px-4 py-3">
<div class="flex justify-between">
<span>Volume</span> <span>Volume</span>
<span>{{ volume }}</span> <span>{{ volume }}</span>
</div> </div>
<PrimeSlider v-model="volume" class="mt-4" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" /> <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>
</template>
</PrimeMenu>
</template>
</div> </div>
</div> </div>
</CollapseTransition>
</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 { User } from 'lucide-vue-next' import CollapseTransition from '~/components/CollapseTransition.vue'
const props = defineProps<{ const props = defineProps<{
client: ChadClient client: ChadClient
}>() }>()
const { outputMuted } = useApp() const { outputMuted } = useApp()
const { getClientConsumers, micProducer } = 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) const audioTrack = computed(() => {
return audioConsumer.value?.raw.track
})
return consumers.find(consumer => consumer.track.kind === 'audio') const audioConsumerPaused = computed(() => {
if (Object.keys(allConsumers.value).length === 0)
return false
return audioConsumer.value?.paused ?? false
}) })
const inputMuted = computed(() => { const inputMuted = computed(() => {
if (isMe.value) if (isMe.value)
return micProducer.value?.paused ?? false return micProducer.value?.paused ?? false
const consumers = getClientConsumers(props.client.socketId) return premuted.value || audioConsumerPaused.value
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused
}) })
const audioTrack = computed(() => { const hasBadges = computed(() => {
return audioConsumer.value?.track 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))
// return
setGain(volume * 0.01)
}) })
// watch(outputMuted, (outputMuted) => { function toggleExpand() {
// setGain(outputMuted ? 0 : (volume.value * 0.01)) if (isMe.value)
// }) return
expanded.value = !expanded.value
}
function watchStream() {
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,132 @@ 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 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)
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
function toggleInput() {
if (inputMuted.value)
unmuteInput()
else
muteInput()
}
function muteOutput() {
outputMuted.value = true
}
function unmuteOutput() {
outputMuted.value = false
}
function toggleOutput() {
if (outputMuted.value)
unmuteOutput()
else
muteOutput()
}
watch(inputMuted, async (inputMuted) => {
if (inputMuted) {
await mediasoup.pauseProducer('microphone')
}
else {
if (outputMuted.value) { if (outputMuted.value) {
outputMuted.value = false await unmuteOutput()
}
await mediasoup.resumeProducer('microphone')
} }
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated' await mediasoup.resumeProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
}
async function toggleInput() {
if (inputMuted.value)
await unmuteInput()
else
await muteInput()
}
async function muteOutput() {
if (outputMuted.value)
return
outputMuted.value = true
previousInputMuted.value = inputMuted.value
await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: true,
}) })
watch(outputMuted, (outputMuted) => { toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
if (outputMuted) { }
previousInputMuted.value = inputMuted.value
muteInput() async function unmuteOutput() {
outputMuted.value = false
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()
} }
else { else {
inputMuted.value = previousInputMuted.value await mediasoup.disableProducer(mediasoup.videoProducer.value)
}
} }
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed' async function toggleShare() {
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 }) if (!mediasoup.shareProducer.value) {
}) await mediasoup.enableShare()
}
else {
await mediasoup.disableProducer(mediasoup.shareProducer.value)
}
}
return { return {
ready,
clients, clients,
inputMuted, inputMuted,
muteInput, muteInput,
@@ -85,7 +141,13 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version, version,
isTauri, isTauri,
commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

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

View File

@@ -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' },
@@ -25,19 +33,49 @@ export const useMediasoup = createSharedComposable(() => {
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)
@@ -158,20 +196,35 @@ 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 (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 +235,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,53 +276,69 @@ 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: {
deviceId: { exact: preferences.inputDeviceId.value }, deviceId: { exact: preferences.inputDeviceId.value },
@@ -258,131 +353,128 @@ export const useMediasoup = createSharedComposable(() => {
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)
triggerRef(micProducer)
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)
}
try { async function enableVideo() {
micProducer.value.close() if (videoProducer.value)
return
await signaling.socket.value.emitWithAck('closeProducer', { if (!device.value)
producerId: micProducer.value.id, return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
}) })
} }
catch {
} async function enableShare() {
finally { if (shareProducer.value)
triggerRef(producers) return
triggerRef(micProducer)
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',
},
})
} }
micProducer.value = undefined async function pauseProducer(producer: Producer) {
}
async function pauseProducer(type: ProducerType) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type) if (producer.paused)
if (!producer.value)
return
if (producer.value.paused)
return return
try { try {
producer.value.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.resume() producer.raw.resume()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function resumeProducer(type: ProducerType) { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value) if (!signaling.socket.value)
return return
const producer = getProducerByType(type)
if (!producer.value)
return
try { try {
producer.value.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: producer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.value.pause() producer.raw.pause()
}
finally {
triggerRef(producers)
triggerRef(producer)
} }
} }
async function init() {
signaling.connect()
}
function getProducerByType(type: ProducerType) {
switch (type) {
case 'microphone':
return micProducer
case 'camera':
return cameraProducer
case 'share':
return shareProducer
}
}
watch(
preferences.inputDeviceId,
async (inputDeviceId) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
},
)
watch([ watch([
preferences.inputDeviceId, preferences.inputDeviceId,
preferences.echoCancellation, preferences.echoCancellation,
@@ -398,18 +490,23 @@ export const useMediasoup = createSharedComposable(() => {
}) })
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,20 +1,29 @@
import { createGlobalState, useDevicesList, useLocalStorage } 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 { videoInputs, audioInputs, audioOutputs } = useDevices()
const synced = ref(false)
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default') const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default') const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false) const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true) const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true) const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
const { const shareFps = useLocalStorage('SHARE_FPS', 30)
ensurePermissions,
permissionGranted, const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
videoInputs, const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
audioInputs,
audioOutputs,
} = useDevicesList()
const inputDeviceExist = computed(() => { const inputDeviceExist = computed(() => {
return audioInputs.value.some(device => device.deviceId === inputDeviceId.value) return audioInputs.value.some(device => device.deviceId === inputDeviceId.value)
@@ -24,16 +33,43 @@ export const usePreferences = createGlobalState(() => {
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value) return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
}) })
const videoDeviceExist = computed(() => {
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
})
watchDebounced(
[toggleInputHotkey, toggleOutputHotkey],
async ([toggleInputHotkey, toggleOutputHotkey]) => {
try {
await chadApi(
'/preferences',
{
method: 'PATCH',
body: {
toggleInputHotkey,
toggleOutputHotkey,
},
},
)
}
catch {}
},
{ debounce: 1000 },
)
return { return {
synced,
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
shareFps,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))), videoDeviceExist,
audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))),
audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))),
} }
}) })

View File

@@ -1,25 +1,37 @@
<template> <template>
<div class="grid grid-cols-2 gap-2 p-2 h-screen grid-rows-[auto_1fr]"> <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"
> >
<div class="inline-flex items-center gap-3"> <div class="inline-flex items-center gap-3">
<PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" /> <PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " /> <PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div> </div>
<PrimeButtonGroup class="ml-auto"> <PrimeButtonGroup class="ml-auto">
<PrimeButton outlined @click="toggleInput"> <PrimeButton :severity="inputMuted ? 'info' : undefined" outlined @click="toggleInput">
<template #icon> <template #icon>
<Component :is="inputMuted ? MicOff : Mic" /> <Component :is="inputMuted ? MicOff : Mic" />
</template> </template>
</PrimeButton> </PrimeButton>
<PrimeButton outlined @click="toggleOutput"> <PrimeButton :severity="outputMuted ? 'info' : undefined" outlined @click="toggleOutput">
<template #icon> <template #icon>
<Component :is="outputMuted ? VolumeOff : Volume2" /> <Component :is="outputMuted ? VolumeOff : Volume2" />
</template> </template>
</PrimeButton> </PrimeButton>
</PrimeButtonGroup> </PrimeButtonGroup>
<PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
<template #icon>
<Component :is="videoEnabled ? CameraOff : Camera" />
</template>
</PrimeButton>
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<template #icon>
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
</template>
</PrimeButton>
</div> </div>
<div <div
@@ -28,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"
> >
@@ -38,24 +50,51 @@
</PrimeSelectButton> </PrimeSelectButton>
</div> </div>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div v-auto-animate class="p-3 divide-y divide-surface-800"> <div v-auto-animate class="p-3 space-y-1">
<ClientRow v-for="client of clients" :key="client.id" :client="client" /> <ClientRow v-for="client of clients" :key="client.userId" :client="client" />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
<PrimeScrollPanel class="bg-surface-900 rounded-xl" style="min-height: 0"> <PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
<div class="p-3"> <div class="p-3">
<slot /> <slot />
</div> </div>
</PrimeScrollPanel> </PrimeScrollPanel>
</div> </div>
<FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MessageCircle, Mic, MicOff, Settings, UserPen, Volume2, VolumeOff } from 'lucide-vue-next' import {
Camera,
CameraOff,
MessageCircle,
Mic,
MicOff,
ScreenShare,
ScreenShareOff,
Settings,
TvMinimalPlay,
UserPen,
Volume2,
VolumeOff,
} from 'lucide-vue-next'
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp() const {
version,
clients,
inputMuted,
outputMuted,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
toggleInput,
toggleOutput,
toggleVideo,
toggleShare,
} = useApp()
const { connect, connected } = useSignaling() const { connect, connected } = useSignaling()
interface Tab { interface Tab {
@@ -66,7 +105,20 @@ interface Tab {
const route = useRoute() const route = useRoute()
const tabs: Tab[] = [ const tabs = computed<Tab[]>(() => {
const result = []
if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
{ {
id: 'Index', id: 'Index',
icon: MessageCircle, icon: MessageCircle,
@@ -88,9 +140,12 @@ const tabs: Tab[] = [
navigateTo({ name: 'Preferences' }) navigateTo({ name: 'Preferences' })
}, },
}, },
] )
const activeTab = ref<Tab>(tabs.find(tab => tab.id === route.name) ?? tabs[0]!) return result
})
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
watch(activeTab, (activeTab) => { watch(activeTab, (activeTab) => {
activeTab.onClick() activeTab.onClick()

View File

@@ -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,4 +1,5 @@
<template> <template>
<div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<PrimeCard> <PrimeCard>
<template #content> <template #content>
@@ -6,6 +7,7 @@
</template> </template>
</PrimeCard> </PrimeCard>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,13 +1,18 @@
<template> <template>
<div> <div>
<PrimeDivider align="left">
Audio
</PrimeDivider>
<PrimeFloatLabel variant="on"> <PrimeFloatLabel variant="on">
<PrimeSelect <PrimeSelect
v-model="inputDeviceId" v-model="inputDeviceId"
:options="audioInputs" :options="audioInputs"
option-label="label" option-label="label"
option-value="deviceId" option-value="deviceId"
fluid
input-id="inputDevice" input-id="inputDevice"
placeholder="No input device"
fluid
:invalid="!inputDeviceExist" :invalid="!inputDeviceExist"
/> />
<label for="inputDevice">Input device</label> <label for="inputDevice">Input device</label>
@@ -43,71 +48,161 @@
<!-- <label for="outputDevice">Output device</label> --> <!-- <label for="outputDevice">Output device</label> -->
<!-- </PrimeFloatLabel> --> <!-- </PrimeFloatLabel> -->
<template v-if="isTauri"> <PrimeDivider align="left">
<PrimeDivider /> 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>
<PrimeFloatLabel variant="on">
<PrimeInputText id="microphoneToggle" :model-value="toggleInputHotkey" fluid @keydown="setupToggleInputHotkey" />
<label for="microphoneToggle">Toggle microphone</label>
</PrimeFloatLabel>
<PrimeFloatLabel variant="on" class="mt-3">
<PrimeInputText id="soundToggle" :model-value="toggleOutputHotkey" fluid @keydown="setupToggleOutputHotkey" />
<label for="soundToggle">Toggle sound</label>
</PrimeFloatLabel>
</template>
<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 <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" size="small"
label="Check for Updates" label="Check for Updates"
fluid fluid
severity="info" severity="info"
:loading="checking" :loading="checking"
@click="onCheckForUpdates" @click="checkForUpdates"
/> />
</template> </template>
</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>
</PrimeToast>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
definePageMeta({ definePageMeta({
name: 'Preferences', name: 'Preferences',
}) })
const { isTauri } = useApp() const { isTauri, version, commitSha } = useApp()
const { checking, checkForUpdates } = useUpdater() const { checking, checkForUpdates, lastUpdate } = useUpdater()
const { audioInputs, audioOutputs, videoInputs } = useDevices()
const { const {
inputDeviceId, inputDeviceId,
outputDeviceId, outputDeviceId,
videoDeviceId,
autoGainControl, autoGainControl,
noiseSuppression, noiseSuppression,
echoCancellation, echoCancellation,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist, inputDeviceExist,
outputDeviceExist, outputDeviceExist,
audioInputs, videoDeviceExist,
audioOutputs, shareFps,
} = usePreferences() } = usePreferences()
const toast = useToast() const shareFpsOptions = [5, 30, 60].map((value) => {
return {
label: value.toString(),
value,
}
})
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, if (modifierApplied && hotkey.length === 1) {
}) model.value = ''
return
}
model.value = hotkey.join('+')
} }
</script> </script>

View File

@@ -1,13 +1,15 @@
<template> <template>
<form @submit.prevent="save()"> <form @submit.prevent="save()">
<PrimeDivider align="left">
General
</PrimeDivider>
<PrimeFloatLabel variant="on"> <PrimeFloatLabel variant="on">
<PrimeInputText id="displayName" v-model="displayName" fluid autocomplete="off" /> <PrimeInputText id="displayName" v-model="displayName" fluid autocomplete="off" />
<label for="displayName">Display name</label> <label for="displayName">Display name</label>
</PrimeFloatLabel> </PrimeFloatLabel>
<PrimeDivider /> <div class="flex items-center gap-3 mt-6">
<div class="flex items-center gap-3">
<PrimeButton label="Save" :disabled="!valid" :loading="saving" fluid type="submit" /> <PrimeButton label="Save" :disabled="!valid" :loading="saving" fluid type="submit" />
<PrimeButton severity="danger" class="shrink-0" @click="logout()"> <PrimeButton severity="danger" class="shrink-0" @click="logout()">
<template #icon> <template #icon>
@@ -19,6 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import chadApi from '#shared/chad-api'
import { LogOut } from 'lucide-vue-next' import { LogOut } from 'lucide-vue-next'
definePageMeta({ definePageMeta({
@@ -49,8 +52,11 @@ async function save() {
saving.value = true saving.value = true
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { const updatedMe = await chadApi('/profile', {
method: 'PATCH',
body: {
displayName: displayName.value, displayName: displayName.value,
},
}) })
setMe({ ...me.value!, displayName: (updatedMe.displayName as string) }) setMe({ ...me.value!, displayName: (updatedMe.displayName as string) })

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) => {

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,11 +14,13 @@
"@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",
"hotkeys-js": "^4.0.0",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.16.7", "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",

View File

@@ -4,10 +4,13 @@ const instance = $fetch.create({
baseURL: __API_BASE_URL__, baseURL: __API_BASE_URL__,
credentials: 'include', credentials: 'include',
retry: false, retry: false,
onResponseError({ response }) { onResponseError({ request, response }) {
if (!import.meta.client) if (!import.meta.client)
return return
if (typeof request === 'string' && request.endsWith('/me'))
return
const message = response._data.error || 'Something went wrong' const message = response._data.error || 'Something went wrong'
ToastEventBus.emit('add', { severity: 'error', summary: 'Error', detail: message, closable: false, life: 3000 }) ToastEventBus.emit('add', { severity: 'error', summary: 'Error', detail: message, closable: false, life: 3000 })

View File

@@ -1,3 +1,5 @@
import type { Consumer as MediasoupConsumer, Producer as MediasoupProducer } from 'mediasoup-client/types'
export interface ChadClient { export interface ChadClient {
socketId: string socketId: string
userId: string userId: string
@@ -5,6 +7,30 @@ export interface ChadClient {
displayName: string displayName: string
inputMuted?: boolean inputMuted?: boolean
outputMuted?: boolean outputMuted?: boolean
consumers: unknown[]
producers: unknown[]
volume: number
isDominant: boolean
}
export interface AppData {
socketId?: ChadClient['socketId']
source?: 'share' | 'mic-video'
}
export interface Consumer {
id: MediasoupConsumer['id']
paused: MediasoupConsumer['paused']
appData: AppData
raw: MediasoupConsumer
}
export interface Producer {
id: MediasoupProducer['id']
paused: MediasoupProducer['paused']
appData: AppData
raw: MediasoupProducer
} }
export type UpdatedClient = Omit<ChadClient, 'socketId' | 'userId' | 'isMe'> export type UpdatedClient = Omit<ChadClient, 'socketId' | 'userId' | 'isMe'>

View File

@@ -93,6 +93,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
@@ -292,7 +293,7 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
"object", "object",
"rustc-demangle", "rustc-demangle",
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -603,7 +604,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"num-traits", "num-traits",
"serde", "serde",
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -1375,6 +1376,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@@ -1504,6 +1515,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2 0.6.2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.17",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -4180,6 +4209,21 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.7.0" version = "2.7.0"
@@ -5227,7 +5271,7 @@ checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.2.0", "windows-link 0.2.1",
"windows-result 0.4.0", "windows-result 0.4.0",
"windows-strings 0.5.0", "windows-strings 0.5.0",
] ]
@@ -5273,9 +5317,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
@@ -5302,7 +5346,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
dependencies = [ dependencies = [
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -5320,7 +5364,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
dependencies = [ dependencies = [
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -5365,7 +5409,7 @@ version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
dependencies = [ dependencies = [
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -5405,7 +5449,7 @@ version = "0.53.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
dependencies = [ dependencies = [
"windows-link 0.2.0", "windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.0", "windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0", "windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0", "windows_i686_gnu 0.53.0",
@@ -5431,7 +5475,7 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e" checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e"
dependencies = [ dependencies = [
"windows-link 0.2.0", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -5687,6 +5731,23 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]] [[package]]
name = "xattr" name = "xattr"
version = "1.6.1" version = "1.6.1"
@@ -5697,6 +5758,12 @@ dependencies = [
"rustix", "rustix",
] ]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.0" version = "0.8.0"

View File

@@ -26,5 +26,6 @@ tauri-plugin-log = "2"
tauri-plugin-process = "2" tauri-plugin-process = "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-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"

View File

@@ -9,6 +9,10 @@
"main" "main"
], ],
"permissions": [ "permissions": [
"updater:default" "updater:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all"
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M216 32C220.418 32 224 35.5817 224 40V205.846C224 212.802 215.735 216.444 210.602 211.75L191.292 194.096C189.818 192.748 187.892 192 185.895 192H40C35.5817 192 32 188.418 32 184V40C32 35.5817 35.5817 32 40 32H216ZM129.494 64.6367C121.221 64.6367 113.752 66.4847 107.085 70.1816C100.418 73.8786 95.1307 79.288 91.2217 86.4092C87.3127 93.5303 85.3585 102.212 85.3584 112.454C85.3584 122.666 87.2825 131.333 91.1309 138.454C94.9793 145.575 100.222 151 106.858 154.728C113.525 158.424 121.07 160.272 129.494 160.272C135.888 160.272 141.555 159.303 146.494 157.363C151.464 155.424 155.676 152.819 159.131 149.546C162.585 146.243 165.297 142.591 167.267 138.591C168.565 135.993 169.526 133.37 170.147 130.722C170.684 128.434 168.833 126.397 166.483 126.383L151.51 126.293C149.559 126.281 147.933 127.701 147.275 129.538C146.924 130.519 146.497 131.446 145.994 132.318C144.994 134.076 143.707 135.576 142.131 136.818C140.585 138.03 138.782 138.954 136.722 139.591C134.691 140.227 132.434 140.546 129.949 140.546C125.525 140.546 121.692 139.5 118.449 137.409C115.237 135.288 112.737 132.151 110.949 128C109.192 123.818 108.312 118.636 108.312 112.454C108.313 106.515 109.176 101.454 110.903 97.2725C112.661 93.0907 115.161 89.8937 118.403 87.6816C121.676 85.4696 125.57 84.3633 130.085 84.3633C132.63 84.3633 134.949 84.7268 137.04 85.4541C139.161 86.1511 140.995 87.1667 142.54 88.5C144.085 89.8333 145.327 91.4396 146.267 93.3184C146.721 94.2263 147.101 95.1871 147.406 96.2012C147.986 98.1257 149.635 99.6366 151.645 99.6367H166.447C168.81 99.6367 170.677 97.5927 170.23 95.2725C169.492 91.4407 168.292 87.9403 166.631 84.7725C164.358 80.4392 161.403 76.788 157.767 73.8184C154.13 70.8185 149.919 68.5454 145.131 67C140.343 65.4242 135.131 64.6367 129.494 64.6367Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,10 +1,10 @@
#[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_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,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.12", "version": "0.2.27",
"identifier": "xyz.koptilnya.chad", "identifier": "xyz.koptilnya.chad",
"build": { "build": {
"frontendDist": "../.output/public", "frontendDist": "../.output/public",
@@ -12,12 +12,14 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"maximizable": false, "maximizable": true,
"label": "main", "label": "main",
"title": "Chad", "title": "Chad",
"width": 800, "width": 800,
"height": 600, "height": 600,
"resizable": false, "minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false, "fullscreen": false,
"center": true, "center": true,
"theme": "Dark", "theme": "Dark",
@@ -40,7 +42,12 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico"
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View File

@@ -2831,6 +2831,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/api@npm:^2.8.0":
version: 2.9.1
resolution: "@tauri-apps/api@npm:2.9.1"
checksum: 10c0/18c76ec58b579860bfde5cd5b5ea6c3b13019d356c17d436bf395cafdf15dd1f277364cacd24cc94e5d4aa3816f39698f231773d2a18625e98702295ab0c2c8f
languageName: node
linkType: hard
"@tauri-apps/cli-darwin-arm64@npm:2.8.4": "@tauri-apps/cli-darwin-arm64@npm:2.8.4":
version: 2.8.4 version: 2.8.4
resolution: "@tauri-apps/cli-darwin-arm64@npm:2.8.4" resolution: "@tauri-apps/cli-darwin-arm64@npm:2.8.4"
@@ -2952,6 +2959,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tauri-apps/plugin-global-shortcut@npm:~2":
version: 2.3.1
resolution: "@tauri-apps/plugin-global-shortcut@npm:2.3.1"
dependencies:
"@tauri-apps/api": "npm:^2.8.0"
checksum: 10c0/e3833a1d0bab370aecb64396ea7e701f5342e303c6fdd3870583619b50c9e2784854b85ca24c279e9871bdcd6796de878392acd1ea4cd563fda83068939e27cd
languageName: node
linkType: hard
"@tauri-apps/plugin-process@npm:~2": "@tauri-apps/plugin-process@npm:~2":
version: 2.3.0 version: 2.3.0
resolution: "@tauri-apps/plugin-process@npm:2.3.0" resolution: "@tauri-apps/plugin-process@npm:2.3.0"
@@ -4036,13 +4052,15 @@ __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-process": "npm:~2" "@tauri-apps/plugin-process": "npm:~2"
"@tauri-apps/plugin-updater": "npm:~2" "@tauri-apps/plugin-updater": "npm:~2"
"@vueuse/core": "npm:^13.9.0" "@vueuse/core": "npm:^13.9.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"
lucide-vue-next: "npm:^0.562.0" lucide-vue-next: "npm:^0.562.0"
mediasoup-client: "npm:^3.16.7" mediasoup-client: "npm:^3.18.6"
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"
@@ -6158,12 +6176,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"h264-profile-level-id@npm:^2.3.1": "h264-profile-level-id@npm:^2.3.2":
version: 2.3.1 version: 2.3.2
resolution: "h264-profile-level-id@npm:2.3.1" resolution: "h264-profile-level-id@npm:2.3.2"
dependencies: dependencies:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
checksum: 10c0/c3459549bb28e456db62428c79885cffd4958ce282099c4181b09576f8e5ad90b42395a77209fff4f20a7cb920aaeb660f73902f08343daead0f5527faeb4015 checksum: 10c0/75bd12ff36707ffacf379c31c403d4508f3116ef2065e375deadcfafd4f7d163521cf0c70ae5385ebac970fa0acc07f9dd497c4248cfc1ee5623b4533707731d
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6207,6 +6225,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hotkeys-js@npm:^4.0.0":
version: 4.0.0
resolution: "hotkeys-js@npm:4.0.0"
checksum: 10c0/7cf84a0a8c20ff36e3d90de83977480a035a69335afffc41597d66930be49f69c5ad0a430ef38e953ab2ec73804b2a1635f34d096c7146fef2a2f7a45b3417c8
languageName: node
linkType: hard
"http-cache-semantics@npm:^4.1.1": "http-cache-semantics@npm:^4.1.1":
version: 4.2.0 version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0" resolution: "http-cache-semantics@npm:4.2.0"
@@ -7277,9 +7302,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mediasoup-client@npm:^3.16.7": "mediasoup-client@npm:^3.18.6":
version: 3.16.7 version: 3.18.6
resolution: "mediasoup-client@npm:3.16.7" resolution: "mediasoup-client@npm:3.18.6"
dependencies: dependencies:
"@types/debug": "npm:^4.1.12" "@types/debug": "npm:^4.1.12"
"@types/events-alias": "npm:@types/events@^3.0.3" "@types/events-alias": "npm:@types/events@^3.0.3"
@@ -7287,10 +7312,10 @@ __metadata:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
events-alias: "npm:events@^3.3.0" events-alias: "npm:events@^3.3.0"
fake-mediastreamtrack: "npm:^2.2.1" fake-mediastreamtrack: "npm:^2.2.1"
h264-profile-level-id: "npm:^2.3.1" h264-profile-level-id: "npm:^2.3.2"
sdp-transform: "npm:^2.15.0" sdp-transform: "npm:^3.0.0"
supports-color: "npm:^10.2.2" supports-color: "npm:^10.2.2"
checksum: 10c0/da44c6de8889963192c5b0b7907ed628e04d48be73b7bbfbf18012d66b07ede9d7367c0723466e496a87c7002c07f1af432d854c4c5e16cbd0887013870d8abe checksum: 10c0/f5baff9139afccf88de5db767c1139efa5cdd68f4871e2fa9d6ff94d2e71d2365dc40e9ba6e903cde5fbb51a2d82972e738da656be9f6fc7006640fdd82dd5da
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9804,12 +9829,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sdp-transform@npm:^2.15.0": "sdp-transform@npm:^3.0.0":
version: 2.15.0 version: 3.0.0
resolution: "sdp-transform@npm:2.15.0" resolution: "sdp-transform@npm:3.0.0"
bin: bin:
sdp-verify: checker.js sdp-verify: checker.js
checksum: 10c0/96c060f113a3d5418defa168db609f7e23e5bd7954fa1cf7784f103dbe702e24d667e5310d2ac6d88abdb32322af83d6ebd0df08e07f4f172d5ed5888f921386 checksum: 10c0/828a4595041ba64c86b29075aa4007ab384519b1fa29882db59ccb83b54b2b2a33b60848293f8da537fe151c52f5844fc17c8325396cac309fb19e2e81ec5bf4
languageName: node languageName: node
linkType: hard linkType: hard

View File

@@ -25,5 +25,50 @@ export const autoConfig: mediasoup.types.RouterOptions = {
channels: 2, channels: 2,
parameters: { useinbandfec: 1, stereo: 1 }, parameters: { useinbandfec: 1, stereo: 1 },
}, },
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: {
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '4d0032',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/AV1',
clockRate: 90000,
parameters: {},
},
], ],
} }

View File

@@ -22,8 +22,8 @@ export default fp<Partial<ServerOptions>>(
await fastify.io.close() await fastify.io.close()
}) })
fastify.ready(() => { fastify.ready(async () => {
registerWebrtcSocket(fastify.io, fastify.mediasoupRouter) await registerWebrtcSocket(fastify.io, fastify.mediasoupRouter)
}) })
}, },
{ name: 'socket-io', dependencies: ['mediasoup-worker', 'mediasoup-router'] }, { name: 'socket-io', dependencies: ['mediasoup-worker', 'mediasoup-router'] },

View File

@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT NOT NULL,
"toggleOutputHotkey" TEXT NOT NULL,
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- Added the required column `volumes` to the `UserPreferences` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT NOT NULL,
"toggleOutputHotkey" TEXT NOT NULL,
"volumes" JSONB NOT NULL,
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT,
"toggleOutputHotkey" TEXT,
"volumes" JSONB,
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId", "volumes") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId", "volumes" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -17,6 +17,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
Session Session[] Session Session[]
UserPreferences UserPreferences?
} }
model Session { model Session {
@@ -28,3 +29,12 @@ model Session {
@@index([userId]) @@index([userId])
} }
model UserPreferences {
userId String @id
toggleInputHotkey String? @default("")
toggleOutputHotkey String? @default("")
volumes Json? @default("{}")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

97
server/routes/user.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { FastifyInstance } from 'fastify'
import type { Namespace } from '../types/webrtc.ts'
import { z } from 'zod'
import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts'
export default function (fastify: FastifyInstance) {
fastify.get('/preferences', async (req, reply) => {
if (req.user) {
return prisma.userPreferences.findFirst({ where: { userId: req.user.id } })
}
reply.code(401).send(false)
})
fastify.patch('/preferences', async (req, reply) => {
if (!req.user) {
reply.code(401).send(false)
return
}
try {
const schema = z.object({
toggleInputHotkey: z.string().optional(),
toggleOutputHotkey: z.string().optional(),
volumes: z.record(z.string(), z.number()).optional(),
})
const input = schema.parse(req.body)
return prisma.userPreferences.upsert({
where: { userId: req.user.id },
create: {
userId: req.user.id,
...input,
},
update: input,
})
}
catch (err) {
fastify.log.error(err)
reply.code(400)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
fastify.patch('/profile', async (req, reply) => {
if (!req.user) {
reply.code(401).send(false)
return
}
try {
const schema = z.object({
displayName: z.string().optional(),
})
const input = schema.parse(req.body)
const updatedUser = prisma.user.update({
where: { id: req.user.id },
data: {
displayName: input.displayName,
},
})
const namespace: Namespace = fastify.io.of('/webrtc')
const sockets = await namespace.fetchSockets()
const found = sockets.find(socket => socket.data.joined && socket.data.userId === req.user!.id)
if (found) {
found.data.displayName = input.displayName
namespace.emit('clientChanged', found.id, socketToClient(found))
}
return updatedUser
}
catch (err) {
fastify.log.error(err)
reply.code(400)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
}

View File

@@ -17,7 +17,10 @@ fastify.register(FastifyCors, {
origin: [ origin: [
'http://localhost:3000', 'http://localhost:3000',
'http://tauri.localhost', 'http://tauri.localhost',
'https://koptilnya.xyz',
'https://chad.koptilnya.xyz',
], ],
methods: ['PATCH', 'PUT', 'OPTIONS', 'GET', 'POST', 'DELETE'],
credentials: true, credentials: true,
}) })

View File

@@ -1,145 +1,46 @@
import type { User } from '@prisma/client'
import type { types } from 'mediasoup' import type { types } from 'mediasoup'
import type { Namespace, RemoteSocket, Socket, Server as SocketServer } from 'socket.io' import type { Server as SocketServer } from 'socket.io'
import type {
ChadClient,
Namespace,
SomeSocket,
} from '../types/webrtc.ts'
import { consola } from 'consola' import { consola } from 'consola'
import prisma from '../prisma/client.ts' import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts'
interface ChadClient { export default async function (io: SocketServer, router: types.Router) {
socketId: string const namespace: Namespace = io.of('/webrtc')
userId: User['id']
username: User['username'] const audioLevelObserver = await router.createAudioLevelObserver({
displayName: User['displayName'] maxEntries: 10,
inputMuted: boolean threshold: -80,
outputMuted: boolean interval: 800,
})
const activeSpeakerObserver = await router.createActiveSpeakerObserver()
audioLevelObserver.on('volumes', async (volumes: types.AudioLevelObserverVolume[]) => {
namespace.emit('speakingPeers', volumes.map(({ producer, volume }) => {
const { socketId } = producer.appData as { socketId: ChadClient['socketId'] }
return {
clientId: socketId,
volume,
} }
}))
})
interface ProducerShort { audioLevelObserver.on('silence', () => {
producerId: types.Producer['id'] namespace.emit('speakingPeers', [])
kind: types.MediaKind namespace.emit('activeSpeaker', undefined)
} })
interface ErrorCallbackResult { activeSpeakerObserver.on('dominantspeaker', ({ producer }) => {
error: string const { socketId } = producer.appData as { socketId: ChadClient['socketId'] }
}
interface SuccessCallbackResult { namespace.emit('activeSpeaker', socketId)
ok: true })
}
type EventCallback<T = SuccessCallbackResult> = (result: T | ErrorCallbackResult) => void
interface ClientToServerEvents {
join: (
options: {
rtpCapabilities: types.RtpCapabilities
},
cb: EventCallback<ChadClient[]>
) => void
getRtpCapabilities: (
cb: EventCallback<types.RtpCapabilities>
) => void
createTransport: (
options: {
producing: boolean
consuming: boolean
},
cb: EventCallback<Pick<types.WebRtcTransport, 'id' | 'iceParameters' | 'iceCandidates' | 'dtlsParameters'>>
) => void
connectTransport: (
options: {
transportId: types.WebRtcTransport['id']
dtlsParameters: types.WebRtcTransport['dtlsParameters']
},
cb: EventCallback
) => void
produce: (
options: {
transportId: types.WebRtcTransport['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
},
cb: EventCallback<{ id: types.Producer['id'] }>
) => void
closeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
resumeProducer: (
options: {
producerId: types.Producer['id']
},
cb: EventCallback
) => void
pauseConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
resumeConsumer: (
options: {
consumerId: types.Consumer['id']
},
cb: EventCallback
) => void
updateClient: (
options: Partial<Omit<ChadClient, 'socketId' | 'userId'>>,
cb: EventCallback<ChadClient>
) => void
}
interface ServerToClientEvents {
authenticated: () => void
newPeer: (arg: ChadClient) => void
producers: (arg: ProducerShort[]) => void
newConsumer: (
arg: {
socketId: string
producerId: types.Producer['id']
id: types.Consumer['id']
kind: types.MediaKind
rtpParameters: types.RtpParameters
type: types.ConsumerType
appData: types.Producer['appData']
producerPaused: types.Consumer['producerPaused']
},
cb: EventCallback
) => void
peerClosed: (arg: string) => void
consumerClosed: (arg: { consumerId: string }) => void
consumerPaused: (arg: { consumerId: string }) => void
consumerResumed: (arg: { consumerId: string }) => void
consumerScore: (arg: { consumerId: string, score: types.ConsumerScore }) => void
clientChanged: (clientId: ChadClient['socketId'], client: ChadClient) => void
}
interface InterServerEvent {}
interface SocketData {
joined: boolean
userId: User['id']
username: User['username']
displayName: User['displayName']
inputMuted: boolean
outputMuted: boolean
rtpCapabilities: types.RtpCapabilities
transports: Map<types.WebRtcTransport['id'], types.WebRtcTransport>
producers: Map<types.Producer['id'], types.Producer>
consumers: Map<types.Consumer['id'], types.Consumer>
}
type SomeSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData> | RemoteSocket<ServerToClientEvents, SocketData>
export default function (io: SocketServer, router: types.Router) {
const namespace: Namespace<ClientToServerEvents, ServerToClientEvents, InterServerEvent, SocketData> = io.of('/webrtc')
namespace.on('connection', async (socket) => { namespace.on('connection', async (socket) => {
consola.info('[WebRtc]', 'Client connected', socket.id) consola.info('[WebRtc]', 'Client connected', socket.id)
@@ -278,7 +179,7 @@ export default function (io: SocketServer, router: types.Router) {
} }
}) })
socket.on('produce', async ({ transportId, kind, rtpParameters }, cb) => { socket.on('produce', async ({ transportId, kind, rtpParameters, appData }, cb) => {
if (!socket.data.joined) { if (!socket.data.joined) {
consola.error('Peer not joined yet') consola.error('Peer not joined yet')
cb({ error: 'Peer not joined yet' }) cb({ error: 'Peer not joined yet' })
@@ -296,7 +197,7 @@ export default function (io: SocketServer, router: types.Router) {
} }
try { try {
const producer = await transport.produce({ kind, rtpParameters, appData: { socketId: socket.id } }) const producer = await transport.produce({ kind, rtpParameters, appData: { ...appData, socketId: socket.id } })
socket.data.producers.set(producer.id, producer) socket.data.producers.set(producer.id, producer)
@@ -312,8 +213,8 @@ export default function (io: SocketServer, router: types.Router) {
) )
} }
// TODO: Add into the AudioLevelObserver and ActiveSpeakerObserver. await audioLevelObserver.addProducer({ producerId: producer.id })
// https://github.com/versatica/mediasoup-demo/blob/v3/server/lib/Room.js#L1276 await activeSpeakerObserver.addProducer({ producerId: producer.id })
} }
catch (error) { catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@@ -439,24 +340,11 @@ export default function (io: SocketServer, router: types.Router) {
}) })
socket.on('updateClient', async (updatedClient, cb) => { socket.on('updateClient', async (updatedClient, cb) => {
if (updatedClient.displayName) { if (typeof updatedClient.inputMuted === 'boolean') {
await prisma.user.update({
where: {
id: socket.data.userId,
},
data: {
displayName: updatedClient.displayName,
},
})
socket.data.displayName = updatedClient.displayName
}
if (updatedClient.inputMuted) {
socket.data.inputMuted = updatedClient.inputMuted socket.data.inputMuted = updatedClient.inputMuted
} }
if (updatedClient.outputMuted) { if (typeof updatedClient.outputMuted === 'boolean') {
socket.data.outputMuted = updatedClient.outputMuted socket.data.outputMuted = updatedClient.outputMuted
} }
@@ -583,15 +471,4 @@ export default function (io: SocketServer, router: types.Router) {
consola.error('_createConsumer() | failed:%o', error) consola.error('_createConsumer() | failed:%o', error)
} }
} }
function socketToClient(socket: SomeSocket): ChadClient {
return {
socketId: socket.id,
userId: socket.data.userId,
username: socket.data.username,
displayName: socket.data.displayName,
inputMuted: socket.data.inputMuted,
outputMuted: socket.data.outputMuted,
}
}
} }

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