Compare commits
23 Commits
v0.3.0-rc.
...
livekit
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f36d06488 | |||
| 95931c6976 | |||
| 8c883d5481 | |||
| fa1985c99c | |||
| 5c80277851 | |||
| e862703c6c | |||
| 81fbe447fe | |||
| f0abaaff6a | |||
| 9a71f7c903 | |||
| 87a1f348be | |||
| ecb1cbbb91 | |||
| c3bb544c6a | |||
| a4ed795769 | |||
| 047fce207f | |||
| 8410234a4e | |||
| f76543fe0c | |||
| b9693be5de | |||
| 78135a4b36 | |||
| dfb9941b86 | |||
| b8c5f68972 | |||
| 564707f4d6 | |||
| 5f72ad430e | |||
| 8cc60e1849 |
@@ -17,6 +17,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Set up secret file
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" | sed 's/./& /g'
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
Binary file not shown.
@@ -4,6 +4,7 @@
|
|||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|
||||||
<PrimeToast position="bottom-center" />
|
<PrimeToast position="bottom-center" />
|
||||||
|
<PrimeDynamicDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
2
client/app/components.d.ts
vendored
2
client/app/components.d.ts
vendored
@@ -14,7 +14,9 @@ declare module 'vue' {
|
|||||||
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']
|
PrimeDivider: typeof import('primevue/divider')['default']
|
||||||
|
PrimeDynamicDialog: typeof import('primevue/dynamicdialog')['default']
|
||||||
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
|
PrimeFloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
|
PrimeIftaLabel: typeof import('primevue/iftalabel')['default']
|
||||||
PrimeInputGroup: typeof import('primevue/inputgroup')['default']
|
PrimeInputGroup: typeof import('primevue/inputgroup')['default']
|
||||||
PrimeInputText: typeof import('primevue/inputtext')['default']
|
PrimeInputText: typeof import('primevue/inputtext')['default']
|
||||||
PrimePassword: typeof import('primevue/password')['default']
|
PrimePassword: typeof import('primevue/password')['default']
|
||||||
|
|||||||
139
client/app/components/ShareScreenDialog.vue
Normal file
139
client/app/components/ShareScreenDialog.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PrimeFloatLabel class="mb-1">
|
||||||
|
Resolution
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
<PrimeSelectButton
|
||||||
|
v-model="resolution"
|
||||||
|
:options="resolutionOptions"
|
||||||
|
fluid
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
:allow-empty="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<PrimeFloatLabel class="mb-1">
|
||||||
|
FPS
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
<PrimeSelectButton
|
||||||
|
v-model="fps"
|
||||||
|
:options="fpsOptions"
|
||||||
|
fluid
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
:allow-empty="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<PrimeFloatLabel class="mb-1">
|
||||||
|
Codec
|
||||||
|
</PrimeFloatLabel>
|
||||||
|
<PrimeSelect
|
||||||
|
v-model="codecPayloadType"
|
||||||
|
:options="videoCodecs"
|
||||||
|
fluid
|
||||||
|
option-label="mimeType"
|
||||||
|
option-value="preferredPayloadType"
|
||||||
|
placeholder="Select codec"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="w-full whitespace-normal">
|
||||||
|
<p>{{ option.mimeType }}</p>
|
||||||
|
<p class="text-muted-color text-xs break-words">
|
||||||
|
{{ option.parameters }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PrimeSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right mt-8">
|
||||||
|
<PrimeButton severity="success" @click="share">
|
||||||
|
Share screen
|
||||||
|
</PrimeButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RtpCodecCapability } from 'mediasoup-client/types'
|
||||||
|
import { StorageSerializers, useLocalStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
export interface ShareSettings {
|
||||||
|
resolution: number
|
||||||
|
fps: number
|
||||||
|
codec?: RtpCodecCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
share: [ShareSettings]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mediasoup = useMediasoup()
|
||||||
|
|
||||||
|
const resolutionOptions = [
|
||||||
|
{
|
||||||
|
label: '720p',
|
||||||
|
value: 720,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '1080p',
|
||||||
|
value: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '1440p',
|
||||||
|
value: 1440,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const fpsOptions = [5, 30, 60].map((value) => {
|
||||||
|
return {
|
||||||
|
label: value.toString(),
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoCodecs = computed(() => {
|
||||||
|
return (mediasoup.rtpCapabilities.value?.codecs ?? []).filter((codec) => {
|
||||||
|
return codec.kind === 'video' && codec.mimeType !== 'video/rtx'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const fps = useLocalStorage<number>(
|
||||||
|
'SHARE_FPS',
|
||||||
|
30,
|
||||||
|
{
|
||||||
|
serializer: StorageSerializers.number,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolution = useLocalStorage<number>(
|
||||||
|
'SHARE_RESOLUTION',
|
||||||
|
1080,
|
||||||
|
{
|
||||||
|
serializer: StorageSerializers.number,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const codecPayloadType = useLocalStorage<number>(
|
||||||
|
'SHARE_CODEC_PAYLOAD_TYPE',
|
||||||
|
(videoCodecs.value.find(codec => codec.mimeType === 'video/H264') ?? videoCodecs.value[0])?.preferredPayloadType ?? -1,
|
||||||
|
{
|
||||||
|
serializer: StorageSerializers.number,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function share() {
|
||||||
|
emit('share', {
|
||||||
|
resolution: resolution.value,
|
||||||
|
fps: fps.value,
|
||||||
|
codec: videoCodecs.value.find(codec => codec.preferredPayloadType === codecPayloadType.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ShareSettings } from '~/components/ShareScreenDialog.vue'
|
||||||
|
import { ShareScreenDialog } from '#components'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { computedAsync, createGlobalState } from '@vueuse/core'
|
import { computedAsync, createGlobalState } from '@vueuse/core'
|
||||||
@@ -8,6 +10,7 @@ export const useApp = createGlobalState(() => {
|
|||||||
const mediasoup = useMediasoup()
|
const mediasoup = useMediasoup()
|
||||||
const signaling = useSignaling()
|
const signaling = useSignaling()
|
||||||
const { emit } = useEventBus()
|
const { emit } = useEventBus()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
|
const isTauri = computed(() => '__TAURI_INTERNALS__' in window)
|
||||||
@@ -125,8 +128,25 @@ export const useApp = createGlobalState(() => {
|
|||||||
|
|
||||||
async function toggleShare() {
|
async function toggleShare() {
|
||||||
if (!mediasoup.shareProducer.value) {
|
if (!mediasoup.shareProducer.value) {
|
||||||
await mediasoup.enableShare()
|
const { close } = dialog.open(ShareScreenDialog, {
|
||||||
emit('share:enabled')
|
props: {
|
||||||
|
header: 'Share settings',
|
||||||
|
modal: true,
|
||||||
|
draggable: false,
|
||||||
|
closable: false,
|
||||||
|
dismissableMask: true,
|
||||||
|
style: {
|
||||||
|
width: '440px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
onShare: async (settings: ShareSettings) => {
|
||||||
|
await mediasoup.enableShare(settings)
|
||||||
|
emit('share:enabled')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await mediasoup.disableProducer(mediasoup.shareProducer.value)
|
await mediasoup.disableProducer(mediasoup.shareProducer.value)
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ export const useDevices = createGlobalState(() => {
|
|||||||
audioOutputs,
|
audioOutputs,
|
||||||
} = useDevicesList()
|
} = useDevicesList()
|
||||||
|
|
||||||
async function getShareStream(fps = 30) {
|
async function getShareStream(fps = 30, resolution = 1080) {
|
||||||
return navigator.mediaDevices.getDisplayMedia({
|
return navigator.mediaDevices.getDisplayMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: {
|
video: {
|
||||||
|
height: { max: resolution },
|
||||||
displaySurface: 'monitor',
|
displaySurface: 'monitor',
|
||||||
frameRate: { max: fps },
|
frameRate: { ideal: fps, max: fps },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChadClient, Consumer, Producer } from '#shared/types'
|
import type { ChadClient, Consumer, Producer } from '#shared/types'
|
||||||
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
|
import type { MediaKind, ProducerOptions } from 'mediasoup-client/types'
|
||||||
|
import type { ShareSettings } from '~/components/ShareScreenDialog.vue'
|
||||||
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 { shallowRef } from 'vue'
|
||||||
@@ -13,6 +14,7 @@ interface SpeakingClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ICE_SERVERS: RTCIceServer[] = [
|
const ICE_SERVERS: RTCIceServer[] = [
|
||||||
|
{ urls: 'stun:stunserver2025.stunprotocol.org:3478' },
|
||||||
{ 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' },
|
||||||
{ urls: 'stun:stun1.l.google.com:3478' },
|
{ urls: 'stun:stun1.l.google.com:3478' },
|
||||||
@@ -84,9 +86,11 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
device.value = new mediasoupClient.Device()
|
device.value = new mediasoupClient.Device()
|
||||||
rtpCapabilities.value = await signaling.socket.value.emitWithAck('getRtpCapabilities')
|
const routerRtpCapabilities = await signaling.socket.value.emitWithAck('getRtpCapabilities')
|
||||||
|
|
||||||
await device.value.load({ routerRtpCapabilities: rtpCapabilities.value! })
|
await device.value.load({ routerRtpCapabilities })
|
||||||
|
|
||||||
|
rtpCapabilities.value = device.value.rtpCapabilities
|
||||||
|
|
||||||
// Send transport
|
// Send transport
|
||||||
{
|
{
|
||||||
@@ -343,6 +347,8 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
producer.on('trackended', () => {
|
producer.on('trackended', () => {
|
||||||
disableProducer(producers.value[producer.id]!)
|
disableProducer(producers.value[producer.id]!)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return producer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disableProducer(producer: Producer) {
|
async function disableProducer(producer: Producer) {
|
||||||
@@ -381,13 +387,17 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
if (!track)
|
if (!track)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
track.contentHint = 'speech'
|
||||||
|
|
||||||
await createProducer({
|
await createProducer({
|
||||||
track,
|
track,
|
||||||
streamId: 'mic-video',
|
streamId: 'mic-video',
|
||||||
codecOptions: {
|
codecOptions: {
|
||||||
opusStereo: true,
|
opusStereo: true,
|
||||||
opusDtx: true, // Меньше пакетов летит когда тишина
|
opusMaxPlaybackRate: 48000,
|
||||||
opusFec: false, // Фиксит пакет лос
|
opusMaxAverageBitrate: 192000,
|
||||||
|
opusDtx: false,
|
||||||
|
opusFec: false,
|
||||||
},
|
},
|
||||||
appData: {
|
appData: {
|
||||||
source: 'mic-video',
|
source: 'mic-video',
|
||||||
@@ -423,6 +433,8 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
if (!track)
|
if (!track)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
track.contentHint = 'detail'
|
||||||
|
|
||||||
await createProducer({
|
await createProducer({
|
||||||
track,
|
track,
|
||||||
streamId: 'mic-video',
|
streamId: 'mic-video',
|
||||||
@@ -438,28 +450,38 @@ export const useMediasoup = createSharedComposable(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableShare() {
|
async function enableShare(settings: ShareSettings) {
|
||||||
if (shareProducer.value)
|
if (shareProducer.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!device.value)
|
if (!device.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
const stream = await getShareStream(preferences.shareFps.value)
|
const stream = await getShareStream(settings.fps)
|
||||||
|
|
||||||
const track = stream.getVideoTracks()[0]
|
const track = stream.getVideoTracks()[0]
|
||||||
|
|
||||||
if (!track)
|
if (!track)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
track.contentHint = 'motion'
|
||||||
|
|
||||||
await createProducer({
|
await createProducer({
|
||||||
track,
|
track,
|
||||||
streamId: 'share',
|
streamId: 'share',
|
||||||
codec: device.value.rtpCapabilities.codecs?.find(
|
codec: settings.codec,
|
||||||
c => c.mimeType.toLowerCase() === 'video/AV1',
|
encodings: [
|
||||||
),
|
{
|
||||||
|
// maxBitrate: 120_000_000,
|
||||||
|
maxFramerate: settings.fps,
|
||||||
|
scalabilityMode: 'L1T1',
|
||||||
|
networkPriority: 'high',
|
||||||
|
},
|
||||||
|
],
|
||||||
codecOptions: {
|
codecOptions: {
|
||||||
videoGoogleStartBitrate: 1000,
|
// videoGoogleStartBitrate: 8000,
|
||||||
|
// videoGoogleMaxBitrate: 120000,
|
||||||
|
// videoGoogleMinBitrate: 2000,
|
||||||
},
|
},
|
||||||
zeroRtpOnPause: true,
|
zeroRtpOnPause: true,
|
||||||
appData: {
|
appData: {
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export const usePreferences = createGlobalState(() => {
|
|||||||
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
|
const noiseSuppression = useLocalStorage('NOISE_SUPPRESSION', true)
|
||||||
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
|
const echoCancellation = useLocalStorage('ECHO_CANCELLATION', true)
|
||||||
|
|
||||||
const shareFps = useLocalStorage('SHARE_FPS', 30)
|
|
||||||
|
|
||||||
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
|
const toggleInputHotkey = ref<SyncedPreferences['toggleInputHotkey']>('')
|
||||||
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
|
const toggleOutputHotkey = ref<SyncedPreferences['toggleOutputHotkey']>('')
|
||||||
|
|
||||||
@@ -65,7 +63,6 @@ export const usePreferences = createGlobalState(() => {
|
|||||||
autoGainControl,
|
autoGainControl,
|
||||||
noiseSuppression,
|
noiseSuppression,
|
||||||
echoCancellation,
|
echoCancellation,
|
||||||
shareFps,
|
|
||||||
toggleInputHotkey,
|
toggleInputHotkey,
|
||||||
toggleOutputHotkey,
|
toggleOutputHotkey,
|
||||||
inputDeviceExist,
|
inputDeviceExist,
|
||||||
|
|||||||
@@ -66,24 +66,6 @@
|
|||||||
<label for="inputDevice">Input device</label>
|
<label for="inputDevice">Input device</label>
|
||||||
</PrimeFloatLabel>
|
</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">
|
<template v-if="isTauri">
|
||||||
<PrimeDivider align="left">
|
<PrimeDivider align="left">
|
||||||
Hotkeys
|
Hotkeys
|
||||||
@@ -156,16 +138,8 @@ const {
|
|||||||
inputDeviceExist,
|
inputDeviceExist,
|
||||||
outputDeviceExist,
|
outputDeviceExist,
|
||||||
videoDeviceExist,
|
videoDeviceExist,
|
||||||
shareFps,
|
|
||||||
} = usePreferences()
|
} = usePreferences()
|
||||||
|
|
||||||
const shareFpsOptions = [5, 30, 60].map((value) => {
|
|
||||||
return {
|
|
||||||
label: value.toString(),
|
|
||||||
value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
|
const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey)
|
||||||
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
|
const setupToggleOutputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleOutputHotkey)
|
||||||
|
|
||||||
|
|||||||
@@ -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.3.0-rc.2",
|
"version": "0.3.0-rc.8",
|
||||||
"identifier": "xyz.koptilnya.chad",
|
"identifier": "xyz.koptilnya.chad",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../.output/public",
|
"frontendDist": "../.output/public",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
|
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
|
||||||
import { Lucia } from 'lucia'
|
import { Lucia, TimeSpan } from 'lucia'
|
||||||
import prisma from '../prisma/client.ts'
|
import prisma from '../prisma/client.ts'
|
||||||
|
|
||||||
declare module 'lucia' {
|
declare module 'lucia' {
|
||||||
interface Register {
|
interface Register {
|
||||||
Lucia: typeof Lucia
|
Lucia: typeof auth
|
||||||
UserId: string
|
UserId: string
|
||||||
DatabaseUserAttributes: DatabaseUserAttributes
|
DatabaseUserAttributes: DatabaseUserAttributes
|
||||||
|
DatabaseSessionAttributes: DatabaseSessionAttributes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +17,14 @@ interface DatabaseUserAttributes {
|
|||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DatabaseSessionAttributes {
|
||||||
|
livekitToken: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_EXPIRES_IN = new TimeSpan(30, 'd')
|
||||||
|
|
||||||
export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
|
export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
|
||||||
|
sessionExpiresIn: SESSION_EXPIRES_IN,
|
||||||
sessionCookie: {
|
sessionCookie: {
|
||||||
attributes: {
|
attributes: {
|
||||||
sameSite: 'none',
|
sameSite: 'none',
|
||||||
@@ -29,6 +37,11 @@ export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
|
|||||||
username,
|
username,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getSessionAttributes: ({ livekitToken }) => {
|
||||||
|
return {
|
||||||
|
livekitToken,
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Auth = typeof auth
|
export type Auth = typeof auth
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fastify": "^5.6.1",
|
"fastify": "^5.6.1",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"livekit-server-sdk": "^2.18.0",
|
||||||
"lucia": "^3.2.2",
|
"lucia": "^3.2.2",
|
||||||
"mediasoup": "^3.19.3",
|
"mediasoup": "^3.19.3",
|
||||||
"prisma": "^6.17.0",
|
"prisma": "^6.17.0",
|
||||||
|
|||||||
@@ -23,25 +23,36 @@ export const autoConfig: mediasoup.types.RouterOptions = {
|
|||||||
mimeType: 'audio/opus',
|
mimeType: 'audio/opus',
|
||||||
clockRate: 48000,
|
clockRate: 48000,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
parameters: { useinbandfec: 1, stereo: 1 },
|
parameters: { useinbandfec: 0, stereo: 1 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
mimeType: 'video/VP8',
|
mimeType: 'video/AV1',
|
||||||
clockRate: 90000,
|
clockRate: 90000,
|
||||||
parameters: {
|
parameters: {
|
||||||
'x-google-start-bitrate': 1000,
|
'level-idx': 13, // Level 4.1 — 1080p60
|
||||||
|
'profile': 0, // Main Profile
|
||||||
|
'tier': 0, // Main tier (0) vs High tier (1)
|
||||||
|
'x-google-start-bitrate': 8000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
mimeType: 'video/VP9',
|
mimeType: 'video/AV1',
|
||||||
clockRate: 90000,
|
clockRate: 90000,
|
||||||
parameters: {
|
parameters: {},
|
||||||
'profile-id': 2,
|
|
||||||
'x-google-start-bitrate': 1000,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// kind: 'video',
|
||||||
|
// mimeType: 'video/h264',
|
||||||
|
// clockRate: 90000,
|
||||||
|
// parameters: {
|
||||||
|
// 'packetization-mode': 1,
|
||||||
|
// 'profile-level-id': '640032',
|
||||||
|
// 'level-asymmetry-allowed': 1,
|
||||||
|
// 'x-google-start-bitrate': 12000,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
mimeType: 'video/h264',
|
mimeType: 'video/h264',
|
||||||
@@ -50,7 +61,7 @@ export const autoConfig: mediasoup.types.RouterOptions = {
|
|||||||
'packetization-mode': 1,
|
'packetization-mode': 1,
|
||||||
'profile-level-id': '4d0032',
|
'profile-level-id': '4d0032',
|
||||||
'level-asymmetry-allowed': 1,
|
'level-asymmetry-allowed': 1,
|
||||||
'x-google-start-bitrate': 1000,
|
'x-google-start-bitrate': 8000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,14 +72,25 @@ export const autoConfig: mediasoup.types.RouterOptions = {
|
|||||||
'packetization-mode': 1,
|
'packetization-mode': 1,
|
||||||
'profile-level-id': '42e01f',
|
'profile-level-id': '42e01f',
|
||||||
'level-asymmetry-allowed': 1,
|
'level-asymmetry-allowed': 1,
|
||||||
'x-google-start-bitrate': 1000,
|
'x-google-start-bitrate': 8000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
mimeType: 'video/AV1',
|
mimeType: 'video/VP9',
|
||||||
clockRate: 90000,
|
clockRate: 90000,
|
||||||
parameters: {},
|
parameters: {
|
||||||
|
'profile-id': 0,
|
||||||
|
'x-google-start-bitrate': 12000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'video',
|
||||||
|
mimeType: 'video/VP8',
|
||||||
|
clockRate: 90000,
|
||||||
|
parameters: {
|
||||||
|
'x-google-start-bitrate': 2000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Session" ADD COLUMN "livekitToken" TEXT;
|
||||||
@@ -9,32 +9,33 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String
|
||||||
displayName String
|
displayName String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
Session Session[]
|
Session Session[]
|
||||||
UserPreferences UserPreferences?
|
UserPreferences UserPreferences?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
userId String
|
userId String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
livekitToken String?
|
||||||
|
|
||||||
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
userId String @id
|
userId String @id
|
||||||
toggleInputHotkey String? @default("")
|
toggleInputHotkey String? @default("")
|
||||||
toggleOutputHotkey String? @default("")
|
toggleOutputHotkey String? @default("")
|
||||||
volumes Json? @default("{}")
|
volumes Json? @default("{}")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { auth } from '../auth/lucia.ts'
|
import { auth } from '../auth/lucia.ts'
|
||||||
import prisma from '../prisma/client.ts'
|
import prisma from '../prisma/client.ts'
|
||||||
|
import { createSessionLivekitToken, getSessionLivekitToken } from '../utils/livekit.ts'
|
||||||
|
|
||||||
export default function (fastify: FastifyInstance) {
|
export default function (fastify: FastifyInstance) {
|
||||||
fastify.post('/register', async (req, reply) => {
|
fastify.post('/register', async (req, reply) => {
|
||||||
@@ -22,7 +23,9 @@ export default function (fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await auth.createSession(user.id, {})
|
const session = await auth.createSession(user.id, {
|
||||||
|
livekitToken: await createSessionLivekitToken(user),
|
||||||
|
})
|
||||||
const cookie = auth.createSessionCookie(session.id)
|
const cookie = auth.createSessionCookie(session.id)
|
||||||
|
|
||||||
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
|
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
|
||||||
@@ -67,7 +70,9 @@ export default function (fastify: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: 'Incorrect username or password' })
|
return reply.code(404).send({ error: 'Incorrect username or password' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth.createSession(user.id, {})
|
const session = await auth.createSession(user.id, {
|
||||||
|
livekitToken: await createSessionLivekitToken(user),
|
||||||
|
})
|
||||||
const cookie = auth.createSessionCookie(session.id)
|
const cookie = auth.createSessionCookie(session.id)
|
||||||
|
|
||||||
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
|
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
|
||||||
@@ -92,11 +97,22 @@ export default function (fastify: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
fastify.get('/me', async (req, reply) => {
|
fastify.get('/me', async (req, reply) => {
|
||||||
if (req.user) {
|
if (!req.user || !req.session) {
|
||||||
return req.user
|
reply.code(401).send(false)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.code(401).send(false)
|
try {
|
||||||
|
return {
|
||||||
|
...req.user,
|
||||||
|
livekitToken: await getSessionLivekitToken(req.user, req.session),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
fastify.log.error(err)
|
||||||
|
reply.code(500).send({ error: err.message })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.post('/logout', async (req, reply) => {
|
fastify.post('/logout', async (req, reply) => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable perfectionist/sort-imports -- .env must be loaded before any import that reads process.env */
|
||||||
|
import 'dotenv/config'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import FastifyAutoLoad from '@fastify/autoload'
|
import FastifyAutoLoad from '@fastify/autoload'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { types } from 'mediasoup'
|
import type { types } from 'mediasoup'
|
||||||
|
import type { Transport } from 'mediasoup/types'
|
||||||
import type { Server as SocketServer } from 'socket.io'
|
import type { Server as SocketServer } from 'socket.io'
|
||||||
import type {
|
import type {
|
||||||
ChadClient,
|
ChadClient,
|
||||||
@@ -112,6 +113,8 @@ export default async function (io: SocketServer, router: types.Router) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
initialAvailableOutgoingBitrate: 8_000_000,
|
||||||
|
maxSctpMessageSize: 262144,
|
||||||
enableUdp: true,
|
enableUdp: true,
|
||||||
preferUdp: true,
|
preferUdp: true,
|
||||||
appData: {
|
appData: {
|
||||||
@@ -194,7 +197,7 @@ export default async function (io: SocketServer, router: types.Router) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const producer = await transport.produce({ kind, rtpParameters, appData: { ...appData, socketId: socket.id } })
|
const producer = await (transport as Transport).produce({ kind, rtpParameters, appData: { ...appData, socketId: socket.id } })
|
||||||
|
|
||||||
socket.data.producers.set(producer.id, producer)
|
socket.data.producers.set(producer.id, producer)
|
||||||
|
|
||||||
@@ -402,7 +405,6 @@ export default async function (io: SocketServer, router: types.Router) {
|
|||||||
{
|
{
|
||||||
producerId: producer.id,
|
producerId: producer.id,
|
||||||
rtpCapabilities: consumerSocket.data.rtpCapabilities,
|
rtpCapabilities: consumerSocket.data.rtpCapabilities,
|
||||||
// Enable NACK for OPUS.
|
|
||||||
enableRtx: true,
|
enableRtx: true,
|
||||||
paused: true,
|
paused: true,
|
||||||
ignoreDtx: true,
|
ignoreDtx: true,
|
||||||
|
|||||||
78
server/utils/livekit.ts
Normal file
78
server/utils/livekit.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Session, User } from 'lucia'
|
||||||
|
import { Buffer } from 'node:buffer'
|
||||||
|
import { AccessToken } from 'livekit-server-sdk'
|
||||||
|
import { SESSION_EXPIRES_IN } from '../auth/lucia.ts'
|
||||||
|
import prisma from '../prisma/client.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issue the token slightly before it actually expires, so a client that
|
||||||
|
* caches the /me response for a while still gets a usable token.
|
||||||
|
*/
|
||||||
|
const REFRESH_BEFORE_EXPIRY_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
export async function createLivekitToken(user: User, ttlSeconds: number) {
|
||||||
|
const apiKey = process.env.LIVEKIT_API_KEY
|
||||||
|
const apiSecret = process.env.LIVEKIT_API_SECRET
|
||||||
|
|
||||||
|
if (!apiKey || !apiSecret)
|
||||||
|
throw new Error('LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set')
|
||||||
|
|
||||||
|
const token = new AccessToken(apiKey, apiSecret, {
|
||||||
|
identity: user.id,
|
||||||
|
name: user.displayName,
|
||||||
|
ttl: ttlSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
token.addGrant({
|
||||||
|
room: process.env.LIVEKIT_ROOM || 'chad',
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true,
|
||||||
|
canUpdateOwnMetadata: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return token.toJwt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mints the token that gets stored on a session at the moment it is created. */
|
||||||
|
export function createSessionLivekitToken(user: User) {
|
||||||
|
return createLivekitToken(user, SESSION_EXPIRES_IN.seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the token stored alongside the session, re-issuing and persisting it
|
||||||
|
* when it is missing (session predates the column) or about to expire (Lucia
|
||||||
|
* extends `expiresAt` on active sessions, outliving the original token).
|
||||||
|
*/
|
||||||
|
export async function getSessionLivekitToken(user: User, session: Session) {
|
||||||
|
if (session.livekitToken && !isExpiringSoon(session.livekitToken))
|
||||||
|
return session.livekitToken
|
||||||
|
|
||||||
|
const ttlSeconds = Math.max(
|
||||||
|
Math.ceil((session.expiresAt.getTime() - Date.now()) / 1000),
|
||||||
|
REFRESH_BEFORE_EXPIRY_SECONDS,
|
||||||
|
)
|
||||||
|
const livekitToken = await createLivekitToken(user, ttlSeconds)
|
||||||
|
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { livekitToken },
|
||||||
|
})
|
||||||
|
|
||||||
|
session.livekitToken = livekitToken
|
||||||
|
|
||||||
|
return livekitToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpiringSoon(token: string) {
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split('.')
|
||||||
|
const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString())
|
||||||
|
|
||||||
|
return typeof exp !== 'number' || exp - REFRESH_BEFORE_EXPIRY_SECONDS <= Date.now() / 1000
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@bufbuild/protobuf@npm:^1.10.0, @bufbuild/protobuf@npm:^1.10.1":
|
||||||
|
version: 1.10.1
|
||||||
|
resolution: "@bufbuild/protobuf@npm:1.10.1"
|
||||||
|
checksum: 10c0/a89572ae99aa193dd232fca0cdc9ece1dfe2f3d8b061be1f966a4f88fb63410aeb0fe7de927037e970aefcb52036eec58a7f89a40fe1286eed1448ea1bd2634e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@clack/core@npm:0.5.0":
|
"@clack/core@npm:0.5.0":
|
||||||
version: 0.5.0
|
version: 0.5.0
|
||||||
resolution: "@clack/core@npm:0.5.0"
|
resolution: "@clack/core@npm:0.5.0"
|
||||||
@@ -498,6 +505,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@livekit/protocol@npm:1.48.0":
|
||||||
|
version: 1.48.0
|
||||||
|
resolution: "@livekit/protocol@npm:1.48.0"
|
||||||
|
dependencies:
|
||||||
|
"@bufbuild/protobuf": "npm:^1.10.0"
|
||||||
|
checksum: 10c0/665ada6b38578f53bc2854d709f05a642cfdad8c671571bd02dc0c6d1b94028d02ce5e7dd86862a2260d6fc0647d333811ce3b85127ba40e37ef76d426547d48
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@lucia-auth/adapter-prisma@npm:^4.0.1":
|
"@lucia-auth/adapter-prisma@npm:^4.0.1":
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
resolution: "@lucia-auth/adapter-prisma@npm:4.0.1"
|
resolution: "@lucia-auth/adapter-prisma@npm:4.0.1"
|
||||||
@@ -2747,6 +2763,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"jose@npm:^5.1.2":
|
||||||
|
version: 5.10.0
|
||||||
|
resolution: "jose@npm:5.10.0"
|
||||||
|
checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"js-yaml@npm:^4.1.0":
|
"js-yaml@npm:^4.1.0":
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
resolution: "js-yaml@npm:4.1.0"
|
resolution: "js-yaml@npm:4.1.0"
|
||||||
@@ -2876,6 +2899,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"livekit-server-sdk@npm:^2.18.0":
|
||||||
|
version: 2.18.0
|
||||||
|
resolution: "livekit-server-sdk@npm:2.18.0"
|
||||||
|
dependencies:
|
||||||
|
"@bufbuild/protobuf": "npm:^1.10.1"
|
||||||
|
"@livekit/protocol": "npm:1.48.0"
|
||||||
|
jose: "npm:^5.1.2"
|
||||||
|
checksum: 10c0/5150170be1302bbbcde32f558eb6104a93a6a56fc656b776db50db7eb0a35285e54c28630d993ac809ecb025626d4c7c989721bebc0d0562640b58edcdd72abe
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"local-pkg@npm:^1.1.2":
|
"local-pkg@npm:^1.1.2":
|
||||||
version: 1.1.2
|
version: 1.1.2
|
||||||
resolution: "local-pkg@npm:1.1.2"
|
resolution: "local-pkg@npm:1.1.2"
|
||||||
@@ -4303,6 +4337,7 @@ __metadata:
|
|||||||
eslint: "npm:^9.36.0"
|
eslint: "npm:^9.36.0"
|
||||||
fastify: "npm:^5.6.1"
|
fastify: "npm:^5.6.1"
|
||||||
fastify-plugin: "npm:^5.1.0"
|
fastify-plugin: "npm:^5.1.0"
|
||||||
|
livekit-server-sdk: "npm:^2.18.0"
|
||||||
lucia: "npm:^3.2.2"
|
lucia: "npm:^3.2.2"
|
||||||
mediasoup: "npm:^3.19.3"
|
mediasoup: "npm:^3.19.3"
|
||||||
prisma: "npm:^6.17.0"
|
prisma: "npm:^6.17.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user