107 Commits

Author SHA1 Message Date
1bd8aa0fea cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 51s
2026-02-06 23:14:13 +06:00
626f52c616 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 50s
2026-02-06 23:06:50 +06:00
29914d73a0 cringe sfx 2026-02-06 23:06:42 +06:00
dd530266f9 cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 49s
2026-02-06 22:44:11 +06:00
a37b2048fe cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 53s
2026-02-06 22:41:58 +06:00
e3ac3e003c cringe sfx
All checks were successful
Deploy / publish-web (push) Successful in 2m35s
2026-02-06 22:32:01 +06:00
6fa142f133 productName typo 2026-02-03 22:06:09 +06:00
8e0a08da05 icons
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 22:02:45 +06:00
0a3b2c3dc8 resize
All checks were successful
Deploy / publish-web (push) Successful in 44s
2026-02-03 17:05:43 +06:00
e5f1e6bbb3 показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-02-03 16:54:51 +06:00
1354ca3f7e показывать себя в превью
All checks were successful
Deploy / publish-web (push) Successful in 42s
2026-02-03 16:47:33 +06:00
269b19a5be вебкамера там, туда-сюда
All checks were successful
Deploy / publish-web (push) Successful in 1m16s
2026-02-02 14:39:16 +06:00
0922fc4f41 новый-старый clientrow
All checks were successful
Deploy / publish-web (push) Successful in 41s
2026-01-29 23:19:31 +06:00
9fc8f954e3 новый-старый clientrow 2026-01-29 23:18:47 +06:00
a645885cf2 client volumes
All checks were successful
Deploy / publish-web (push) Successful in 1m31s
2026-01-29 22:05:05 +06:00
4c8a0e791c client volumes 2026-01-29 22:04:40 +06:00
fbdceb2e55 client volumes 2026-01-29 21:59:41 +06:00
aeaea47609 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 33s
2026-01-29 21:40:56 +06:00
f4fd752448 client volumes and dominant client
All checks were successful
Deploy / deploy (push) Successful in 34s
2026-01-29 21:34:46 +06:00
595354b7f0 Merge pull request 'shareFps' (#9) from shareFps into master
All checks were successful
Deploy / publish-web (push) Successful in 1m32s
Reviewed-on: #9
2026-01-12 07:23:51 +00:00
Nadar
d08b011596 shareFps 2026-01-12 10:22:56 +03:00
12ce381abd minor update
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-27 02:52:17 +06:00
2d30ac2863 minor update
All checks were successful
Deploy / publish-web (push) Successful in 44s
2025-12-27 02:49:39 +06:00
0f218c1519 button colors 2025-12-27 01:58:01 +06:00
4b1a563850 screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 1m25s
2025-12-27 01:49:25 +06:00
169d43f0db screen sharing 2025-12-27 01:48:49 +06:00
47a464f08f screen sharing
All checks were successful
Deploy / publish-web (push) Successful in 48s
2025-12-26 18:22:22 +06:00
4d5db12e1b screen sharing
Some checks failed
Deploy / deploy (push) Successful in 35s
Deploy / publish-web (push) Failing after 22s
2025-12-26 17:36:30 +06:00
4f59cbcf65 screen sharing
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 17:21:59 +06:00
3b3f6b6e40 update 2025-12-26 01:44:16 +06:00
461cbc6f83 profile rest
All checks were successful
Deploy / publish-web (push) Successful in 46s
2025-12-26 01:25:14 +06:00
a5cda8828f refactor
All checks were successful
Deploy / deploy (push) Successful in 34s
2025-12-26 01:22:34 +06:00
778f0a5687 refactor
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-26 01:18:54 +06:00
2aca9bca08 refactor
All checks were successful
Deploy / deploy (push) Successful in 40s
2025-12-26 01:13:21 +06:00
7ed23df3e9 refactor
All checks were successful
Deploy / deploy (push) Successful in 39s
2025-12-26 01:08:44 +06:00
2ac88f1010 user preferences
All checks were successful
Deploy / publish-web (push) Successful in 2m14s
2025-12-25 22:53:12 +06:00
c2cffd18de user preferences 2025-12-25 22:51:02 +06:00
bf38267c37 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 22:50:56 +06:00
22c5fafb11 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:30:30 +06:00
37683c42a9 user preferences
All checks were successful
Deploy / deploy (push) Successful in 35s
2025-12-25 21:27:54 +06:00
2cbc75d7e3 user preferences
All checks were successful
Deploy / deploy (push) Successful in 37s
2025-12-25 21:24:41 +06:00
6721f63d22 user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:22:43 +06:00
b47643552f user preferences
All checks were successful
Deploy / deploy (push) Successful in 36s
2025-12-25 21:08:14 +06:00
0ab3e15784 cors origins
All checks were successful
Deploy / deploy (push) Successful in 37s
2025-12-25 07:38:07 +06:00
28c64edaf8 update 2025-12-25 07:33:18 +06:00
67a8dc7782 update 2025-12-25 07:30:57 +06:00
43a8b98a6a update 2025-12-25 07:21:45 +06:00
0f9a7e39ce update 2025-12-25 07:21:30 +06:00
8265e2d719 update 2025-12-25 03:51:29 +06:00
4f91309f7f update 2025-12-24 06:29:44 +06:00
bcd457e2d6 update 2025-12-24 06:20:11 +06:00
8eef4fc477 update 2025-12-24 04:39:46 +06:00
614867bd12 update 2025-12-24 04:35:41 +06:00
cdf2bf5952 update 2025-12-24 04:35:13 +06:00
a4bd6705b6 Merge branch 'master' of git.koptilnya.xyz:opti1337/chad
Some checks failed
Deploy / publish-windows (push) Has been cancelled
2025-12-24 04:03:07 +06:00
723048c72a update 2025-12-24 04:02:53 +06:00
06ea0cd488 Обновить .gitea/workflows/deploy-client.yml
Some checks failed
Deploy / publish-windows (push) Failing after 6m3s
2025-12-23 19:52:47 +00:00
007d3ddda7 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Has been cancelled
2025-12-24 00:42:07 +06:00
33cdaebada Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 3m0s
2025-12-24 00:25:30 +06:00
9650ea63fc update
Some checks failed
Deploy / publish-windows (push) Failing after 18s
2025-12-24 00:23:29 +06:00
db59b85bd2 udpate
All checks were successful
Deploy / publish-windows (push) Successful in 3m22s
2025-12-24 00:11:03 +06:00
3f6f3b739e Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 9m54s
2025-12-23 23:43:14 +06:00
b33a896117 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 4m17s
2025-12-23 23:14:44 +06:00
01ae1b5011 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 24s
2025-12-23 22:53:46 +06:00
0ac69610f2 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 26s
2025-12-23 22:52:38 +06:00
c4489b58c9 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 23s
2025-12-23 22:50:37 +06:00
c19bef73e0 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Has been cancelled
2025-12-23 22:44:24 +06:00
c573d2277a update
Some checks failed
Deploy / publish-windows (push) Failing after 23s
2025-12-23 22:42:04 +06:00
3006a82a0f update
Some checks failed
Deploy / publish-windows (push) Failing after 19m11s
2025-12-23 22:03:09 +06:00
a68aa78ae1 update
Some checks failed
Deploy / publish-windows (push) Failing after 16m16s
2025-12-23 21:45:21 +06:00
ba9f51bd5e Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 18m59s
2025-12-23 21:21:44 +06:00
89a3eac2b9 Update deploy-client.yml
Some checks failed
Deploy / publish-windows (push) Failing after 18s
2025-12-23 20:55:14 +06:00
01de23a036 Merge pull request 'release test' (#7) from release into master
Reviewed-on: #7
2025-12-23 14:51:49 +00:00
aef93ef821 update 2025-12-23 20:50:13 +06:00
72f46df4d1 update
All checks were successful
Deploy / publish-windows (push) Successful in 19s
2025-12-23 00:59:01 +06:00
50c56e87ff Update Dockerfile.windows
All checks were successful
Deploy / publish-windows (push) Successful in 6m11s
2025-12-23 00:37:41 +06:00
a7d65f0e3c Update deploy-client.yml
Some checks failed
Deploy / publish-windows (push) Failing after 5m37s
2025-12-23 00:27:43 +06:00
649b49a732 update
Some checks failed
Deploy / publish-windows (push) Failing after 9s
2025-12-23 00:27:01 +06:00
b887b69997 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 2m45s
2025-12-23 00:07:40 +06:00
687a2958c0 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 1m52s
2025-12-22 23:35:59 +06:00
a7fe94abec Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 3m13s
2025-12-22 22:40:13 +06:00
adb539350f Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 27s
2025-12-22 22:37:41 +06:00
d870b7c1f1 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 9m31s
2025-12-22 22:27:02 +06:00
edd5a69cd4 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 35s
2025-12-22 22:24:34 +06:00
6343f1de4d Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 2m21s
2025-12-22 22:19:14 +06:00
3f581ea8e9 Update deploy-client.yml
Some checks failed
Deploy / publish-windows (push) Failing after 2m34s
2025-12-22 22:15:37 +06:00
10bfcf0727 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 21s
2025-12-22 22:14:24 +06:00
6b5383ba24 Update Dockerfile.windows
Some checks failed
Deploy / publish-windows (push) Failing after 19s
2025-12-22 22:01:30 +06:00
6b5d669f64 update
Some checks failed
Deploy / publish-windows (push) Failing after 27s
2025-12-22 21:55:12 +06:00
40d66d356b update
Some checks failed
Deploy / publish-tauri (push) Failing after 15s
Deploy / publish-web (push) Successful in 40s
2025-12-22 21:50:33 +06:00
c665c19cf3 update
Some checks failed
Deploy / publish-web (push) Failing after 33s
Deploy / publish-tauri (push) Failing after 50s
2025-12-22 21:47:24 +06:00
b05a7324d6 update
Some checks failed
Deploy / publish-web (push) Successful in 41s
Deploy / publish-tauri (push) Failing after 52s
2025-12-22 21:45:17 +06:00
e8cbf6e146 update
Some checks failed
Deploy / publish-tauri (push) Has been cancelled
Deploy / publish-web (push) Has been cancelled
2025-12-22 21:44:36 +06:00
472fa8d907 update
Some checks failed
Deploy / publish-web (push) Failing after 20s
Deploy / publish-tauri (push) Failing after 18s
2025-12-22 21:40:38 +06:00
ddc43e4b42 update
Some checks failed
Deploy / publish-web (push) Failing after 41s
Deploy / publish-tauri (push) Failing after 25s
2025-12-22 21:36:12 +06:00
8d02eb380d update
Some checks failed
Deploy / publish-web (push) Failing after 13s
Deploy / publish-tauri (push) Failing after 12s
2025-12-22 21:17:29 +06:00
4c70dce568 build
Some checks failed
Deploy / publish-web (push) Failing after 25s
Deploy / publish-tauri (push) Failing after 10s
2025-12-22 21:07:20 +06:00
76f0ec74b5 test publish
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / publish-web (push) Successful in 1m43s
Deploy / publish-tauri (push) Has been cancelled
2025-12-22 19:23:06 +06:00
Никита Круглицкий
e3d0106d8f куча говна
All checks were successful
Deploy / deploy (push) Successful in 44s
2025-10-20 06:44:21 +06:00
Никита Круглицкий
e2068dd89a куча говна
All checks were successful
Deploy / deploy (push) Successful in 45s
2025-10-20 06:14:18 +06:00
Никита Круглицкий
a2f845f228 куча говна
All checks were successful
Deploy / deploy (push) Successful in 45s
2025-10-20 06:01:29 +06:00
Никита Круглицкий
1a497d402d куча говна
All checks were successful
Deploy / deploy (push) Successful in 46s
2025-10-20 05:59:06 +06:00
Никита Круглицкий
924bbd4285 куча говна
All checks were successful
Deploy / deploy (push) Successful in 43s
2025-10-20 05:57:08 +06:00
Никита Круглицкий
58d37ee02b куча говна
All checks were successful
Deploy / deploy (push) Successful in 45s
2025-10-20 05:50:27 +06:00
Никита Круглицкий
ba12d413dc куча говна
All checks were successful
Deploy / deploy (push) Successful in 44s
2025-10-20 03:53:29 +06:00
Никита Круглицкий
f525d1afe5 куча говна
All checks were successful
Deploy / deploy (push) Successful in 45s
2025-10-20 03:36:30 +06:00
Никита Круглицкий
75fe5b0b8c куча говна
All checks were successful
Deploy / deploy (push) Successful in 44s
2025-10-20 03:33:07 +06:00
280 changed files with 5226 additions and 1895 deletions

View File

@@ -1,15 +1,55 @@
name: Deploy name: Deploy
on: on:
workflow_dispatch:
push: push:
branches: tags:
- master - "v[0-9]+.[0-9]+.[0-9]+"
paths:
- '.gitea/workflows/deploy-client.yml' # paths:
- 'client/**' # - ".gitea/workflows/deploy-client.yml"
# - "client/**"
jobs: jobs:
deploy: # publish-windows:
# runs-on: ubuntu-latest
#
# steps:
# - name: Keyscan
# run: |
# ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
#
# - name: Checkout
# uses: actions/checkout@v4
# with:
# ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
# ssh-strict: false
# persist-credentials: false
#
# - name: Build
# run: |
# docker build \
# -t chad-client-windows-builder \
# -f ./client/Dockerfile.windows \
# ./client \
# --build-arg COMMIT_SHA=${{ gitea.sha }} \
# --build-arg API_BASE_URL=${{ vars.API_BASE_URL }} \
# --build-arg TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
#
# docker create --name chad-client-windows-container chad-client-windows-builder
# mkdir -p artifacts
# docker cp chad-client-windows-container:/artifacts artifacts/
# docker rm chad-client-windows-container
# ls -la artifacts
#
# - name: Publish
# uses: akkuman/gitea-release-action@v1
# with:
# files: |
# artifacts/**
# draft: true
publish-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -25,7 +65,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Build - name: Build
run: docker build -t chad-client ./client --build-arg COMMIT_SHA=${{ gitea.sha }} --build-arg API_BASE_URL=${{ vars.API_BASE_URL }} 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 - name: Stop old container
run: docker rm -f chad-client || true run: docker rm -f chad-client || true

2
client/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
/.yarn/releases/** binary
/.yarn/plugins/** binary

3
client/.gitignore vendored
View File

@@ -23,6 +23,7 @@ logs
.env.* .env.*
!.env.example !.env.example
.pnp.*
.yarn/* .yarn/*
!.yarn/patches !.yarn/patches
!.yarn/plugins !.yarn/plugins
@@ -32,3 +33,5 @@ logs
scripts/release.ps1 scripts/release.ps1
.tauri .tauri
updater.json

Binary file not shown.

942
client/.yarn/releases/yarn-4.12.0.cjs vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,3 @@
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.12.0.cjs

View File

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

46
client/Dockerfile.windows Normal file
View File

@@ -0,0 +1,46 @@
# === Build ===
FROM node:lts AS builder
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn install --immutable
COPY . .
ARG COMMIT_SHA=unknown
ARG API_BASE_URL
ARG TAURI_SIGNING_PRIVATE_KEY
ENV COMMIT_SHA=$COMMIT_SHA \
API_BASE_URL=$API_BASE_URL \
TAURI_SIGNING_PRIVATE_KEY=$TAURI_SIGNING_PRIVATE_KEY \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
RUN apt update && apt install -y --no-install-recommends \
nsis \
clang \
lld \
llvm \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH=/root/.cargo/bin:$PATH
RUN rustup target add x86_64-pc-windows-msvc
RUN cargo install --locked cargo-xwin
RUN yarn tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
RUN node scripts/generate-updater.mjs
# === Artifacts ===
FROM scratch AS artifacts
COPY --from=builder /app/updater.json ./artifacts
COPY --from=builder /app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/ ./artifacts
CMD ["true"]

View File

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

View File

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

View File

@@ -13,15 +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']
PrimeFieldset: typeof import('primevue/fieldset')['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']
PrimePassword: typeof import('primevue/password')['default'] PrimePassword: typeof import('primevue/password')['default']
PrimeProgressBar: typeof import('primevue/progressbar')['default']
PrimeScrollPanel: typeof import('primevue/scrollpanel')['default']
PrimeSelect: typeof import('primevue/select')['default']
PrimeSelectButton: typeof import('primevue/selectbutton')['default'] PrimeSelectButton: typeof import('primevue/selectbutton')['default']
PrimeSlider: typeof import('primevue/slider')['default'] PrimeSlider: typeof import('primevue/slider')['default']
PrimeTag: typeof import('primevue/tag')['default']
PrimeToast: typeof import('primevue/toast')['default'] PrimeToast: typeof import('primevue/toast')['default']
PrimeToggleSwitch: typeof import('primevue/toggleswitch')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
export interface ComponentCustomProperties {
Tooltip: typeof import('primevue/tooltip')['default']
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,75 +1,144 @@
import { createGlobalState } from '@vueuse/core' import { getVersion } from '@tauri-apps/api/app'
import { computedAsync, createGlobalState } from '@vueuse/core'
import { useClients } from '~/composables/use-clients' import { useClients } from '~/composables/use-clients'
export const useApp = createGlobalState(() => { export const useApp = createGlobalState(() => {
const { clients } = useClients() const { clients } = useClients()
const mediasoup = useMediasoup() const mediasoup = useMediasoup()
const signaling = useSignaling()
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const inputMuted = ref(false) const ready = ref(false)
const outputMuted = ref(false) const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => {
if (import.meta.dev) {
return 'dev'
}
else if (isTauri.value) {
return getVersion()
}
else {
return 'web'
}
}, '-')
const inputMuted = computed(() => {
return !!mediasoup.micProducer.value?.paused
})
const previousInputMuted = ref(inputMuted.value) const previousInputMuted = ref(inputMuted.value)
function muteInput() { const outputMuted = ref(false)
inputMuted.value = true
const videoEnabled = computed(() => {
return !!mediasoup.videoProducer.value
})
const sharingEnabled = computed(() => {
return !!mediasoup.shareProducer.value
})
const somebodyStreamingVideo = computed(() => {
return !!mediasoup.videoProducer.value
|| !!mediasoup.shareProducer.value
|| mediasoup.videoConsumers.value.length > 0
|| mediasoup.shareConsumers.value.length > 0
})
async function muteInput() {
if (inputMuted.value || !mediasoup.micProducer.value)
return
await mediasoup.pauseProducer(mediasoup.micProducer.value)
sfx.playEvent('mic-off').then()
toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 })
} }
function unmuteInput() { async function unmuteInput() {
inputMuted.value = false if (!inputMuted.value || !mediasoup.micProducer.value)
} return
function toggleInput() {
if (inputMuted.value)
unmuteInput()
else
muteInput()
}
function muteOutput() {
outputMuted.value = true
}
function unmuteOutput() {
outputMuted.value = false
}
function toggleOutput() {
if (outputMuted.value)
unmuteOutput()
else
muteOutput()
}
watch(inputMuted, async (inputMuted) => {
if (inputMuted) {
await mediasoup.muteMic()
}
else {
if (outputMuted.value) { if (outputMuted.value) {
outputMuted.value = false await unmuteOutput()
}
await mediasoup.unmuteMic()
} }
const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated' await mediasoup.resumeProducer(mediasoup.micProducer.value)
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 })
sfx.playEvent('mic-on').then()
toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 })
}
async function toggleInput() {
if (inputMuted.value)
await unmuteInput()
else
await muteInput()
}
async function muteOutput() {
if (outputMuted.value)
return
outputMuted.value = true
previousInputMuted.value = inputMuted.value
await muteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: true,
}) })
watch(outputMuted, (outputMuted) => { toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 })
if (outputMuted) { }
previousInputMuted.value = inputMuted.value
muteInput() async function unmuteOutput() {
outputMuted.value = false
if (!previousInputMuted.value)
await unmuteInput()
await signaling.socket.value?.emitWithAck('updateClient', {
outputMuted: false,
})
toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 })
}
async function toggleOutput() {
if (outputMuted.value)
await unmuteOutput()
else
await muteOutput()
}
async function toggleVideo() {
if (!mediasoup.videoProducer.value) {
await mediasoup.enableVideo()
await sfx.playEvent('stream-on')
} }
else { else {
inputMuted.value = previousInputMuted.value await mediasoup.disableProducer(mediasoup.videoProducer.value)
await sfx.playEvent('stream-off')
}
} }
const toastText = outputMuted ? 'Sound muted' : 'Sound resumed' async function toggleShare() {
toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 }) if (!mediasoup.shareProducer.value) {
}) await mediasoup.enableShare()
await sfx.playEvent('stream-on')
}
else {
await mediasoup.disableProducer(mediasoup.shareProducer.value)
await sfx.playEvent('stream-off')
}
}
return { return {
ready,
clients, clients,
inputMuted, inputMuted,
muteInput, muteInput,
@@ -79,5 +148,13 @@ export const useApp = createGlobalState(() => {
muteOutput, muteOutput,
unmuteOutput, unmuteOutput,
toggleOutput, toggleOutput,
toggleVideo,
version,
isTauri,
commitSha,
toggleShare,
videoEnabled,
sharingEnabled,
somebodyStreamingVideo,
} }
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,19 @@
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' | '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' },
{ urls: 'stun:stun.l.google.com:5349' }, { urls: 'stun:stun.l.google.com:5349' },
@@ -19,23 +29,54 @@ const ICE_SERVERS: RTCIceServer[] = [
export const useMediasoup = createSharedComposable(() => { export const useMediasoup = createSharedComposable(() => {
const toast = useToast() const toast = useToast()
const sfx = useSfx()
const signaling = useSignaling() const signaling = useSignaling()
const { addClient, removeClient } = useClients() const { addClient, removeClient, me } = 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 webcamProducer = 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)
@@ -129,12 +170,16 @@ export const useMediasoup = createSharedComposable(() => {
addClient(...joinedClients) addClient(...joinedClients)
if (me.value)
sfx.playRandomConnectionSound(me.value.socketId).then()
toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 }) toast.add({ severity: 'success', summary: 'Joined', closable: false, life: 1000 })
await enableMic() await enableMic()
}) })
socket.on('newPeer', (client) => { socket.on('newPeer', (client) => {
sfx.playRandomConnectionSound(client.socketId).then()
addClient(client) addClient(client)
}) })
@@ -145,7 +190,7 @@ export const useMediasoup = createSharedComposable(() => {
socket.on( socket.on(
'newConsumer', 'newConsumer',
async ( async (
{ id, producerId, kind, rtpParameters, peerId: clientId, appData }, { id, producerId, kind, rtpParameters, socketId, appData, producerPaused },
cb, cb,
) => { ) => {
if (!recvTransport.value) if (!recvTransport.value)
@@ -156,17 +201,41 @@ export const useMediasoup = createSharedComposable(() => {
producerId, producerId,
kind, kind,
rtpParameters, rtpParameters,
streamId: `${clientId}-${appData.share ? 'share' : 'mic-webcam'}`, streamId: `${socketId}-${appData.source || 'stream'}`,
appData: { ...appData, clientId }, appData: { ...appData, socketId },
}) })
consumer.on('transportclose', () => { if (kind === 'video')
consumers.value.delete(consumer.id) sfx.playEvent('stream-on').then()
triggerRef(consumers)
if (producerPaused)
consumer.pause()
consumers.value[consumer.id] = {
id: consumer.id,
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', () => {
if (kind === 'video')
sfx.playEvent('stream-off').then()
delete consumers.value[consumer.id]
})
consumer.on('trackended', () => {
consumer.close()
})
cb() cb()
}, },
@@ -177,16 +246,37 @@ export const useMediasoup = createSharedComposable(() => {
async ( async (
{ consumerId }, { consumerId },
) => { ) => {
const consumer = consumers.value.get(consumerId) const consumer = consumers.value[consumerId]
if (!consumer) if (!consumer)
return return
consumers.value.delete(consumer.id) consumer.raw.close()
triggerRef(consumers)
}, },
) )
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,130 +287,237 @@ export const useMediasoup = createSharedComposable(() => {
recvTransport.value?.close() recvTransport.value?.close()
recvTransport.value = undefined recvTransport.value = undefined
micProducer.value = undefined consumers.value = {}
webcamProducer.value = undefined producers.value = {}
shareProducer.value = undefined
consumers.value = new Map()
producers.value = new Map()
}) })
}, { immediate: true, flush: 'sync' }) }, { immediate: true, flush: 'sync' })
function getClientConsumers(clientId: ChadClient['socketId']) { async function createProducer(options: ProducerOptions) {
return consumers.value.values().filter(consumer => consumer.appData.clientId === clientId) 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 { async function enableVideo() {
micProducer.value.close() if (videoProducer.value)
return
await signaling.socket.value.emitWithAck('closeProducer', { if (!device.value)
producerId: micProducer.value.id, return
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: preferences.videoDeviceId.value },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
},
})
const track = stream.getVideoTracks()[0]
if (!track)
return
await createProducer({
track,
streamId: 'mic-video',
// codec: device.value.rtpCapabilities.codecs?.find(
// c => c.mimeType.toLowerCase() === 'video/AV1',
// ),
// codecOptions: {
// videoGoogleStartBitrate: 1000,
// },
appData: {
source: 'mic-video',
},
}) })
} }
catch {
async function enableShare() {
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',
},
})
} }
micProducer.value = undefined async function pauseProducer(producer: Producer) {
} if (!signaling.socket.value)
return
async function muteMic() { if (producer.paused)
if (!signaling.socket.value || !micProducer.value)
return return
try { try {
micProducer.value.pause() producer.raw.pause()
await signaling.socket.value.emitWithAck('pauseProducer', { await signaling.socket.value.emitWithAck('pauseProducer', {
producerId: micProducer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.raw.resume()
} }
} }
async function unmuteMic() { async function resumeProducer(producer: Producer) {
if (!signaling.socket.value || !micProducer.value) if (!signaling.socket.value)
return return
try { try {
micProducer.value.resume() producer.raw.resume()
await signaling.socket.value.emitWithAck('resumeProducer', { await signaling.socket.value.emitWithAck('resumeProducer', {
producerId: micProducer.value.id, producerId: producer.id,
}) })
} }
catch { catch {
producer.raw.pause()
} }
} }
async function init() { watch([
signaling.connect() preferences.inputDeviceId,
} preferences.echoCancellation,
preferences.autoGainControl,
preferences.noiseSuppression,
], async ([inputDeviceId]) => {
await disableMic()
if (!inputDeviceId)
return
await enableMic()
})
return { return {
init,
consumers, consumers,
audioConsumers,
videoConsumers,
shareConsumers,
producers, producers,
speakingClients,
sendTransport, sendTransport,
recvTransport, recvTransport,
rtpCapabilities, rtpCapabilities,
device, device,
micProducer, micProducer,
webcamProducer, videoProducer,
shareProducer, shareProducer,
getClientConsumers, pauseProducer,
muteMic, resumeProducer,
unmuteMic, enableVideo,
enableShare,
disableProducer,
} }
}) })

View File

@@ -1,11 +1,75 @@
import { createGlobalState } 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 audioDevice = shallowRef() const { videoInputs, audioInputs, audioOutputs } = useDevices()
const videoDevice = shallowRef()
const synced = ref(false)
const inputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('INPUT_DEVICE_ID', 'default')
const outputDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('OUTPUT_DEVICE_ID', 'default')
const videoDeviceId = useLocalStorage<MediaDeviceInfo['deviceId']>('VIDEO_DEVICE_ID', 'default')
const autoGainControl = useLocalStorage('AUTO_GAIN_CONTROL', false)
const 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 {
audioDevice, synced,
videoDevice, inputDeviceId,
outputDeviceId,
videoDeviceId,
autoGainControl,
noiseSuppression,
echoCancellation,
shareFps,
toggleInputHotkey,
toggleOutputHotkey,
inputDeviceExist,
outputDeviceExist,
videoDeviceExist,
} }
}) })

View File

@@ -0,0 +1,86 @@
import { createSharedComposable } from '@vueuse/core'
import { Howl } from 'howler'
const CONNECTION_SOUNDS = Object.keys(import.meta.glob('@/../public/sfx/connection/*.ogg')).map(path => path.replace('../public', ''))
console.log('CONNECTION_SOUNDS', CONNECTION_SOUNDS)
function hashStringToNumber(str: string, cap: number): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) | 0
}
return Math.abs(hash) % cap
}
const oneShots: Howl[] = []
type SfxEvent = 'mic-on' | 'mic-off' | 'stream-on' | 'stream-off' | 'connection'
const EVENT_VOLUME: Record<SfxEvent, number> = {
'mic-on': 0.2,
'mic-off': 0.2,
'stream-on': 0.03,
'stream-off': 0.03,
'connection': 0.1,
}
export const useSfx = createSharedComposable(() => {
async function play(src: string, volume = 0.2): Promise<void> {
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
howl.on('end', () => {
resolve()
})
})
}
async function playOneShot(src: string, volume = 0.2): Promise<void> {
for (const oneShot of oneShots) {
oneShot.stop()
}
oneShots.length = 0
return new Promise((resolve) => {
const howl = new Howl({
src,
autoplay: true,
loop: false,
volume,
})
oneShots.push(howl)
howl.on('end', () => {
resolve()
})
})
}
async function playEvent(event: SfxEvent) {
switch (event) {
default:
await playOneShot(`/sfx/${event}.ogg`, EVENT_VOLUME[event])
break
}
}
async function playRandomConnectionSound(seed: string) {
await playEvent('stream-on')
await play(CONNECTION_SOUNDS[hashStringToNumber(seed, CONNECTION_SOUNDS.length)]!, 0.1)
}
return {
playOneShot,
play,
playRandomConnectionSound,
playEvent,
}
})

View File

@@ -1,6 +1,7 @@
import type { Socket } from 'socket.io-client' import type { Socket } from 'socket.io-client'
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import { parseURL } from 'ufo'
export const useSignaling = createSharedComposable(() => { export const useSignaling = createSharedComposable(() => {
const toast = useToast() const toast = useToast()
@@ -58,16 +59,19 @@ export const useSignaling = createSharedComposable(() => {
}) })
function connect() { function connect() {
if (socket.value) if (socket.value || !me.value)
return return
socket.value = io('https://api.koptilnya.xyz/webrtc', { const { protocol, host, pathname } = parseURL(__API_BASE_URL__)
// socket.value = io('http://localhost:4000/webrtc', {
path: '/chad/ws', const uri = host ? `${protocol}//${host}` : ``
socket.value = io(`${uri}/webrtc`, {
path: `${pathname}/ws`,
transports: ['websocket'], transports: ['websocket'],
withCredentials: true, withCredentials: true,
auth: { auth: {
userId: me.value!.id, userId: me.value.id,
}, },
}) })
} }

View File

@@ -0,0 +1,28 @@
import type { Update } from '@tauri-apps/plugin-updater'
import { check } from '@tauri-apps/plugin-updater'
import { createGlobalState } from '@vueuse/core'
export const useUpdater = createGlobalState(() => {
const lastUpdate = shallowRef<Update>()
const checking = ref(false)
async function checkForUpdates() {
try {
checking.value = true
lastUpdate.value = (await check()) ?? undefined
}
finally {
checking.value = false
}
return lastUpdate.value
}
return {
lastUpdate,
checking,
checkForUpdates,
}
})

View File

@@ -10,10 +10,13 @@
<slot /> <slot />
</div> </div>
</div> </div>
<PrimeBadge class="fixed top-3 right-3 opacity-50" severity="secondary" :value="version" size="small" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() const route = useRoute()
const { version } = useApp()
const options = computed(() => { const options = computed(() => {
return [ return [
@@ -24,7 +27,6 @@ const options = computed(() => {
{ {
label: 'Register', label: 'Register',
routeName: 'Register', routeName: 'Register',
}, },
] ]
}) })

View File

@@ -1,3 +1,3 @@
<template> <template>
UPDATER <slot />
</template> </template>

View File

@@ -1,47 +1,155 @@
<template> <template>
<div v-auto-animate 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> >
<div class="inline-flex items-center gap-3">
<PrimeBadge class="opacity-50" severity="secondary" :value="version" size="small" />
<PrimeBadge :severity="connected ? 'success' : 'danger' " />
</div>
<PrimeButtonGroup class="ml-auto"> <PrimeButtonGroup class="ml-auto">
<PrimeButton <PrimeButton :severity="inputMuted ? 'info' : undefined" outlined @click="toggleInput">
icon="pi pi-microphone" size="large" :severity="inputMuted ? 'contrast' : 'secondary'" <template #icon>
:outlined="!inputMuted" @click="toggleInput" <Component :is="inputMuted ? MicOff : Mic" />
/> </template>
<PrimeButton </PrimeButton>
icon="pi pi-headphones" size="large" :severity="outputMuted ? 'contrast' : 'secondary'" <PrimeButton :severity="outputMuted ? 'info' : undefined" outlined @click="toggleOutput">
:outlined="!outputMuted" @click="toggleOutput" <template #icon>
/> <Component :is="outputMuted ? VolumeOff : Volume2" />
</template>
</PrimeButton>
</PrimeButtonGroup> </PrimeButtonGroup>
<PrimeButton icon="pi pi-cog" size="large" :text="!inPreferences" :severity="inPreferences ? 'contrast' : 'secondary'" @click="onClickPreferences" /> <PrimeButton :severity="videoEnabled ? 'success' : undefined" outlined @click="toggleVideo">
<template #icon>
<Component :is="videoEnabled ? CameraOff : Camera" />
</template> </template>
</AppHeader> </PrimeButton>
<div v-auto-animate class="p-3 overflow-y-auto flex-1 bg-surface-900 overflow-hidden divide-y divide-surface-800"> <PrimeButton :severity="sharingEnabled ? 'success' : undefined" outlined @click="toggleShare">
<ClientRow v-for="client of clients" :key="client.id" :client="client" /> <template #icon>
</div> <Component :is="sharingEnabled ? ScreenShareOff : ScreenShare" />
</template>
</PrimeButton>
</div> </div>
<div
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>
<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 /> <slot />
</div> </div>
</PrimeScrollPanel>
</div>
<PrimeBadge class="fixed top-3 right-3" severity="success" /> <FullscreenGallery />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput } = useApp() import {
const { connect } = useSignaling() 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()
interface Tab {
id: string
icon: Component
onClick: () => void | Promise<void>
}
const route = useRoute() const route = useRoute()
const inPreferences = computed(() => { const tabs = computed<Tab[]>(() => {
return route.name === 'Preferences' const result = []
if (somebodyStreamingVideo.value) {
result.push({
id: 'Gallery',
icon: TvMinimalPlay,
onClick: () => {
navigateTo({ name: 'Gallery' })
},
})
}
result.push(
{
id: 'Index',
icon: MessageCircle,
onClick: () => {
navigateTo({ name: 'Index' })
},
},
{
id: 'Profile',
icon: UserPen,
onClick: () => {
navigateTo({ name: 'Profile' })
},
},
{
id: 'Preferences',
icon: Settings,
onClick: () => {
navigateTo({ name: 'Preferences' })
},
},
)
return result
}) })
function onClickPreferences() { const activeTab = ref<Tab>(tabs.value.find(tab => tab.id === route.name) ?? tabs.value.find(tab => tab.id === 'Index')!)
navigateTo(!inPreferences.value ? { name: 'Preferences' } : '/')
} watch(activeTab, (activeTab) => {
activeTab.onClick()
})
connect() connect()
</script> </script>

View File

@@ -1,41 +1,20 @@
import { relaunch } from '@tauri-apps/plugin-process'
import { check } from '@tauri-apps/plugin-updater'
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
if (from?.name) if (import.meta.dev || import.meta.server)
return return
const update = await check() const { isTauri } = useApp()
console.log(update) if (!isTauri.value)
if (import.meta.dev)
return return
if (from?.name || !!to.redirectedFrom)
return
const { checkForUpdates } = useUpdater()
const update = await checkForUpdates()
if (update) { if (update) {
console.log( return navigateTo({ name: 'Updater' })
`found update ${update.version} from ${update.date} with notes ${update.body}`,
)
let downloaded = 0
let contentLength = 0
// alternatively we could also call update.download() and update.install() separately
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength ?? 0
console.log(`started downloading ${event.data.contentLength} bytes`)
break
case 'Progress':
downloaded += event.data.chunkLength
console.log(`downloaded ${downloaded} from ${contentLength}`)
break
case 'Finished':
console.log('download finished')
break
}
})
console.log('update installed')
await relaunch()
} }
}) })

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,17 @@
<template> <template>
<div class="flex items-center justify-center p-3"> <div>
<PrimeFieldset legend="Important information"> <div class="flex items-center justify-center">
<PrimeCard>
<template #content>
The chat is under development. The chat is under development.
</PrimeFieldset> </template>
</PrimeCard>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { clients, inputMuted, toggleInput, outputMuted, toggleOutput } = useApp() definePageMeta({
name: 'Index',
})
</script> </script>

View File

@@ -1,54 +1,208 @@
<template> <template>
<div> <div>
<AppHeader title="Preferences" secondary /> <PrimeDivider align="left">
Audio
</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" /> <PrimeSelect
<label for="username">Username</label> 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> </PrimeFloatLabel>
<PrimeButton label="Save" type="submit" :disabled="!valid" /> <div class="flex items-center gap-2 mt-3">
</form> <PrimeToggleSwitch v-model="autoGainControl" input-id="autoGainControl" />
<label for="autoGainControl">Auto Gain Control</label>
<div class="p-3">
<PrimeButton label="Logout" fluid severity="danger" @click="logout" />
</div> </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>
<PrimeFloatLabel variant="on">
<PrimeInputText id="microphoneToggle" :model-value="toggleInputHotkey" fluid @keydown="setupToggleInputHotkey" />
<label for="microphoneToggle">Toggle microphone</label>
</PrimeFloatLabel>
<PrimeFloatLabel variant="on" class="mt-3">
<PrimeInputText id="soundToggle" :model-value="toggleOutputHotkey" fluid @keydown="setupToggleOutputHotkey" />
<label for="soundToggle">Toggle sound</label>
</PrimeFloatLabel>
</template>
<PrimeDivider align="left">
About
</PrimeDivider>
<p v-if="version" class="text-muted-color text-sm">
VERSION: {{ version }}
</p>
<p class="text-muted-color text-sm mt-2">
COMMIT_SHA: {{ commitSha }}
</p>
<template v-if="isTauri">
<PrimeButton
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> </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 { me, setMe, logout } = useAuth() const shareFpsOptions = [5, 30, 60].map((value) => {
return {
const signaling = useSignaling() label: value.toString(),
const toast = useToast() value,
}
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 save() { const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
if (!valid.value) const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
function setupHotkey(event: KeyboardEvent, model: RemovableRef<string>) {
if (event.key === 'Tab' || event.key === 'Enter') {
return return
}
const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { event.preventDefault()
displayName: displayName.value,
})
setMe({ ...me.value, displayName: updatedMe.displayName }) const hotkey = []
toast.add({ severity: 'success', summary: 'Saved', life: 1000, closable: false }) 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
}
if (!['Control', 'Shift', 'Alt'].includes(event.key)) {
hotkey.push(event.key.toUpperCase())
}
if (modifierApplied && hotkey.length === 1) {
model.value = ''
return
}
model.value = hotkey.join('+')
} }
</script> </script>

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<div class="flex h-full">
<div class="w-80 m-auto">
<p class="text-center text-muted-color mb-4">
Updating...
</p>
<PrimeProgressBar mode="indeterminate" style="height: 8px;" />
<div class="flex items-center justify-center gap-2 mt-8">
<PrimeBadge :value="lastUpdate.currentVersion" size="small" severity="secondary" />
<i class="pi pi-arrow-right" style="font-size: 0.75rem;" />
<PrimeBadge :value="lastUpdate.version" size="small" severity="contrast" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({
name: 'Updater',
layout: 'blank',
auth: false,
middleware: () => {
const { lastUpdate } = useUpdater()
if (!lastUpdate.value)
return navigateTo('/')
},
})
const lastUpdate = useUpdater().lastUpdate.value!
;(async () => {
try {
await lastUpdate.downloadAndInstall()
}
catch {
await navigateTo('/')
}
})()
</script>

View File

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

View File

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

1
client/globals.d.ts vendored
View File

@@ -1 +1,2 @@
declare const __API_BASE_URL__: string
declare const __COMMIT_SHA__: string declare const __COMMIT_SHA__: string

View File

@@ -1,11 +0,0 @@
{
"pub_date": "2025-10-19T18:09:51Z",
"version": "0.2.1",
"platforms": {
"windows-x86_64": {
"url": "https://git.koptilnya.xyz/opti1337/chad/releases/download/latest/chad_0.2.1_x64-setup.exe",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUMHdwTUN1SnhESjBiY2xtakN0WW1LTHNyQ1RyQjd2YlVXRUozWHp0K003SlFYbmlreHY2UjF5RjAvdEhZKzBpL0J6NVJ1c09VaUVUa3ZCZmFUL1AxN2lCNW9pVW9MY0FvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzYwODk3MzkwCWZpbGU6Y2hhZF8wLjIuMV94NjQtc2V0dXAuZXhlCmtqcDJtSVQ0d1ZVdXpoYWxnMXVxQVlwLzM5V3BjM2Q4RGQzRXZBUlRFQzhnaDdqdjNTK0h0RW1zcjR5UFE2ZWx2dVppbWpjMlBYdG1ZUGM3NXVqT0F3PT0K"
}
},
"notes": ""
}

View File

@@ -1,3 +1,4 @@
import { definePreset } from '@primeuix/themes'
import Aura from '@primeuix/themes/aura' import Aura from '@primeuix/themes/aura'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@@ -10,10 +11,60 @@ export default defineNuxtConfig({
'@primevue/nuxt-module', '@primevue/nuxt-module',
'@formkit/auto-animate/nuxt', '@formkit/auto-animate/nuxt',
], ],
fonts: {
provider: 'google',
},
primevue: { primevue: {
options: { options: {
theme: { theme: {
preset: Aura, preset: definePreset(Aura, {
semantic: {
transitionDuration: '150ms',
primary: {
50: '{zinc.50}',
100: '{zinc.100}',
200: '{zinc.200}',
300: '{zinc.300}',
400: '{zinc.400}',
500: '{zinc.500}',
600: '{zinc.600}',
700: '{zinc.700}',
800: '{zinc.800}',
900: '{zinc.900}',
950: '{zinc.950}',
},
},
colorScheme: {
light: {
primary: {
color: '{zinc.950}',
inverseColor: '#ffffff',
hoverColor: '{zinc.900}',
activeColor: '{zinc.800}',
},
highlight: {
background: '{zinc.950}',
focusBackground: '{zinc.700}',
color: '#ffffff',
focusColor: '#ffffff',
},
},
dark: {
primary: {
color: '{zinc.50}',
inverseColor: '{zinc.950}',
hoverColor: '{zinc.100}',
activeColor: '{zinc.200}',
},
highlight: {
background: 'rgba(250, 250, 250, .16)',
focusBackground: 'rgba(250, 250, 250, .24)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)',
},
},
},
}),
}, },
}, },
components: { components: {
@@ -25,9 +76,6 @@ export default defineNuxtConfig({
'@/assets/styles/primeicons.css', '@/assets/styles/primeicons.css',
'@/assets/styles/main.scss', '@/assets/styles/main.scss',
], ],
devServer: {
// host: '0',
},
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
@@ -38,14 +86,18 @@ export default defineNuxtConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
// target: 'http://localhost:4000', // target: 'http://localhost:4000/chad',
target: 'https://api.koptilnya.xyz', target: 'https://api.koptilnya.xyz/chad',
ws: true,
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '/chad'), rewrite: (path) => {
return path.replace(/^\/api/, '')
},
}, },
}, },
}, },
define: { define: {
__API_BASE_URL__: JSON.stringify(import.meta.env.API_BASE_URL || 'http://localhost:4000/chad'),
__COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'), __COMMIT_SHA__: JSON.stringify(import.meta.env.COMMIT_SHA || 'local'),
}, },
}, },

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
@@ -14,25 +14,31 @@
"@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",
"nuxt": "^4.1.2", "howler": "^2.2.4",
"lucide-vue-next": "^0.562.0",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.2.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.4.0", "primevue": "^4.4.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tailwindcss-primeui": "^0.6.1", "tailwindcss-primeui": "^0.6.1",
"ufo": "^1.6.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"packageManager": "yarn@4.10.3", "packageManager": "yarn@4.12.0",
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^5.4.1", "@antfu/eslint-config": "^5.4.1",
"@primevue/nuxt-module": "^4.4.0", "@primevue/nuxt-module": "^4.4.0",
"@tauri-apps/cli": "^2.8.4", "@tauri-apps/cli": "^2.8.4",
"@types/howler": "^2",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-format": "^1.0.2", "eslint-plugin-format": "^1.0.2",
"sass-embedded": "^1.93.2", "sass-embedded": "^1.93.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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