Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e0a08da05 | |||
| 0a3b2c3dc8 | |||
| e5f1e6bbb3 | |||
| 1354ca3f7e | |||
| 269b19a5be | |||
| 0922fc4f41 | |||
| 9fc8f954e3 | |||
| a645885cf2 | |||
| 4c8a0e791c | |||
| fbdceb2e55 | |||
| aeaea47609 | |||
| f4fd752448 | |||
| 595354b7f0 | |||
|
|
d08b011596 | ||
| 12ce381abd | |||
| 2d30ac2863 | |||
| 0f218c1519 | |||
| 4b1a563850 | |||
| 169d43f0db | |||
| 47a464f08f | |||
| 4d5db12e1b | |||
| 4f59cbcf65 | |||
| 3b3f6b6e40 | |||
| 461cbc6f83 | |||
| a5cda8828f | |||
| 778f0a5687 | |||
| 2aca9bca08 | |||
| 7ed23df3e9 | |||
| 2ac88f1010 | |||
| c2cffd18de | |||
| bf38267c37 | |||
| 22c5fafb11 | |||
| 37683c42a9 | |||
| 2cbc75d7e3 | |||
| 6721f63d22 | |||
| b47643552f | |||
| 0ab3e15784 | |||
| 28c64edaf8 | |||
| 67a8dc7782 | |||
| 43a8b98a6a | |||
| 0f9a7e39ce | |||
| 8265e2d719 | |||
| 4f91309f7f | |||
| bcd457e2d6 | |||
| 8eef4fc477 |
@@ -1,82 +1,83 @@
|
|||||||
name: Deploy
|
name: Deploy
|
||||||
|
|
||||||
# on:
|
on:
|
||||||
# push:
|
workflow_dispatch:
|
||||||
# tags:
|
push:
|
||||||
# - "v[0-9]+.[0-9]+.[0-9]+"
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
# paths:
|
# paths:
|
||||||
# - ".gitea/workflows/deploy-client.yml"
|
# - ".gitea/workflows/deploy-client.yml"
|
||||||
# - "client/**"
|
# - "client/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-windows:
|
# publish-windows:
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
|
#
|
||||||
|
# steps:
|
||||||
|
# - name: Keyscan
|
||||||
|
# run: |
|
||||||
|
# ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
||||||
|
#
|
||||||
|
# - name: Checkout
|
||||||
|
# uses: actions/checkout@v4
|
||||||
|
# with:
|
||||||
|
# ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
# ssh-strict: false
|
||||||
|
# persist-credentials: false
|
||||||
|
#
|
||||||
|
# - name: Build
|
||||||
|
# run: |
|
||||||
|
# docker build \
|
||||||
|
# -t chad-client-windows-builder \
|
||||||
|
# -f ./client/Dockerfile.windows \
|
||||||
|
# ./client \
|
||||||
|
# --build-arg COMMIT_SHA=${{ gitea.sha }} \
|
||||||
|
# --build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \
|
||||||
|
# --build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
#
|
||||||
|
# docker create --name chad-client-windows-container chad-client-windows-builder
|
||||||
|
# mkdir -p artifacts
|
||||||
|
# docker cp chad-client-windows-container:/artifacts artifacts/
|
||||||
|
# docker rm chad-client-windows-container
|
||||||
|
# ls -la artifacts
|
||||||
|
#
|
||||||
|
# - name: Publish
|
||||||
|
# uses: akkuman/gitea-release-action@v1
|
||||||
|
# with:
|
||||||
|
# files: |
|
||||||
|
# artifacts/**
|
||||||
|
# draft: true
|
||||||
|
|
||||||
steps:
|
publish-web:
|
||||||
- name: Keyscan
|
runs-on: ubuntu-latest
|
||||||
run: |
|
|
||||||
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Checkout
|
steps:
|
||||||
uses: actions/checkout@v4
|
- name: Keyscan
|
||||||
with:
|
run: |
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
||||||
ssh-strict: false
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Build
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
docker build \
|
with:
|
||||||
-t chad-client-windows-builder \
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
-f ./client/Dockerfile.windows \
|
ssh-strict: false
|
||||||
./client \
|
persist-credentials: false
|
||||||
--build-arg COMMIT_SHA=${{ gitea.sha }} \
|
|
||||||
--build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \
|
|
||||||
--build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
docker create --name chad-client-windows-container chad-client-windows-builder
|
- name: Build
|
||||||
mkdir -p artifacts
|
run: docker build -t chad-client -f ./client/Dockerfile.web ./client --build-arg COMMIT_SHA=${{ gitea.sha }} --build-arg API_BASE_URL=${{ vars.API_BASE_URL }}
|
||||||
docker cp chad-client-windows-container:/artifacts artifacts/
|
|
||||||
docker rm chad-client-windows-container
|
|
||||||
ls -la artifacts
|
|
||||||
|
|
||||||
- name: Publish
|
- name: Stop old container
|
||||||
uses: akkuman/gitea-release-action@v1
|
run: docker rm -f chad-client || true
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
artifacts/**
|
|
||||||
draft: true
|
|
||||||
|
|
||||||
# publish-web:
|
- name: Run
|
||||||
# runs-on: ubuntu-latest
|
run: |
|
||||||
|
docker run -d \
|
||||||
# steps:
|
--name chad-client \
|
||||||
# - name: Keyscan
|
--network traefik \
|
||||||
# run: |
|
--label "traefik.enable=true" \
|
||||||
# ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
--label "traefik.http.routers.chad-client.rule=Host(\`chad.koptilnya.xyz\`)" \
|
||||||
|
--label "traefik.http.routers.chad-client.entrypoints=websecure" \
|
||||||
# - name: Checkout
|
--label "traefik.http.routers.chad-client.tls.certresolver=myresolver" \
|
||||||
# uses: actions/checkout@v4
|
--label "traefik.http.services.chad-client.loadbalancer.server.port=80" \
|
||||||
# with:
|
chad-client:latest
|
||||||
# ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
# ssh-strict: false
|
|
||||||
# persist-credentials: false
|
|
||||||
|
|
||||||
# - name: Build
|
|
||||||
# run: docker build -t chad-client -f ./client/Dockerfile.web ./client --build-arg COMMIT_SHA=${{ gitea.sha }} --build-arg API_BASE_URL=${{ vars.API_BASE_URL }}
|
|
||||||
|
|
||||||
# - name: Stop old container
|
|
||||||
# run: docker rm -f chad-client || true
|
|
||||||
|
|
||||||
# - name: Run
|
|
||||||
# run: |
|
|
||||||
# docker run -d \
|
|
||||||
# --name chad-client \
|
|
||||||
# --network traefik \
|
|
||||||
# --label "traefik.enable=true" \
|
|
||||||
# --label "traefik.http.routers.chad-client.rule=Host(\`chad.koptilnya.xyz\`)" \
|
|
||||||
# --label "traefik.http.routers.chad-client.entrypoints=websecure" \
|
|
||||||
# --label "traefik.http.routers.chad-client.tls.certresolver=myresolver" \
|
|
||||||
# --label "traefik.http.services.chad-client.loadbalancer.server.port=80" \
|
|
||||||
# chad-client:latest
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
10
client/app/components.d.ts
vendored
@@ -13,16 +13,22 @@ declare module 'vue' {
|
|||||||
PrimeButton: typeof import('primevue/button')['default']
|
PrimeButton: typeof import('primevue/button')['default']
|
||||||
PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
|
PrimeButtonGroup: typeof import('primevue/buttongroup')['default']
|
||||||
PrimeCard: typeof import('primevue/card')['default']
|
PrimeCard: typeof import('primevue/card')['default']
|
||||||
|
PrimeDivider: typeof import('primevue/divider')['default']
|
||||||
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
|
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
PrimeInputText: typeof import('primevue/inputtext')['default']
|
PrimeInputText: typeof import('primevue/inputtext')['default']
|
||||||
PrimeMenu: typeof import('primevue/menu')['default']
|
|
||||||
PrimePanel: typeof import('primevue/panel')['default']
|
|
||||||
PrimePassword: typeof import('primevue/password')['default']
|
PrimePassword: typeof import('primevue/password')['default']
|
||||||
PrimeProgressBar: typeof import('primevue/progressbar')['default']
|
PrimeProgressBar: typeof import('primevue/progressbar')['default']
|
||||||
|
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
|
||||||
|
PrimeSelect: typeof import('primevue/select')['default']
|
||||||
PrimeSelectButton: typeof import('primevue/selectbutton')['default']
|
PrimeSelectButton: typeof import('primevue/selectbutton')['default']
|
||||||
PrimeSlider: typeof import('primevue/slider')['default']
|
PrimeSlider: typeof import('primevue/slider')['default']
|
||||||
|
PrimeTag: typeof import('primevue/tag')['default']
|
||||||
PrimeToast: typeof import('primevue/toast')['default']
|
PrimeToast: typeof import('primevue/toast')['default']
|
||||||
|
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
export interface ComponentCustomProperties {
|
||||||
|
Tooltip: typeof import('primevue/tooltip')['default']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-2 border-b-2 border-surface-800 px-3 py-3"
|
|
||||||
:class="{
|
|
||||||
'bg-surface-950': !secondary,
|
|
||||||
'bg-surface-900': secondary,
|
|
||||||
}"
|
|
||||||
style="height: 75px;"
|
|
||||||
>
|
|
||||||
<slot name="left">
|
|
||||||
<h1>
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<slot name="right" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
secondary?: boolean
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,106 +1,142 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="py-3">
|
<div
|
||||||
<div class="flex items-center gap-3 ">
|
class="overflow-hidden rounded-xl transition-[background-color]"
|
||||||
<PrimeAvatar
|
:class="{
|
||||||
icon="pi pi-user"
|
'hover:bg-surface-800 cursor-pointer': !isMe,
|
||||||
size="small"
|
'bg-surface-800': expanded,
|
||||||
/>
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-3" @click="toggleExpand">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<PrimeAvatar
|
||||||
|
size="small"
|
||||||
|
class="shrink-0"
|
||||||
|
:class="{
|
||||||
|
'outline-1 outline-primary outline-offset-2': speaking,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<User :size="20" />
|
||||||
|
</template>
|
||||||
|
</PrimeAvatar>
|
||||||
|
|
||||||
<div class="flex-1">
|
<p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
|
||||||
<div class="text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
|
{{ client.displayName || client.username }}
|
||||||
{{ client.displayName }}
|
</p>
|
||||||
</div>
|
|
||||||
<div v-if="client.username !== client.displayName" class="mt-1 text-xs leading-5 text-muted-color">
|
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
|
||||||
{{ client.username }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PrimeBadge v-if="audioConsumerPaused" severity="info" value="Muted" />
|
<div v-if="hasBadges" class="flex justify-end align-center gap-1 mt-2">
|
||||||
<PrimeBadge v-if="isMe" severity="secondary" value="You" />
|
<PrimeBadge v-if="streaming" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
|
||||||
|
|
||||||
<template v-if="!isMe">
|
<PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
|
||||||
<PrimeButton icon="pi pi-ellipsis-h" text size="small" severity="contrast" @click="menuRef?.toggle" />
|
<PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
|
||||||
|
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" size="small" />
|
||||||
|
|
||||||
<PrimeMenu ref="menu" popup :model="menuItems" style="translate: calc(-100% + 2rem) 0.5rem">
|
<PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
|
||||||
<template #start>
|
</div>
|
||||||
<div class="px-4 py-3">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Volume</span>
|
|
||||||
<span>{{ volume }}</span>
|
|
||||||
</div>
|
|
||||||
<PrimeSlider v-model="volume" class="mt-4" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</PrimeMenu>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CollapseTransition v-if="!isMe">
|
||||||
|
<div v-if="expanded">
|
||||||
|
<div class="px-3 pb-3">
|
||||||
|
<div class="flex justify-between text-sm mb-3">
|
||||||
|
<span>Volume</span>
|
||||||
|
<span>{{ volume }}</span>
|
||||||
|
</div>
|
||||||
|
<PrimeSlider v-model="volume" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
|
||||||
|
|
||||||
|
<div class="mt-3 flex gap-1 justify-end">
|
||||||
|
<PrimeButton size="small" variant="text" @click="premuted = !premuted">
|
||||||
|
{{ premuted ? 'Unmute' : 'Mute' }}
|
||||||
|
</PrimeButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapseTransition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChadClient } from '#shared/types'
|
import type { ChadClient } from '#shared/types'
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
|
||||||
|
import CollapseTransition from '~/components/CollapseTransition.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
client: ChadClient
|
client: ChadClient
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { inputMuted, outputMuted } = useApp()
|
const { outputMuted } = useApp()
|
||||||
const { getClientConsumers } = useMediasoup()
|
const { consumers: allConsumers, micProducer } = useMediasoup()
|
||||||
const { me } = useClients()
|
const { me } = useClients()
|
||||||
|
const { show } = useFullscreenVideo()
|
||||||
|
|
||||||
const menuRef = useTemplateRef<HTMLAudioElement>('menu')
|
const expanded = ref(false)
|
||||||
|
|
||||||
const volume = ref(100)
|
const {
|
||||||
|
volume,
|
||||||
const menuItems: MenuItem[] = [
|
premuted,
|
||||||
{
|
speaking,
|
||||||
label: 'Mute',
|
audioConsumers,
|
||||||
icon: 'pi pi-headphones',
|
videoConsumers,
|
||||||
},
|
shareConsumers,
|
||||||
{
|
streaming,
|
||||||
label: 'DM',
|
} = useClient(toRef(() => props.client.socketId))
|
||||||
icon: 'pi pi-comment',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const isMe = computed(() => {
|
const isMe = computed(() => {
|
||||||
return me.value && props.client.userId === me.value.userId
|
return me.value && props.client.userId === me.value.userId
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioConsumer = computed(() => {
|
const audioConsumer = computed(() => {
|
||||||
if (isMe.value)
|
return audioConsumers.value[0]
|
||||||
return undefined
|
|
||||||
|
|
||||||
const consumers = getClientConsumers(props.client.socketId)
|
|
||||||
|
|
||||||
return consumers.find(consumer => consumer.track.kind === 'audio')
|
|
||||||
})
|
|
||||||
|
|
||||||
const audioConsumerPaused = computed(() => {
|
|
||||||
if (isMe.value)
|
|
||||||
return false
|
|
||||||
|
|
||||||
const consumers = getClientConsumers(props.client.socketId)
|
|
||||||
|
|
||||||
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioTrack = computed(() => {
|
const audioTrack = computed(() => {
|
||||||
return audioConsumer.value?.track
|
return audioConsumer.value?.raw.track
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioConsumerPaused = computed(() => {
|
||||||
|
if (Object.keys(allConsumers.value).length === 0)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return audioConsumer.value?.paused ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputMuted = computed(() => {
|
||||||
|
if (isMe.value)
|
||||||
|
return micProducer.value?.paused ?? false
|
||||||
|
|
||||||
|
return premuted.value || audioConsumerPaused.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasBadges = computed(() => {
|
||||||
|
return streaming.value
|
||||||
|
|| premuted.value
|
||||||
|
|| inputMuted.value
|
||||||
|
|| props.client.outputMuted
|
||||||
|
|| isMe.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const { setGain } = useAudioContext(audioTrack)
|
const { setGain } = useAudioContext(audioTrack)
|
||||||
|
|
||||||
watch(volume, (volume) => {
|
watchEffect(() => {
|
||||||
if (outputMuted.value)
|
setGain((outputMuted.value || premuted.value) ? 0 : (volume.value * 0.01))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleExpand() {
|
||||||
|
if (isMe.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
setGain(volume * 0.01)
|
expanded.value = !expanded.value
|
||||||
})
|
}
|
||||||
|
|
||||||
watch(outputMuted, (outputMuted) => {
|
function watchStream() {
|
||||||
setGain(outputMuted ? 0 : (volume.value * 0.01))
|
if (!streaming.value)
|
||||||
})
|
return
|
||||||
|
|
||||||
|
const consumer = [...videoConsumers.value, ...shareConsumers.value][0]!
|
||||||
|
|
||||||
|
show(new MediaStream([consumer.raw.track]))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
118
client/app/components/CollapseTransition.vue
Normal 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>
|
||||||
25
client/app/components/Debug/Consumer.vue
Normal 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>
|
||||||
20
client/app/components/FullscreenGallery.vue
Normal 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>
|
||||||
13
client/app/components/FullscreenGallery/Card.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fullscreen-gallery-card">
|
||||||
|
sasd
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
40
client/app/components/Gallery/Card.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
if (outputMuted.value) {
|
||||||
|
await unmuteOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediasoup.resumeProducer(mediasoup.micProducer.value)
|
||||||
|
|
||||||
|
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleInput() {
|
async function toggleInput() {
|
||||||
if (inputMuted.value)
|
if (inputMuted.value)
|
||||||
unmuteInput()
|
await unmuteInput()
|
||||||
else
|
else
|
||||||
muteInput()
|
await muteInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
function muteOutput() {
|
async function muteOutput() {
|
||||||
outputMuted.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function unmuteOutput() {
|
|
||||||
outputMuted.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOutput() {
|
|
||||||
if (outputMuted.value)
|
if (outputMuted.value)
|
||||||
unmuteOutput()
|
return
|
||||||
else
|
|
||||||
muteOutput()
|
outputMuted.value = true
|
||||||
|
|
||||||
|
previousInputMuted.value = inputMuted.value
|
||||||
|
|
||||||
|
await muteInput()
|
||||||
|
|
||||||
|
await signaling.socket.value?.emitWithAck('updateClient', {
|
||||||
|
outputMuted: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(inputMuted, async (inputMuted) => {
|
async function unmuteOutput() {
|
||||||
if (inputMuted) {
|
outputMuted.value = false
|
||||||
await mediasoup.pauseProducer('microphone')
|
|
||||||
|
if (!previousInputMuted.value)
|
||||||
|
await unmuteInput()
|
||||||
|
|
||||||
|
await signaling.socket.value?.emitWithAck('updateClient', {
|
||||||
|
outputMuted: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOutput() {
|
||||||
|
if (outputMuted.value)
|
||||||
|
await unmuteOutput()
|
||||||
|
else
|
||||||
|
await muteOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVideo() {
|
||||||
|
if (!mediasoup.videoProducer.value) {
|
||||||
|
await mediasoup.enableVideo()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (outputMuted.value) {
|
await mediasoup.disableProducer(mediasoup.videoProducer.value)
|
||||||
outputMuted.value = false
|
|
||||||
}
|
|
||||||
await mediasoup.resumeProducer('microphone')
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated'
|
async function toggleShare() {
|
||||||
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
|
if (!mediasoup.shareProducer.value) {
|
||||||
})
|
await mediasoup.enableShare()
|
||||||
|
|
||||||
watch(outputMuted, (outputMuted) => {
|
|
||||||
if (outputMuted) {
|
|
||||||
previousInputMuted.value = inputMuted.value
|
|
||||||
muteInput()
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
inputMuted.value = previousInputMuted.value
|
await mediasoup.disableProducer(mediasoup.shareProducer.value)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed'
|
|
||||||
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
clients,
|
clients,
|
||||||
inputMuted,
|
inputMuted,
|
||||||
muteInput,
|
muteInput,
|
||||||
@@ -85,7 +141,13 @@ export const useApp = createGlobalState(() => {
|
|||||||
muteOutput,
|
muteOutput,
|
||||||
unmuteOutput,
|
unmuteOutput,
|
||||||
toggleOutput,
|
toggleOutput,
|
||||||
|
toggleVideo,
|
||||||
version,
|
version,
|
||||||
isTauri,
|
isTauri,
|
||||||
|
commitSha,
|
||||||
|
toggleShare,
|
||||||
|
videoEnabled,
|
||||||
|
sharingEnabled,
|
||||||
|
somebodyStreamingVideo,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
|
|||||||
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
|
||||||
const stream = new MediaStream()
|
const stream = new MediaStream()
|
||||||
|
const audioEl = new Audio()
|
||||||
|
|
||||||
const sourceNode = shallowRef<MediaStreamAudioSourceNode>()
|
const sourceNode = shallowRef<MediaStreamAudioSourceNode>()
|
||||||
const gainNode = ctx.createGain()
|
const gainNode = ctx.createGain()
|
||||||
|
|
||||||
let hackExecuted = false
|
watch(audioTrack, async (track, prevTrack) => {
|
||||||
|
|
||||||
watch(audioTrack, (track, prevTrack) => {
|
|
||||||
if (prevTrack)
|
if (prevTrack)
|
||||||
stream.removeTrack(prevTrack)
|
stream.removeTrack(prevTrack)
|
||||||
|
|
||||||
@@ -19,16 +18,14 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
|
|||||||
|
|
||||||
stream.addTrack(track)
|
stream.addTrack(track)
|
||||||
|
|
||||||
if (!hackExecuted) {
|
if (!audioEl.srcObject) {
|
||||||
const audioEl = new Audio()
|
|
||||||
audioEl.srcObject = stream
|
audioEl.srcObject = stream
|
||||||
audioEl.muted = true
|
audioEl.muted = true
|
||||||
hackExecuted = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceNode.value = ctx.createMediaStreamSource(stream)
|
sourceNode.value = ctx.createMediaStreamSource(stream)
|
||||||
|
|
||||||
connect()
|
await connect()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
useEventListener(document, 'click', async () => {
|
useEventListener(document, 'click', async () => {
|
||||||
@@ -36,10 +33,16 @@ export default function useAudioContext(audioTrack: Ref<MediaStreamTrack | undef
|
|||||||
await ctx.resume()
|
await ctx.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
connect()
|
await connect()
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
|
|
||||||
function connect() {
|
onScopeDispose(() => {
|
||||||
|
audioEl.pause()
|
||||||
|
audioEl.srcObject = null
|
||||||
|
ctx.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
if (!sourceNode.value || ctx.state === 'suspended')
|
if (!sourceNode.value || ctx.state === 'suspended')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
52
client/app/composables/use-client.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client/app/composables/use-devices.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
5
client/app/composables/use-fullscreen-gallery.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createSharedComposable } from '@vueuse/core'
|
||||||
|
|
||||||
|
export const useFullscreenGallery = createSharedComposable(() => {
|
||||||
|
return {}
|
||||||
|
})
|
||||||
65
client/app/composables/use-fullscreen-video.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,180 +276,237 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
recvTransport.value?.close()
|
recvTransport.value?.close()
|
||||||
recvTransport.value = undefined
|
recvTransport.value = undefined
|
||||||
|
|
||||||
micProducer.value = undefined
|
consumers.value = {}
|
||||||
cameraProducer.value = undefined
|
producers.value = {}
|
||||||
shareProducer.value = undefined
|
|
||||||
|
|
||||||
consumers.value = new Map()
|
|
||||||
producers.value = new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.on('consumerPaused', ({ consumerId }) => {
|
|
||||||
const consumer = consumers.value.get(consumerId)
|
|
||||||
|
|
||||||
if (!consumer)
|
|
||||||
return
|
|
||||||
|
|
||||||
consumer.pause()
|
|
||||||
|
|
||||||
console.log(consumerId)
|
|
||||||
|
|
||||||
triggerRef(consumers)
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.on('consumerResumed', ({ consumerId }) => {
|
|
||||||
const consumer = consumers.value.get(consumerId)
|
|
||||||
|
|
||||||
if (!consumer)
|
|
||||||
return
|
|
||||||
|
|
||||||
consumer.resume()
|
|
||||||
|
|
||||||
triggerRef(consumers)
|
|
||||||
})
|
})
|
||||||
}, { immediate: true, flush: 'sync' })
|
}, { immediate: true, flush: 'sync' })
|
||||||
|
|
||||||
function getClientConsumers(socketId: ChadClient['socketId']) {
|
async function createProducer(options: ProducerOptions) {
|
||||||
return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId)
|
if (!device.value || !sendTransport.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!options.track)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!device.value.canProduce(options.track.kind as MediaKind))
|
||||||
|
return
|
||||||
|
|
||||||
|
const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options })
|
||||||
|
|
||||||
|
producers.value[producer.id] = {
|
||||||
|
id: producer.id,
|
||||||
|
paused: producer.paused,
|
||||||
|
appData: producer.appData,
|
||||||
|
raw: markRaw(producer),
|
||||||
|
}
|
||||||
|
|
||||||
|
producer.observer.on('pause', () => {
|
||||||
|
producers.value[producer.id]!.paused = true
|
||||||
|
})
|
||||||
|
|
||||||
|
producer.observer.on('resume', () => {
|
||||||
|
producers.value[producer.id]!.paused = false
|
||||||
|
})
|
||||||
|
|
||||||
|
producer.observer.on('close', () => {
|
||||||
|
delete producers.value[producer.id]
|
||||||
|
})
|
||||||
|
|
||||||
|
producer.on('trackended', () => {
|
||||||
|
disableProducer(producers.value[producer.id]!)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableProducer(producer: Producer) {
|
||||||
|
if (!signaling.socket.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
producer.raw.close()
|
||||||
|
|
||||||
|
await signaling.socket.value.emitWithAck('closeProducer', {
|
||||||
|
producerId: producer.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
delete producers.value[producer.id]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableMic() {
|
async function enableMic() {
|
||||||
if (micProducer.value)
|
if (micProducer.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!device.value || !sendTransport.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!device.value.canProduce('audio'))
|
|
||||||
return
|
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
autoGainControl: false,
|
deviceId: { exact: preferences.inputDeviceId.value },
|
||||||
noiseSuppression: true,
|
autoGainControl: { exact: preferences.autoGainControl.value },
|
||||||
echoCancellation: false,
|
echoCancellation: { exact: preferences.echoCancellation.value },
|
||||||
channelCount: 2,
|
noiseSuppression: { exact: preferences.noiseSuppression.value },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const track = stream.getAudioTracks()[0]
|
const track = stream.getAudioTracks()[0]
|
||||||
|
|
||||||
if (!track)
|
if (!track)
|
||||||
return
|
return
|
||||||
|
|
||||||
micProducer.value = await sendTransport.value.produce({
|
await createProducer({
|
||||||
track,
|
track,
|
||||||
|
streamId: 'mic-video',
|
||||||
codecOptions: {
|
codecOptions: {
|
||||||
opusStereo: true,
|
opusStereo: true,
|
||||||
opusDtx: true, // Меньше пакетов летит когда тишина
|
opusDtx: true, // Меньше пакетов летит когда тишина
|
||||||
opusFec: false, // Фиксит пакет лос
|
opusFec: false, // Фиксит пакет лос
|
||||||
},
|
},
|
||||||
})
|
appData: {
|
||||||
|
source: 'mic-video',
|
||||||
producers.value.set(micProducer.value.id, micProducer.value)
|
},
|
||||||
triggerRef(producers)
|
|
||||||
|
|
||||||
micProducer.value.on('transportclose', () => {
|
|
||||||
micProducer.value = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
micProducer.value.on('trackended', () => {
|
|
||||||
disableMic()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disableMic() {
|
async function disableMic() {
|
||||||
if (!signaling.socket.value || !micProducer.value)
|
if (!micProducer.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
producers.value.delete(micProducer.value.id)
|
await disableProducer(micProducer.value)
|
||||||
triggerRef(producers)
|
|
||||||
|
|
||||||
try {
|
|
||||||
micProducer.value.close()
|
|
||||||
|
|
||||||
await signaling.socket.value.emitWithAck('closeProducer', {
|
|
||||||
producerId: micProducer.value.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
}
|
|
||||||
|
|
||||||
micProducer.value = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pauseProducer(type: ProducerType) {
|
async function enableVideo() {
|
||||||
if (!signaling.socket.value)
|
if (videoProducer.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
const producer = getProducerByType(type)
|
if (!device.value)
|
||||||
|
return
|
||||||
|
|
||||||
if (!producer)
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
deviceId: { exact: preferences.videoDeviceId.value },
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 },
|
||||||
|
frameRate: { ideal: 60 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = stream.getVideoTracks()[0]
|
||||||
|
|
||||||
|
if (!track)
|
||||||
|
return
|
||||||
|
|
||||||
|
await createProducer({
|
||||||
|
track,
|
||||||
|
streamId: 'mic-video',
|
||||||
|
// codec: device.value.rtpCapabilities.codecs?.find(
|
||||||
|
// c => c.mimeType.toLowerCase() === 'video/AV1',
|
||||||
|
// ),
|
||||||
|
// codecOptions: {
|
||||||
|
// videoGoogleStartBitrate: 1000,
|
||||||
|
// },
|
||||||
|
appData: {
|
||||||
|
source: 'mic-video',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableShare() {
|
||||||
|
if (shareProducer.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!device.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
const stream = await getShareStream(preferences.shareFps.value)
|
||||||
|
|
||||||
|
const track = stream.getVideoTracks()[0]
|
||||||
|
|
||||||
|
if (!track)
|
||||||
|
return
|
||||||
|
|
||||||
|
await createProducer({
|
||||||
|
track,
|
||||||
|
streamId: 'share',
|
||||||
|
codec: device.value.rtpCapabilities.codecs?.find(
|
||||||
|
c => c.mimeType.toLowerCase() === 'video/AV1',
|
||||||
|
),
|
||||||
|
codecOptions: {
|
||||||
|
videoGoogleStartBitrate: 1000,
|
||||||
|
},
|
||||||
|
zeroRtpOnPause: true,
|
||||||
|
appData: {
|
||||||
|
source: 'share',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseProducer(producer: Producer) {
|
||||||
|
if (!signaling.socket.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (producer.paused)
|
if (producer.paused)
|
||||||
return
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
producer.pause()
|
producer.raw.pause()
|
||||||
|
|
||||||
await signaling.socket.value.emitWithAck('pauseProducer', {
|
await signaling.socket.value.emitWithAck('pauseProducer', {
|
||||||
producerId: producer.id,
|
producerId: producer.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
producer.resume()
|
producer.raw.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeProducer(type: ProducerType) {
|
async function resumeProducer(producer: Producer) {
|
||||||
if (!signaling.socket.value)
|
if (!signaling.socket.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
const producer = getProducerByType(type)
|
|
||||||
|
|
||||||
if (!producer)
|
|
||||||
return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
producer.resume()
|
producer.raw.resume()
|
||||||
|
|
||||||
await signaling.socket.value.emitWithAck('resumeProducer', {
|
await signaling.socket.value.emitWithAck('resumeProducer', {
|
||||||
producerId: producer.id,
|
producerId: producer.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
producer.pause()
|
producer.raw.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
watch([
|
||||||
signaling.connect()
|
preferences.inputDeviceId,
|
||||||
}
|
preferences.echoCancellation,
|
||||||
|
preferences.autoGainControl,
|
||||||
|
preferences.noiseSuppression,
|
||||||
|
], async ([inputDeviceId]) => {
|
||||||
|
await disableMic()
|
||||||
|
|
||||||
function getProducerByType(type: ProducerType) {
|
if (!inputDeviceId)
|
||||||
switch (type) {
|
return
|
||||||
case 'microphone':
|
|
||||||
return micProducer.value
|
await enableMic()
|
||||||
case 'camera':
|
})
|
||||||
return cameraProducer.value
|
|
||||||
case 'share':
|
|
||||||
return shareProducer.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init,
|
|
||||||
consumers,
|
consumers,
|
||||||
|
audioConsumers,
|
||||||
|
videoConsumers,
|
||||||
|
shareConsumers,
|
||||||
producers,
|
producers,
|
||||||
|
speakingClients,
|
||||||
sendTransport,
|
sendTransport,
|
||||||
recvTransport,
|
recvTransport,
|
||||||
rtpCapabilities,
|
rtpCapabilities,
|
||||||
device,
|
device,
|
||||||
micProducer,
|
micProducer,
|
||||||
cameraProducer,
|
videoProducer,
|
||||||
shareProducer,
|
shareProducer,
|
||||||
getClientConsumers,
|
|
||||||
pauseProducer,
|
pauseProducer,
|
||||||
resumeProducer,
|
resumeProducer,
|
||||||
|
enableVideo,
|
||||||
|
enableShare,
|
||||||
|
disableProducer,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,75 @@
|
|||||||
import { createGlobalState, useDevicesList } from '@vueuse/core'
|
import chadApi from '#shared/chad-api'
|
||||||
|
import { createGlobalState, useLocalStorage, watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
|
export interface SyncedPreferences {
|
||||||
|
toggleInputHotkey: string
|
||||||
|
toggleOutputHotkey: string
|
||||||
|
volumes: Record<Client['id'], number>
|
||||||
|
}
|
||||||
|
|
||||||
export const usePreferences = createGlobalState(() => {
|
export const usePreferences = createGlobalState(() => {
|
||||||
const inputDeviceId = shallowRef<MediaDeviceInfo['deviceId']>()
|
const { videoInputs, audioInputs, audioOutputs } = useDevices()
|
||||||
const outputDeviceId = shallowRef<MediaDeviceInfo['deviceId']>()
|
|
||||||
|
|
||||||
const {
|
const synced = ref(false)
|
||||||
ensurePermissions,
|
|
||||||
permissionGranted,
|
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
|
||||||
videoInputs,
|
const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
|
||||||
audioInputs,
|
const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
|
||||||
audioOutputs,
|
|
||||||
} = useDevicesList()
|
const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
|
||||||
|
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
|
||||||
|
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
|
||||||
|
|
||||||
|
const shareFps = useLocalStorage('SHARE_FPS', 30)
|
||||||
|
|
||||||
|
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
|
||||||
|
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
|
||||||
|
|
||||||
|
const inputDeviceExist = computed(() => {
|
||||||
|
return audioInputs.value.some(device => device.deviceId === inputDeviceId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputDeviceExist = computed(() => {
|
||||||
|
return audioOutputs.value.some(device => device.deviceId === outputDeviceId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoDeviceExist = computed(() => {
|
||||||
|
return videoInputs.value.some(device => device.deviceId === videoDeviceId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
[toggleInputHotkey, toggleOutputHotkey],
|
||||||
|
async ([toggleInputHotkey, toggleOutputHotkey]) => {
|
||||||
|
try {
|
||||||
|
await chadApi(
|
||||||
|
'/preferences',
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
toggleInputHotkey,
|
||||||
|
toggleOutputHotkey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
},
|
||||||
|
{ debounce: 1000 },
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
synced,
|
||||||
inputDeviceId,
|
inputDeviceId,
|
||||||
outputDeviceId,
|
outputDeviceId,
|
||||||
videoInputs,
|
videoDeviceId,
|
||||||
audioInputs,
|
autoGainControl,
|
||||||
audioOutputs,
|
noiseSuppression,
|
||||||
|
echoCancellation,
|
||||||
|
shareFps,
|
||||||
|
toggleInputHotkey,
|
||||||
|
toggleOutputHotkey,
|
||||||
|
inputDeviceExist,
|
||||||
|
outputDeviceExist,
|
||||||
|
videoDeviceExist,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,11 +66,9 @@ export const useSignaling = createSharedComposable(() => {
|
|||||||
|
|
||||||
const uri = host ? `${protocol}//${host}` : ``
|
const uri = host ? `${protocol}//${host}` : ``
|
||||||
|
|
||||||
socket.value = io(`http://localhost:4000/webrtc`, {
|
socket.value = io(`${uri}/webrtc`, {
|
||||||
path: `/chad/ws`,
|
path: `${pathname}/ws`,
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
// socket.value = io(`${uri}/webrtc`, {
|
|
||||||
// path: `${pathname}/ws`,
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
auth: {
|
auth: {
|
||||||
userId: me.value.id,
|
userId: me.value.id,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const useUpdater = createGlobalState(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
|
checking,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const options = computed(() => {
|
|||||||
{
|
{
|
||||||
label: 'Register',
|
label: 'Register',
|
||||||
routeName: 'Register',
|
routeName: 'Register',
|
||||||
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,52 +1,155 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-2 h-screen">
|
<div class="grid grid-cols-[360px_1fr] gap-2 p-2 h-screen grid-rows-[auto_1fr] max-w-full">
|
||||||
<div class="flex flex-col shadow-xl shadow-surface-950 overflow-y-hidden">
|
<div
|
||||||
<AppHeader title="Шальные сиськи 18+">
|
class="flex items-center justify-between gap-2 rounded-xl p-3 bg-surface-950"
|
||||||
<template #right>
|
>
|
||||||
<PrimeButtonGroup class="ml-auto">
|
<div class="inline-flex items-center gap-3">
|
||||||
<PrimeButton
|
<PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
|
||||||
icon="pi pi-microphone" size="large" :severity="inputMuted ? 'contrast' : 'secondary'"
|
<PrimeBadge :severity="connected ? 'success' : 'danger' " />
|
||||||
:outlined="!inputMuted" @click="toggleInput"
|
|
||||||
/>
|
|
||||||
<PrimeButton
|
|
||||||
icon="pi pi-headphones" size="large" :severity="outputMuted ? 'contrast' : 'secondary'"
|
|
||||||
:outlined="!outputMuted" @click="toggleOutput"
|
|
||||||
/>
|
|
||||||
</PrimeButtonGroup>
|
|
||||||
|
|
||||||
<PrimeButton icon="pi pi-cog" size="large" :text="!inPreferences" :severity="inPreferences ? 'contrast' : 'secondary'" @click="onClickPreferences" />
|
|
||||||
</template>
|
|
||||||
</AppHeader>
|
|
||||||
|
|
||||||
<div v-auto-animate class="p-3 overflow-y-auto flex-1 bg-surface-900 overflow-hidden divide-y divide-surface-800">
|
|
||||||
<ClientRow v-for="client of clients" :key="client.id" :client="client" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PrimeButtonGroup class="ml-auto">
|
||||||
|
<PrimeButton :severity="inputMuted ? 'info' : undefined" outlined @click="toggleInput">
|
||||||
|
<template #icon>
|
||||||
|
<Component :is="inputMuted ? MicOff : Mic" />
|
||||||
|
</template>
|
||||||
|
</PrimeButton>
|
||||||
|
<PrimeButton :severity="outputMuted ? 'info' : undefined" outlined @click="toggleOutput">
|
||||||
|
<template #icon>
|
||||||
|
<Component :is="outputMuted ? VolumeOff : Volume2" />
|
||||||
|
</template>
|
||||||
|
</PrimeButton>
|
||||||
|
</PrimeButtonGroup>
|
||||||
|
|
||||||
|
<PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
|
||||||
|
<template #icon>
|
||||||
|
<Component :is="videoEnabled ? CameraOff : Camera" />
|
||||||
|
</template>
|
||||||
|
</PrimeButton>
|
||||||
|
|
||||||
|
<PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
|
||||||
|
<template #icon>
|
||||||
|
<Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
|
||||||
|
</template>
|
||||||
|
</PrimeButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-y-auto">
|
<div
|
||||||
<slot />
|
class="flex items-center justify-center rounded-xl p-3 bg-surface-950"
|
||||||
|
>
|
||||||
|
<PrimeSelectButton
|
||||||
|
v-model="activeTab"
|
||||||
|
:options="tabs"
|
||||||
|
option-label="id"
|
||||||
|
:allow-empty="false"
|
||||||
|
style="--p-togglebutton-content-padding: 0.25rem 0.5rem"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<Component :is="option.icon" size="24" />
|
||||||
|
</template>
|
||||||
|
</PrimeSelectButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
|
||||||
|
<div v-auto-animate class="p-3 space-y-1">
|
||||||
|
<ClientRow v-for="client of clients" :key="client.userId" :client="client" />
|
||||||
|
</div>
|
||||||
|
</PrimeScrollPanel>
|
||||||
|
|
||||||
|
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
|
||||||
|
<div class="p-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</PrimeScrollPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fixed top-3 right-3 inline-flex items-center gap-3">
|
<FullscreenGallery />
|
||||||
<PrimeBadge v-if="isTauri" class="opacity-50" severity="secondary" :value="version" size="small" />
|
|
||||||
<PrimeBadge :severity="connected ? 'success' : 'danger' " />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput, version, isTauri } = useApp()
|
import {
|
||||||
|
Camera,
|
||||||
|
CameraOff,
|
||||||
|
MessageCircle,
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
|
ScreenShare,
|
||||||
|
ScreenShareOff,
|
||||||
|
Settings,
|
||||||
|
TvMinimalPlay,
|
||||||
|
UserPen,
|
||||||
|
Volume2,
|
||||||
|
VolumeOff,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const {
|
||||||
|
version,
|
||||||
|
clients,
|
||||||
|
inputMuted,
|
||||||
|
outputMuted,
|
||||||
|
videoEnabled,
|
||||||
|
sharingEnabled,
|
||||||
|
somebodyStreamingVideo,
|
||||||
|
toggleInput,
|
||||||
|
toggleOutput,
|
||||||
|
toggleVideo,
|
||||||
|
toggleShare,
|
||||||
|
} = useApp()
|
||||||
const { connect, connected } = useSignaling()
|
const { connect, connected } = useSignaling()
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string
|
||||||
|
icon: Component
|
||||||
|
onClick: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const inPreferences = computed(() => {
|
const tabs = computed<Tab[]>(() => {
|
||||||
return route.name === 'Preferences'
|
const result = []
|
||||||
|
|
||||||
|
if (somebodyStreamingVideo.value) {
|
||||||
|
result.push({
|
||||||
|
id: 'Gallery',
|
||||||
|
icon: TvMinimalPlay,
|
||||||
|
onClick: () => {
|
||||||
|
navigateTo({ name: 'Gallery' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
{
|
||||||
|
id: 'Index',
|
||||||
|
icon: MessageCircle,
|
||||||
|
onClick: () => {
|
||||||
|
navigateTo({ name: 'Index' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Profile',
|
||||||
|
icon: UserPen,
|
||||||
|
onClick: () => {
|
||||||
|
navigateTo({ name: 'Profile' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Preferences',
|
||||||
|
icon: Settings,
|
||||||
|
onClick: () => {
|
||||||
|
navigateTo({ name: 'Preferences' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
function onClickPreferences() {
|
const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
|
||||||
navigateTo(!inPreferences.value ? { name: 'Preferences' } : '/')
|
|
||||||
}
|
watch(activeTab, (activeTab) => {
|
||||||
|
activeTab.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
connect()
|
connect()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
// if (import.meta.dev || import.meta.server)
|
if (import.meta.dev || import.meta.server)
|
||||||
// return
|
return
|
||||||
|
|
||||||
const { isTauri } = useApp()
|
const { isTauri } = useApp()
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
26
client/app/middleware/02.user-preferences.global.ts
Normal 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 {}
|
||||||
|
})
|
||||||
64
client/app/pages/gallery.vue
Normal 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>
|
||||||
@@ -1,62 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3 p-3 pt-12">
|
<div>
|
||||||
<PrimePanel header="Clients" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }">
|
<div class="flex items-center justify-center">
|
||||||
<dl v-for="client in clients" :key="client.socketId" class="">
|
<PrimeCard>
|
||||||
<div v-for="(value, key) in client" :key="key" class="py-2">
|
<template #content>
|
||||||
<dt class="font-bold">
|
The chat is under development.
|
||||||
{{ key }}
|
</template>
|
||||||
</dt>
|
</PrimeCard>
|
||||||
<dd class="pl-8">
|
</div>
|
||||||
{{ value }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</PrimePanel>
|
|
||||||
|
|
||||||
<PrimePanel header="Producers" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }">
|
|
||||||
<dl v-for="[_, producer] in Array.from(producers)" :key="producer.id" class="">
|
|
||||||
<div v-for="key in ['id', 'paused']" :key="key" class="py-2">
|
|
||||||
<dt class="font-bold">
|
|
||||||
{{ key }}
|
|
||||||
</dt>
|
|
||||||
<dd class="pl-8">
|
|
||||||
{{ producer[key] }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</PrimePanel>
|
|
||||||
|
|
||||||
<PrimePanel header="Consumers" toggleable collapsed :pt="{ content: { class: 'divide-y divide-surface-800 py-4' } }">
|
|
||||||
<dl v-for="[_, consumer] in Array.from(consumers)" :key="consumer.id" class="">
|
|
||||||
<div v-for="key in ['id', 'paused']" :key="key" class="py-2">
|
|
||||||
<dt class="font-bold">
|
|
||||||
{{ key }}
|
|
||||||
</dt>
|
|
||||||
<dd class="pl-8">
|
|
||||||
{{ consumer[key] }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</PrimePanel>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="flex items-center justify-center p-3"> -->
|
|
||||||
<!-- <PrimeCard> -->
|
|
||||||
<!-- <template #subtitle> -->
|
|
||||||
<!-- Important information -->
|
|
||||||
<!-- </template> -->
|
|
||||||
|
|
||||||
<!-- <template #content> -->
|
|
||||||
<!-- The chat is under development. -->
|
|
||||||
<!-- </template> -->
|
|
||||||
<!-- </PrimeCard> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { clients } = useClients()
|
|
||||||
const { producers, consumers } = useMediasoup()
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
name: 'Index',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,95 +1,208 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<AppHeader title="Preferences" secondary />
|
<PrimeDivider align="left">
|
||||||
|
Audio
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
|
<PrimeFloatLabel variant="on">
|
||||||
|
<PrimeSelect
|
||||||
|
v-model="inputDeviceId"
|
||||||
|
:options="audioInputs"
|
||||||
|
option-label="label"
|
||||||
|
option-value="deviceId"
|
||||||
|
input-id="inputDevice"
|
||||||
|
placeholder="No input device"
|
||||||
|
fluid
|
||||||
|
:invalid="!inputDeviceExist"
|
||||||
|
/>
|
||||||
|
<label for="inputDevice">Input device</label>
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mt-3">
|
||||||
|
<PrimeToggleSwitch v-model="autoGainControl" input-id="autoGainControl" />
|
||||||
|
<label for="autoGainControl">Auto Gain Control</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mt-3">
|
||||||
|
<PrimeToggleSwitch v-model="echoCancellation" input-id="echoCancellation" />
|
||||||
|
<label for="echoCancellation">Echo Cancellation</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mt-3">
|
||||||
|
<PrimeToggleSwitch v-model="noiseSuppression" input-id="noiseSuppression" />
|
||||||
|
<label for="noiseSuppression">Noise Suppression</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <PrimeFloatLabel variant="on"> -->
|
||||||
|
<!-- <PrimeSelect -->
|
||||||
|
<!-- v-model="outputDeviceId" -->
|
||||||
|
<!-- :options="audioOutputs" -->
|
||||||
|
<!-- option-label="label" -->
|
||||||
|
<!-- option-value="deviceId" -->
|
||||||
|
<!-- fluid -->
|
||||||
|
<!-- class="mt-6" -->
|
||||||
|
<!-- input-id="outputDevice" -->
|
||||||
|
<!-- :invalid="!outputDeviceExist" -->
|
||||||
|
<!-- -->
|
||||||
|
<!-- /> -->
|
||||||
|
<!-- <label for="outputDevice">Output device</label> -->
|
||||||
|
<!-- </PrimeFloatLabel> -->
|
||||||
|
|
||||||
|
<PrimeDivider align="left">
|
||||||
|
Video
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
|
<PrimeFloatLabel variant="on">
|
||||||
|
<PrimeSelect
|
||||||
|
v-model="videoDeviceId"
|
||||||
|
:options="videoInputs"
|
||||||
|
option-label="label"
|
||||||
|
option-value="deviceId"
|
||||||
|
input-id="videoDevice"
|
||||||
|
placeholder="No video device"
|
||||||
|
fluid
|
||||||
|
:invalid="!videoDeviceExist"
|
||||||
|
/>
|
||||||
|
<label for="inputDevice">Input device</label>
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
|
||||||
|
<PrimeDivider align="left">
|
||||||
|
Screen sharing
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm mb-2 text-center">
|
||||||
|
FPS
|
||||||
|
</p>
|
||||||
|
<PrimeSelectButton
|
||||||
|
v-model="shareFps"
|
||||||
|
:options="shareFpsOptions"
|
||||||
|
fluid
|
||||||
|
size="small"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isTauri">
|
||||||
|
<PrimeDivider align="left">
|
||||||
|
Hotkeys
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
<form class="flex flex-col gap-3 p-3" @submit.prevent="save">
|
|
||||||
<PrimeFloatLabel variant="on">
|
<PrimeFloatLabel variant="on">
|
||||||
<PrimeInputText id="username" v-model="displayName" size="large" fluid autocomplete="off" />
|
<PrimeInputText id="microphoneToggle" :model-value="toggleInputHotkey" fluid @keydown="setupToggleInputHotkey" />
|
||||||
<label for="username">Username</label>
|
<label for="microphoneToggle">Toggle microphone</label>
|
||||||
</PrimeFloatLabel>
|
</PrimeFloatLabel>
|
||||||
|
|
||||||
<PrimeButton label="Save" type="submit" :disabled="!valid" />
|
<PrimeFloatLabel variant="on" class="mt-3">
|
||||||
</form>
|
<PrimeInputText id="soundToggle" :model-value="toggleOutputHotkey" fluid @keydown="setupToggleOutputHotkey" />
|
||||||
|
<label for="soundToggle">Toggle sound</label>
|
||||||
<div v-if="isTauri" class="p-3">
|
</PrimeFloatLabel>
|
||||||
<PrimeButton label="Check for Updates" fluid severity="info" @click="onCheckForUpdates" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3">
|
|
||||||
<PrimeButton label="Logout" fluid severity="danger" @click="logout()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PrimeToast position="bottom-center" group="updater">
|
|
||||||
<template #container="slotProps">
|
|
||||||
<div class="p-3">
|
|
||||||
<div class="font-medium text-lg mb-4">
|
|
||||||
{{ slotProps.message.detail }}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<PrimeButton size="small" label="Update now" @click="() => {}" />
|
|
||||||
<PrimeButton size="small" label="Later" severity="secondary" outlined @click="slotProps.closeCallback()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</PrimeToast>
|
|
||||||
|
<PrimeDivider align="left">
|
||||||
|
About
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
|
<p v-if="version" class="text-muted-color text-sm">
|
||||||
|
VERSION: {{ version }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-color text-sm mt-2">
|
||||||
|
COMMIT_SHA: {{ commitSha }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="isTauri">
|
||||||
|
<PrimeButton
|
||||||
|
v-if="lastUpdate"
|
||||||
|
class="mt-3"
|
||||||
|
size="small"
|
||||||
|
label="Install new version"
|
||||||
|
fluid
|
||||||
|
severity="success"
|
||||||
|
@click="navigateTo({ name: 'Updater' })"
|
||||||
|
/>
|
||||||
|
<PrimeButton
|
||||||
|
v-else
|
||||||
|
class="mt-3"
|
||||||
|
size="small"
|
||||||
|
label="Check for Updates"
|
||||||
|
fluid
|
||||||
|
severity="info"
|
||||||
|
:loading="checking"
|
||||||
|
@click="checkForUpdates"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { RemovableRef } from '@vueuse/core'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
name: 'Preferences',
|
name: 'Preferences',
|
||||||
})
|
})
|
||||||
|
const { isTauri, version, commitSha } = useApp()
|
||||||
|
const { checking, checkForUpdates, lastUpdate } = useUpdater()
|
||||||
|
const { audioInputs, audioOutputs, videoInputs } = useDevices()
|
||||||
|
const {
|
||||||
|
inputDeviceId,
|
||||||
|
outputDeviceId,
|
||||||
|
videoDeviceId,
|
||||||
|
autoGainControl,
|
||||||
|
noiseSuppression,
|
||||||
|
echoCancellation,
|
||||||
|
toggleInputHotkey,
|
||||||
|
toggleOutputHotkey,
|
||||||
|
inputDeviceExist,
|
||||||
|
outputDeviceExist,
|
||||||
|
videoDeviceExist,
|
||||||
|
shareFps,
|
||||||
|
} = usePreferences()
|
||||||
|
|
||||||
const { isTauri } = useApp()
|
const shareFpsOptions = [5, 30, 60].map((value) => {
|
||||||
const { checkForUpdates } = useUpdater()
|
return {
|
||||||
const { me, setMe, logout } = useAuth()
|
label: value.toString(),
|
||||||
|
value,
|
||||||
const signaling = useSignaling()
|
}
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const displayName = ref(me.value?.displayName || '')
|
|
||||||
|
|
||||||
const valid = computed(() => {
|
|
||||||
if (!displayName.value || !me.value)
|
|
||||||
return false
|
|
||||||
|
|
||||||
if (displayName.value === me.value.displayName)
|
|
||||||
return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onCheckForUpdates() {
|
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
|
||||||
const update = await checkForUpdates()
|
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
|
||||||
|
|
||||||
toast.removeGroup('updater')
|
function setupHotkey(event: KeyboardEvent, model: RemovableRef<string>) {
|
||||||
|
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!update) {
|
event.preventDefault()
|
||||||
toast.add({ severity: 'success', summary: 'You are up to date', closable: false, life: 1000 })
|
|
||||||
|
const hotkey = []
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey)
|
||||||
|
hotkey.push('CommandOrControl')
|
||||||
|
if (event.altKey)
|
||||||
|
hotkey.push('Alt')
|
||||||
|
if (event.shiftKey)
|
||||||
|
hotkey.push('Shift')
|
||||||
|
|
||||||
|
const modifierApplied = hotkey.length > 0
|
||||||
|
|
||||||
|
if (!modifierApplied && ['Escape', 'Backspace', 'Delete'].includes(event.key)) {
|
||||||
|
model.value = ''
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.add({
|
if (!['Control', 'Shift', 'Alt'].includes(event.key)) {
|
||||||
group: 'updater',
|
hotkey.push(event.key.toUpperCase())
|
||||||
severity: 'info',
|
}
|
||||||
detail: `Version ${update?.version ?? '1.0.1'} is available!`,
|
|
||||||
closable: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// asdasd
|
if (modifierApplied && hotkey.length === 1) {
|
||||||
}
|
model.value = ''
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!valid.value)
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', {
|
model.value = hotkey.join('+')
|
||||||
displayName: displayName.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
setMe({ ...me.value, displayName: updatedMe.displayName })
|
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Saved', life: 1000, closable: false })
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
68
client/app/pages/profile.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<PrimeDivider align="left">
|
||||||
|
General
|
||||||
|
</PrimeDivider>
|
||||||
|
|
||||||
|
<PrimeFloatLabel variant="on">
|
||||||
|
<PrimeInputText id="displayName" v-model="displayName" fluid autocomplete="off" />
|
||||||
|
<label for="displayName">Display name</label>
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mt-6">
|
||||||
|
<PrimeButton label="Save" :disabled="!valid" :loading="saving" fluid type="submit" />
|
||||||
|
<PrimeButton severity="danger" class="shrink-0" @click="logout()">
|
||||||
|
<template #icon>
|
||||||
|
<LogOut />
|
||||||
|
</template>
|
||||||
|
</PrimeButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import chadApi from '#shared/chad-api'
|
||||||
|
import { LogOut } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'Profile',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { me, setMe, logout } = useAuth()
|
||||||
|
const signaling = useSignaling()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const displayName = ref(me.value?.displayName || '')
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const valid = computed(() => {
|
||||||
|
if (!me.value)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (displayName.value === me.value.displayName)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!valid.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
const updatedMe = await chadApi('/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
displayName: displayName.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setMe({ ...me.value!, displayName: (updatedMe.displayName as string) })
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Saved', life: 1000, closable: false })
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
7
client/app/plugins/00.build-info.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineNuxtPlugin({
|
||||||
|
setup() {
|
||||||
|
console.group('Build Info')
|
||||||
|
console.log(`COMMIT_SHA: ${__COMMIT_SHA__}`)
|
||||||
|
console.groupEnd()
|
||||||
|
},
|
||||||
|
})
|
||||||
45
client/app/plugins/01.register-hotkeys.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -86,8 +86,8 @@ export default defineNuxtConfig({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:4000/chad',
|
// target: 'http://localhost:4000/chad',
|
||||||
// target: 'https://api.koptilnya.xyz/chad',
|
target: 'https://api.koptilnya.xyz/chad',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => {
|
rewrite: (path) => {
|
||||||
@@ -97,7 +97,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__API_BASE_URL__: JSON.stringify(import.meta.env.API_BASE_URL || '/api'),
|
__API_BASE_URL__: JSON.stringify(import.meta.env.API_BASE_URL || 'http://localhost:4000/chad'),
|
||||||
__COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'),
|
__COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev --host",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
@@ -14,10 +14,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",
|
||||||
"mediasoup-client": "^3.16.7",
|
"hotkeys-js": "^4.0.0",
|
||||||
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"mediasoup-client": "^3.18.6",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
87
client/src-tauri/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 352 B |
BIN
client/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 663 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 346 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 487 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 717 B |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 882 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 534 B |
BIN
client/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 525 B |
|
After Width: | Height: | Size: 1.6 KiB |
BIN
client/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
client/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 486 B |
|
After Width: | Height: | Size: 1.1 KiB |
BIN
client/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
client/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 956 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 956 B |
BIN
client/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 5.5 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 318 B |
BIN
client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
client/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
client/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 711 B |
BIN
client/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 425 B |
BIN
client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
client/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
client/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 939 B |
BIN
client/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 855 B |
BIN
client/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 855 B |
BIN
client/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 853 B |
BIN
client/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
3
client/src-tauri/icons/original.svg
Normal 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 |
@@ -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(
|
||||||
|
|||||||
@@ -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.8",
|
"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": {
|
||||||
|
|||||||
@@ -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,12 +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"
|
||||||
mediasoup-client: "npm:^3.16.7"
|
hotkeys-js: "npm:^4.0.0"
|
||||||
|
lucide-vue-next: "npm:^0.562.0"
|
||||||
|
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"
|
||||||
@@ -6157,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
|
||||||
|
|
||||||
@@ -6206,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"
|
||||||
@@ -7024,6 +7050,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"lucide-vue-next@npm:^0.562.0":
|
||||||
|
version: 0.562.0
|
||||||
|
resolution: "lucide-vue-next@npm:0.562.0"
|
||||||
|
peerDependencies:
|
||||||
|
vue: ">=3.0.1"
|
||||||
|
checksum: 10c0/5ba792ea5e48d01fc99a3c5ae4a59d9767e5d4c7826901800831cf051cf85eb4a680b3564d2910ed4d17dc1d35223c37d006bdbcdb291d90a9491b9c6a20ae14
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"magic-regexp@npm:^0.10.0":
|
"magic-regexp@npm:^0.10.0":
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
resolution: "magic-regexp@npm:0.10.0"
|
resolution: "magic-regexp@npm:0.10.0"
|
||||||
@@ -7267,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"
|
||||||
@@ -7277,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
|
||||||
|
|
||||||
@@ -9794,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
|
||||||
|
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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;
|
||||||