Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80fdf3faf2 | |||
| 8d2df4001a | |||
| d610c0dff3 | |||
| bad054fa8c | |||
| 3b398ca3fd | |||
| 3c2c1a04db | |||
| cb30048b27 | |||
| 945279d504 | |||
| 13c9436dc3 |
@@ -82,7 +82,6 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -187,7 +186,6 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
@@ -85,13 +85,6 @@ clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
|
||||
Generated
+11
-12
@@ -13,7 +13,6 @@
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
@@ -26,7 +25,7 @@
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.5",
|
||||
"livekit-client": "2.15.2",
|
||||
"posthog-js": "1.256.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
@@ -3379,12 +3378,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/overlays": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
|
||||
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
|
||||
"version": "3.8.16",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
|
||||
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-types/shared": "^3.31.0"
|
||||
"@react-types/shared": "^3.30.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -3440,9 +3439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
|
||||
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -7460,9 +7459,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.15.5",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
|
||||
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
|
||||
"version": "2.15.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
|
||||
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
@@ -31,7 +30,7 @@
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.5",
|
||||
"livekit-client": "2.15.2",
|
||||
"posthog-js": "1.256.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
|
||||
@@ -98,7 +98,6 @@ export const MainNotificationToast = () => {
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
RoomOptions,
|
||||
supportsAdaptiveStream,
|
||||
supportsDynacast,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -99,23 +98,15 @@ export const Conference = ({
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
resolution: userConfig.videoPublishResolution
|
||||
? VideoPresets[userConfig.videoPublishResolution].resolution
|
||||
: undefined,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
}, [
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.videoPublishResolution,
|
||||
userConfig.audioDeviceId,
|
||||
userConfig.audioOutputDeviceId,
|
||||
isAdaptiveStreamSupported,
|
||||
isDynacastSupported,
|
||||
])
|
||||
|
||||
@@ -3,13 +3,7 @@ import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
createLocalAudioTrack,
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
@@ -20,8 +14,6 @@ import {
|
||||
EffectsConfiguration,
|
||||
EffectsConfigurationProps,
|
||||
} from '../livekit/components/effects/EffectsConfiguration'
|
||||
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
|
||||
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
|
||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -34,11 +26,11 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import type { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||
import { ToggleDevice } from './join/ToggleDevice'
|
||||
import { SelectDevice } from './join/SelectDevice'
|
||||
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -109,13 +101,11 @@ export const Join = ({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
username,
|
||||
},
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
@@ -123,44 +113,19 @@ export const Join = ({
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
|
||||
if (initialUserChoices.current === null) {
|
||||
initialUserChoices.current = {
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.audioEnabled && {
|
||||
deviceId: initialUserChoices.current.audioDeviceId,
|
||||
},
|
||||
video: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.videoEnabled && {
|
||||
deviceId: initialUserChoices.current.videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
},
|
||||
audio: { deviceId: audioDeviceId },
|
||||
video: {
|
||||
deviceId: videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
},
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const [dynamicVideoTrack, setDynamicVideoTrack] =
|
||||
useState<LocalVideoTrack | null>(null)
|
||||
const [dynamicAudioTrack, setDynamicAudioTrack] =
|
||||
useState<LocalAudioTrack | null>(null)
|
||||
|
||||
const previewVideoTrack = useMemo(
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
@@ -168,100 +133,18 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const previewAudioTrack = useMemo(
|
||||
const audioTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalAudioTrack,
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
/*
|
||||
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
||||
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
||||
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createVideoTrack = async () => {
|
||||
try {
|
||||
const track = await createLocalVideoTrack({
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
videoEnabled &&
|
||||
!initialUserChoices.current?.videoEnabled &&
|
||||
!previewVideoTrack &&
|
||||
!dynamicVideoTrack
|
||||
) {
|
||||
createVideoTrack()
|
||||
}
|
||||
}, [
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
previewVideoTrack,
|
||||
dynamicVideoTrack,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const createAudioTrack = async () => {
|
||||
try {
|
||||
const track = await createLocalAudioTrack({
|
||||
deviceId: { exact: audioDeviceId },
|
||||
})
|
||||
setDynamicAudioTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
}
|
||||
}
|
||||
if (
|
||||
audioEnabled &&
|
||||
!initialUserChoices.current?.audioEnabled &&
|
||||
!previewAudioTrack &&
|
||||
!dynamicAudioTrack
|
||||
) {
|
||||
createAudioTrack()
|
||||
}
|
||||
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
|
||||
|
||||
// Cleanup dynamic tracks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dynamicVideoTrack?.stop()
|
||||
}
|
||||
}, [dynamicVideoTrack])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dynamicAudioTrack?.stop()
|
||||
}
|
||||
}, [dynamicAudioTrack])
|
||||
|
||||
// Final tracks (dynamic takes precedence over preview)
|
||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||
|
||||
// LiveKit by default populates device choices with "default" value.
|
||||
// Instead, use the current device id used by the preview track as a default
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
audioDeviceId,
|
||||
audioTrack,
|
||||
saveAudioInputDeviceId
|
||||
)
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
videoDeviceId,
|
||||
videoTrack,
|
||||
saveVideoInputDeviceId
|
||||
)
|
||||
useResolveDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId)
|
||||
useResolveDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId)
|
||||
|
||||
const videoEl = useRef(null)
|
||||
const isVideoInitiated = useRef(false)
|
||||
@@ -277,6 +160,7 @@ export const Join = ({
|
||||
}
|
||||
|
||||
if (videoElement && videoTrack && videoEnabled) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoElement)
|
||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
@@ -349,8 +233,13 @@ export const Join = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
|
||||
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
|
||||
const isCameraDeniedOrPrompted =
|
||||
permissions.isCameraDenied || permissions.isCameraPrompted
|
||||
|
||||
const isMicrophoneDeniedOrPrompted =
|
||||
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
|
||||
|
||||
const hintMessage = useMemo(() => {
|
||||
if (isCameraDeniedOrPrompted) {
|
||||
@@ -659,30 +548,18 @@ export const Join = ({
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
kind="audioinput"
|
||||
context="join"
|
||||
enabled={audioEnabled}
|
||||
toggle={async () => {
|
||||
saveAudioInputEnabled(!audioEnabled)
|
||||
if (audioEnabled) {
|
||||
await audioTrack?.mute()
|
||||
} else {
|
||||
await audioTrack?.unmute()
|
||||
}
|
||||
}}
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
onChange={(enabled) => saveAudioInputEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
/>
|
||||
<ToggleDevice
|
||||
kind="videoinput"
|
||||
context="join"
|
||||
enabled={videoEnabled}
|
||||
toggle={async () => {
|
||||
saveVideoInputEnabled(!videoEnabled)
|
||||
if (videoEnabled) {
|
||||
await videoTrack?.mute()
|
||||
} else {
|
||||
await videoTrack?.unmute()
|
||||
}
|
||||
}}
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
onChange={(enabled) => saveVideoInputEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -713,55 +590,24 @@ export const Join = ({
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '30%',
|
||||
width: '50%',
|
||||
})}
|
||||
>
|
||||
<SelectDevice
|
||||
kind="audioinput"
|
||||
id={audioDeviceId}
|
||||
onSubmit={async (id) => {
|
||||
try {
|
||||
saveAudioInputDeviceId(id)
|
||||
if (audioTrack) {
|
||||
await audioTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch microphone device', err)
|
||||
}
|
||||
}}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
{!isSafari() && (
|
||||
<div
|
||||
className={css({
|
||||
width: '30%',
|
||||
})}
|
||||
>
|
||||
<SelectDevice
|
||||
kind="audiooutput"
|
||||
id={audioOutputDeviceId}
|
||||
onSubmit={saveAudioOutputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
width: '30%',
|
||||
width: '50%',
|
||||
})}
|
||||
>
|
||||
<SelectDevice
|
||||
kind="videoinput"
|
||||
id={videoDeviceId}
|
||||
onSubmit={async (id) => {
|
||||
try {
|
||||
saveVideoInputDeviceId(id)
|
||||
if (videoTrack) {
|
||||
await await videoTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch camera device', err)
|
||||
}
|
||||
}}
|
||||
onSubmit={saveVideoInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiMicLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Select } from '@/primitives/Select'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
type DeviceConfig = {
|
||||
icon: RemixiconComponentType
|
||||
}
|
||||
|
||||
type SelectDeviceProps = {
|
||||
id?: string
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps = SelectDeviceProps & {
|
||||
config: DeviceConfig
|
||||
}
|
||||
|
||||
const SelectDevicePermissions = ({
|
||||
id,
|
||||
kind,
|
||||
config,
|
||||
onSubmit,
|
||||
}: SelectDevicePermissionsProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
|
||||
|
||||
const items: DeviceItems = devices
|
||||
.filter((d) => !!d.deviceId)
|
||||
.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.choose`)}
|
||||
label=""
|
||||
isDisabled={items.length === 0}
|
||||
items={items}
|
||||
iconComponent={config?.icon}
|
||||
placeholder={t('selectDevice.loading')}
|
||||
selectedKey={id || activeDeviceId}
|
||||
onSelectionChange={(key) => {
|
||||
onSubmit?.(key as string)
|
||||
setActiveMediaDevice(key as string)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
|
||||
const config = useMemo<DeviceConfig | undefined>(() => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
return {
|
||||
icon: RiMicLine,
|
||||
}
|
||||
case 'videoinput':
|
||||
return {
|
||||
icon: RiVideoOnLine,
|
||||
}
|
||||
}
|
||||
}, [kind])
|
||||
|
||||
const isPermissionDeniedOrPrompted = useMemo(() => {
|
||||
if (kind == 'audioinput') {
|
||||
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
|
||||
}
|
||||
if (kind == 'videoinput') {
|
||||
return permissions.isCameraDenied || permissions.isCameraPrompted
|
||||
}
|
||||
return false
|
||||
}, [kind, permissions])
|
||||
|
||||
if (!config) return null
|
||||
|
||||
if (isPermissionDeniedOrPrompted || permissions.isLoading) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.permissionsNeeded`)}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
iconComponent={config?.icon}
|
||||
placeholder={t('selectDevice.permissionsNeeded')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectDevicePermissions
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
config={config}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
|
||||
import {
|
||||
TOGGLE_DEVICE_CONFIG,
|
||||
ToggleSource,
|
||||
} from '../../livekit/config/ToggleDeviceConfig'
|
||||
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack
|
||||
source: ToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
}
|
||||
|
||||
export const ToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
onChange,
|
||||
...props
|
||||
}: ToggleDeviceProps<T>) => {
|
||||
const config = TOGGLE_DEVICE_CONFIG[props.source]
|
||||
|
||||
if (!config) {
|
||||
throw new Error('Invalid source')
|
||||
}
|
||||
|
||||
const [isTrackEnabled, setIsTrackEnabled] = useState(
|
||||
props.initialState ?? false
|
||||
)
|
||||
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
|
||||
const isPermissionDeniedOrPrompted = useMemo(() => {
|
||||
if (config.kind == 'audioinput') {
|
||||
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
|
||||
}
|
||||
if (config.kind == 'videoinput') {
|
||||
return permissions.isCameraDenied || permissions.isCameraPrompted
|
||||
}
|
||||
}, [config, permissions])
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!track) {
|
||||
console.error('Track is undefined.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isTrackEnabled) {
|
||||
await track.mute()
|
||||
setIsTrackEnabled(false)
|
||||
onChange?.(false, true)
|
||||
} else {
|
||||
await track.unmute()
|
||||
setIsTrackEnabled(true)
|
||||
onChange?.(true, true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle track:', error)
|
||||
}
|
||||
}, [track, onChange, isTrackEnabled])
|
||||
|
||||
return (
|
||||
<BaseToggleDevice
|
||||
enabled={isTrackEnabled}
|
||||
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
|
||||
toggle={toggle}
|
||||
config={config}
|
||||
variant="whiteCircle"
|
||||
errorVariant="errorCircle"
|
||||
toggleButtonProps={{
|
||||
groupPosition: undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
@@ -18,7 +18,7 @@ export const FullScreenShareWarning = ({
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
if (trackReference?.source !== 'screen_share') return false
|
||||
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
screenSharePreferenceStore.enabled = false
|
||||
ScreenSharePreferenceStore.enabled = false
|
||||
}
|
||||
|
||||
if (!shouldShowWarning) return null
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
type AudioDevicesControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Microphone>,
|
||||
'source' | 'onChange'
|
||||
> & {
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const AudioDevicesControl = ({
|
||||
hideMenu,
|
||||
...props
|
||||
}: AudioDevicesControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const {
|
||||
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const trackProps = useTrackToggle({
|
||||
source: Source.Microphone,
|
||||
onChange,
|
||||
...props,
|
||||
})
|
||||
|
||||
const kind = 'audioinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
kind={kind}
|
||||
toggle={trackProps.toggle as () => Promise<void>}
|
||||
overrideToggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={
|
||||
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||
}
|
||||
>
|
||||
<RiArrowUpSLine />
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={audioDeviceId}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind="audiooutput"
|
||||
id={audioOutputDeviceId}
|
||||
onSubmit={saveAudioOutputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<SettingsButton
|
||||
settingTab={SettingsDialogExtendedKey.AUDIO}
|
||||
onPress={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Select, SelectProps } from '@/primitives/Select'
|
||||
import { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
type SelectDeviceContext = {
|
||||
variant?: 'light' | 'dark'
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
type SelectDeviceProps = {
|
||||
id?: string
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
context?: 'join' | 'room'
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
|
||||
|
||||
const SelectDevicePermissions = <T extends string | number>({
|
||||
id,
|
||||
kind,
|
||||
onSubmit,
|
||||
iconComponent,
|
||||
...props
|
||||
}: SelectDevicePermissionsProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
|
||||
|
||||
const items: DeviceItems = devices
|
||||
.filter((d) => !!d.deviceId)
|
||||
.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))
|
||||
|
||||
/**
|
||||
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
|
||||
* Auto-selects the only available audio output device when currently on 'default'
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
kind !== 'audiooutput' ||
|
||||
items.length !== 1 ||
|
||||
items[0].value === 'default' ||
|
||||
activeDeviceId !== 'default'
|
||||
)
|
||||
return
|
||||
onSubmit?.(items[0].value)
|
||||
setActiveMediaDevice(items[0].value)
|
||||
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
|
||||
|
||||
const selectedKey = id || activeDeviceId
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.choose`)}
|
||||
label=""
|
||||
isDisabled={items.length === 0}
|
||||
items={items}
|
||||
iconComponent={iconComponent}
|
||||
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={(key) => {
|
||||
if (key === selectedKey) return
|
||||
onSubmit?.(key as string)
|
||||
setActiveMediaDevice(key as string)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectDevice = ({
|
||||
id,
|
||||
onSubmit,
|
||||
kind,
|
||||
context = 'join',
|
||||
}: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const contextProps = useMemo<SelectDeviceContext>(() => {
|
||||
if (context == 'room') {
|
||||
return { variant: 'dark', placement: 'top' }
|
||||
}
|
||||
return {}
|
||||
}, [context])
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
|
||||
if (cannotUseDevice) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.permissionsNeeded`)}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
placeholder={t('permissionsNeeded')}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectDevicePermissions
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
|
||||
export const SettingsButton = ({
|
||||
settingTab,
|
||||
onPress,
|
||||
}: {
|
||||
settingTab: SettingsDialogExtendedKey
|
||||
onPress?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
square
|
||||
tooltip={t(`settings.${settingTab}`)}
|
||||
aria-label={t(`settings.${settingTab}`)}
|
||||
variant="primaryDark"
|
||||
onPress={() => {
|
||||
openSettingsDialog(settingTab)
|
||||
onPress?.()
|
||||
}}
|
||||
>
|
||||
<RiSettings3Line size={24} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||
|
||||
type ToggleDeviceStyleProps = {
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export type ToggleDeviceProps<T extends ToggleSource> = {
|
||||
enabled: boolean
|
||||
toggle: (
|
||||
forceState?: boolean,
|
||||
captureOptions?: CaptureOptionsBySource<T>
|
||||
) => Promise<void | boolean | undefined>
|
||||
context?: 'room' | 'join'
|
||||
kind: 'audioinput' | 'videoinput'
|
||||
overrideToggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export const ToggleDevice = <T extends ToggleSource>({
|
||||
kind,
|
||||
enabled,
|
||||
toggle,
|
||||
context = 'room',
|
||||
overrideToggleButtonProps,
|
||||
}: ToggleDeviceProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const {
|
||||
variant,
|
||||
errorVariant,
|
||||
toggleButtonProps: computedToggleButtonProps,
|
||||
} = useMemo<ToggleDeviceStyleProps>(() => {
|
||||
if (context === 'join') {
|
||||
return {
|
||||
variant: 'whiteCircle',
|
||||
errorVariant: 'errorCircle',
|
||||
toggleButtonProps: {
|
||||
groupPosition: undefined,
|
||||
},
|
||||
} as ToggleDeviceStyleProps
|
||||
}
|
||||
return {
|
||||
variant: 'primaryDark',
|
||||
errorVariant: 'error2',
|
||||
toggleButtonProps: {
|
||||
groupPosition: 'left',
|
||||
},
|
||||
} as ToggleDeviceStyleProps
|
||||
}, [context])
|
||||
|
||||
const [pushToTalk, setPushToTalk] = useState(false)
|
||||
|
||||
const onKeyDown = () => {
|
||||
if (pushToTalk || enabled) return
|
||||
toggle()
|
||||
setPushToTalk(true)
|
||||
}
|
||||
const onKeyUp = () => {
|
||||
if (!pushToTalk) return
|
||||
toggle()
|
||||
setPushToTalk(false)
|
||||
}
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: deviceShortcut,
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
useLongPress({
|
||||
keyCode: kind === 'audioinput' ? 'Space' : undefined,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
})
|
||||
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
||||
}, [enabled, kind, deviceShortcut, t])
|
||||
|
||||
const Icon =
|
||||
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
|
||||
|
||||
const roomContext = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && roomContext) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={enabled && !cannotUseDevice ? variant : errorVariant}
|
||||
shySelected
|
||||
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||
import { Track, VideoCaptureOptions } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { BackgroundProcessorFactory } from '../../blur'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
aria-label={t('effects')}
|
||||
variant="primaryDark"
|
||||
onPress={() => {
|
||||
if (!isEffectsOpen) toggleEffects()
|
||||
onPress()
|
||||
}}
|
||||
>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type VideoDeviceControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Camera>,
|
||||
'source' | 'onChange'
|
||||
> & {
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const VideoDeviceControl = ({
|
||||
hideMenu,
|
||||
...props
|
||||
}: VideoDeviceControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
||||
usePersistentUserChoices()
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
const trackProps = useTrackToggle({
|
||||
source: Source.Camera,
|
||||
onChange,
|
||||
...props,
|
||||
})
|
||||
|
||||
const kind = 'videoinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
|
||||
const toggleWithProcessor = async () => {
|
||||
/**
|
||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||
* Before, we had the following bug:
|
||||
* 1 - Configure a processor on join screen
|
||||
* 2 - Turn off camera on join screen
|
||||
* 3 - Join the room
|
||||
* 4 - Turn on the camera
|
||||
* 5 - No processor is applied to the camera
|
||||
* Expected: The processor is applied.
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
forceState: boolean,
|
||||
captureOptions: VideoCaptureOptions
|
||||
) => Promise<void>
|
||||
|
||||
await toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
} as VideoCaptureOptions)
|
||||
}
|
||||
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
kind={kind}
|
||||
toggle={toggleWithProcessor}
|
||||
overrideToggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={
|
||||
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||
}
|
||||
>
|
||||
<RiArrowUpSLine />
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={userChoices.videoDeviceId}
|
||||
onSubmit={saveVideoInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<EffectsButton onPress={close} />
|
||||
<SettingsButton
|
||||
settingTab={SettingsDialogExtendedKey.VIDEO}
|
||||
onPress={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
|
||||
export const SettingsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => openSettingsDialog()}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useMediaDeviceSelect,
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { Button, Menu, MenuList } from '@/primitives'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import {
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
VideoCaptureOptions,
|
||||
} from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../blur'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
import {
|
||||
TOGGLE_DEVICE_CONFIG,
|
||||
ToggleSource,
|
||||
} from '../../config/ToggleDeviceConfig'
|
||||
|
||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack
|
||||
initialDeviceId?: string
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: ToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
menuVariant?: 'dark' | 'light'
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
initialDeviceId,
|
||||
onActiveDeviceChange,
|
||||
hideMenu,
|
||||
variant = 'primaryDark',
|
||||
menuVariant = 'light',
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = TOGGLE_DEVICE_CONFIG[props.source]
|
||||
if (!config) {
|
||||
throw new Error('Invalid source')
|
||||
}
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const trackProps = useTrackToggle(props)
|
||||
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
const isPermissionDeniedOrPrompted = useMemo(() => {
|
||||
switch (config.kind) {
|
||||
case 'audioinput':
|
||||
return (
|
||||
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
|
||||
)
|
||||
case 'videoinput':
|
||||
return permissions.isCameraDenied || permissions.isCameraPrompted
|
||||
}
|
||||
}, [permissions, config.kind])
|
||||
|
||||
const toggle = () => {
|
||||
if (props.source === Track.Source.Camera) {
|
||||
/**
|
||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||
* Before, we had the following bug:
|
||||
* 1 - Configure a processor on join screen
|
||||
* 2 - Turn off camera on join screen
|
||||
* 3 - Join the room
|
||||
* 4 - Turn on the camera
|
||||
* 5 - No processor is applied to the camera
|
||||
* Expected: The processor is applied.
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
forceState: boolean,
|
||||
captureOptions: VideoCaptureOptions
|
||||
) => Promise<void>
|
||||
|
||||
toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
} as VideoCaptureOptions)
|
||||
} else {
|
||||
trackProps.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: config.kind, track })
|
||||
|
||||
/**
|
||||
* When providing only track outside of a room context, activeDeviceId is undefined.
|
||||
* So we need to initialize it with the initialDeviceId.
|
||||
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialDeviceId !== undefined) {
|
||||
setActiveMediaDevice(initialDeviceId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setActiveMediaDevice])
|
||||
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
config={config}
|
||||
variant={variant}
|
||||
toggle={toggle}
|
||||
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
|
||||
toggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Menu variant={menuVariant}>
|
||||
<Button
|
||||
isDisabled={isPermissionDeniedOrPrompted}
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={
|
||||
trackProps.enabled && !isPermissionDeniedOrPrompted
|
||||
? variant
|
||||
: 'error2'
|
||||
}
|
||||
>
|
||||
<RiArrowUpSLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={devices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
variant={menuVariant}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const SettingsDialogContext = createContext<
|
||||
| {
|
||||
dialogOpen: boolean
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const SettingsDialogProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
return (
|
||||
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
|
||||
{children}
|
||||
<SettingsDialogExtended
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={(v) => !v && setDialogOpen(false)}
|
||||
/>
|
||||
</SettingsDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSettingsDialog = () => {
|
||||
const context = useContext(SettingsDialogContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSettingsDialog must be used within a SettingsDialogProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
|
||||
|
||||
export type ToggleDeviceProps = {
|
||||
enabled: boolean
|
||||
isPermissionDeniedOrPrompted?: boolean
|
||||
toggle: () => void
|
||||
config: ToggleDeviceConfig
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export const ToggleDevice = ({
|
||||
config,
|
||||
enabled,
|
||||
toggle,
|
||||
variant = 'primaryDark',
|
||||
errorVariant = 'error2',
|
||||
toggleButtonProps,
|
||||
isPermissionDeniedOrPrompted,
|
||||
}: ToggleDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { kind, shortcut, iconOn, iconOff, longPress } = config
|
||||
|
||||
const [pushToTalk, setPushToTalk] = useState(false)
|
||||
|
||||
const onKeyDown = () => {
|
||||
if (pushToTalk || enabled) return
|
||||
toggle()
|
||||
setPushToTalk(true)
|
||||
}
|
||||
const onKeyUp = () => {
|
||||
if (!pushToTalk) return
|
||||
toggle()
|
||||
setPushToTalk(false)
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
|
||||
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `join.${kind}`,
|
||||
})
|
||||
return shortcut ? appendShortcutLabel(label, shortcut) : label
|
||||
}, [enabled, kind, shortcut, t])
|
||||
|
||||
const Icon = enabled && !isPermissionDeniedOrPrompted ? iconOn : iconOff
|
||||
|
||||
const context = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && context) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isPermissionDeniedOrPrompted && <PermissionNeededButton />}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={
|
||||
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
|
||||
}
|
||||
shySelected
|
||||
onPress={() =>
|
||||
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
|
||||
}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
isPermissionDeniedOrPrompted
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
groupPosition="left"
|
||||
{...toggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
| Track.Source.ScreenShareAudio
|
||||
| Track.Source.Unknown
|
||||
| Track.Source.ScreenShare
|
||||
>
|
||||
|
||||
export type ToggleDeviceConfig = {
|
||||
kind: MediaDeviceKind
|
||||
iconOn: RemixiconComponentType
|
||||
iconOff: RemixiconComponentType
|
||||
shortcut?: Shortcut
|
||||
longPress?: Shortcut
|
||||
}
|
||||
|
||||
type ToggleDeviceConfigMap = {
|
||||
[key in ToggleSource]: ToggleDeviceConfig
|
||||
}
|
||||
|
||||
export const TOGGLE_DEVICE_CONFIG = {
|
||||
[Track.Source.Microphone]: {
|
||||
kind: 'audioinput',
|
||||
iconOn: RiMicLine,
|
||||
iconOff: RiMicOffLine,
|
||||
shortcut: {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
},
|
||||
longPress: {
|
||||
key: 'Space',
|
||||
},
|
||||
},
|
||||
[Track.Source.Camera]: {
|
||||
kind: 'videoinput',
|
||||
iconOn: RiVideoOnLine,
|
||||
iconOff: RiVideoOffLine,
|
||||
shortcut: {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies ToggleDeviceConfigMap
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
const {
|
||||
isLoading,
|
||||
isMicrophoneDenied,
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
return useMemo(() => {
|
||||
if (isLoading) return true
|
||||
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
case 'audiooutput': // audiooutput uses microphone permissions
|
||||
return isMicrophoneDenied || isMicrophonePrompted
|
||||
case 'videoinput':
|
||||
return isCameraDenied || isCameraPrompted
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [
|
||||
kind,
|
||||
isLoading,
|
||||
isMicrophoneDenied,
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
])
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
RiVolumeDownLine,
|
||||
RiVolumeMuteLine,
|
||||
} from '@remixicon/react'
|
||||
|
||||
export interface DeviceIcons {
|
||||
toggleOn: RemixiconComponentType
|
||||
toggleOff: RemixiconComponentType
|
||||
select: RemixiconComponentType
|
||||
}
|
||||
|
||||
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
|
||||
audioinput: {
|
||||
toggleOn: RiMicLine,
|
||||
toggleOff: RiMicOffLine,
|
||||
select: RiMicLine,
|
||||
},
|
||||
videoinput: {
|
||||
toggleOn: RiVideoOnLine,
|
||||
toggleOff: RiVideoOffLine,
|
||||
select: RiVideoOnLine,
|
||||
},
|
||||
audiooutput: {
|
||||
toggleOn: RiVolumeDownLine,
|
||||
toggleOff: RiVolumeMuteLine,
|
||||
select: RiVolumeDownLine,
|
||||
},
|
||||
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
|
||||
}
|
||||
|
||||
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
|
||||
ICONS[kind] ?? ICONS.default
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
||||
return useMemo<Shortcut | undefined>(() => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
return {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
}
|
||||
case 'videoinput':
|
||||
return {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [kind])
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import type { VideoResolution } from '@/stores/userChoices'
|
||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||
import type { VideoQuality } from 'livekit-client'
|
||||
|
||||
export function usePersistentUserChoices() {
|
||||
const userChoicesSnap = useSnapshot(userChoicesStore)
|
||||
@@ -18,18 +16,9 @@ export function usePersistentUserChoices() {
|
||||
saveAudioInputDeviceId: (deviceId: string) => {
|
||||
userChoicesStore.audioDeviceId = deviceId
|
||||
},
|
||||
saveAudioOutputDeviceId: (deviceId: string) => {
|
||||
userChoicesStore.audioOutputDeviceId = deviceId
|
||||
},
|
||||
saveVideoInputDeviceId: (deviceId: string) => {
|
||||
userChoicesStore.videoDeviceId = deviceId
|
||||
},
|
||||
saveVideoPublishResolution: (resolution: VideoResolution) => {
|
||||
userChoicesStore.videoPublishResolution = resolution
|
||||
},
|
||||
saveVideoSubscribeQuality: (quality: VideoQuality) => {
|
||||
userChoicesStore.videoSubscribeQuality = quality
|
||||
},
|
||||
saveUsername: (username: string) => {
|
||||
userChoicesStore.username = username
|
||||
},
|
||||
|
||||
+4
-8
@@ -1,21 +1,17 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useResolveInitiallyDefaultDeviceId = <
|
||||
export const useResolveDefaultDeviceId = <
|
||||
T extends { getDeviceId(): Promise<string | undefined> },
|
||||
>(
|
||||
currentId: string,
|
||||
track: T | undefined,
|
||||
save: (id: string) => void
|
||||
) => {
|
||||
const isInitiated = useRef(false)
|
||||
useEffect(() => {
|
||||
if (currentId !== 'default' || !track || isInitiated.current) return
|
||||
if (currentId !== 'default' || !track) return
|
||||
const resolveDefaultDeviceId = async () => {
|
||||
const actualDeviceId = await track.getDeviceId()
|
||||
if (actualDeviceId && actualDeviceId !== 'default') {
|
||||
isInitiated.current = true
|
||||
save(actualDeviceId)
|
||||
}
|
||||
if (actualDeviceId && actualDeviceId !== 'default') save(actualDeviceId)
|
||||
}
|
||||
resolveDefaultDeviceId()
|
||||
}, [currentId, track, save])
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePersistentUserChoices } from './usePersistentUserChoices'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoQuality,
|
||||
} from 'livekit-client'
|
||||
|
||||
/**
|
||||
* This hook sets initial video quality for new participants as they join.
|
||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||
*/
|
||||
export const useVideoResolutionSubscription = () => {
|
||||
const {
|
||||
userChoices: { videoSubscribeQuality },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
const handleTrackPublished = (
|
||||
publication: RemoteTrackPublication,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_participant: RemoteParticipant
|
||||
) => {
|
||||
// By default, the maximum quality is set to high
|
||||
if (
|
||||
videoSubscribeQuality === undefined ||
|
||||
videoSubscribeQuality === VideoQuality.HIGH
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Video &&
|
||||
publication.source !== Track.Source.ScreenShare
|
||||
) {
|
||||
publication.setVideoQuality(videoSubscribeQuality)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
}
|
||||
}, [room, videoSubscribeQuality])
|
||||
}
|
||||
@@ -3,23 +3,98 @@ import * as React from 'react'
|
||||
|
||||
import { MobileControlBar } from './MobileControlBar'
|
||||
import { DesktopControlBar } from './DesktopControlBar'
|
||||
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
microphone?: boolean
|
||||
camera?: boolean
|
||||
chat?: boolean
|
||||
screenShare?: boolean
|
||||
leave?: boolean
|
||||
settings?: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||
variation?: 'minimal' | 'verbose' | 'textOnly'
|
||||
controls?: ControlBarControls
|
||||
/**
|
||||
* If `true`, the user's device choices will be persisted.
|
||||
* This will enable the user to have the same device choices when they rejoin the room.
|
||||
* @defaultValue true
|
||||
* @alpha
|
||||
*/
|
||||
saveUserChoices?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ControlBar` prefab gives the user the basic user interface to control their
|
||||
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
||||
*
|
||||
* @remarks
|
||||
* This component is build with other LiveKit components like `TrackToggle`,
|
||||
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <ControlBar />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||
const {
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const microphoneOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const cameraOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
const barProps = {
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||
return (
|
||||
<SettingsDialogProvider>
|
||||
{isMobile ? (
|
||||
<MobileControlBar {...barProps} />
|
||||
) : (
|
||||
<DesktopControlBar {...barProps} />
|
||||
)}
|
||||
</SettingsDialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||
export interface ControlBarAuxProps {
|
||||
onDeviceError: ControlBarProps['onDeviceError']
|
||||
microphoneOnChange: (
|
||||
enabled: boolean,
|
||||
isUserInitiated: boolean
|
||||
) => void | null
|
||||
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
|
||||
saveAudioInputDeviceId: (deviceId: string) => void
|
||||
saveVideoInputDeviceId: (deviceId: string) => void
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { ControlBarAuxProps } from './ControlBar'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
@@ -10,12 +11,14 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
@@ -50,15 +53,27 @@ export function DesktopControlBar({
|
||||
gap: '0.65rem',
|
||||
})}
|
||||
>
|
||||
<AudioDevicesControl
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
{browserSupportsScreenSharing && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ControlBarAuxProps } from './ControlBar'
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { Button } from '@/primitives/Button'
|
||||
@@ -18,22 +19,24 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { LinkButton } from '@/primitives'
|
||||
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
@@ -59,15 +62,27 @@ export function MobileControlBar({
|
||||
})}
|
||||
>
|
||||
<LeaveButton />
|
||||
<AudioDevicesControl
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
@@ -152,7 +167,7 @@ export function MobileControlBar({
|
||||
)}
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSettingsDialog()
|
||||
setDialogOpen(true)
|
||||
setIsMenuOpened(false)
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
|
||||
@@ -32,8 +32,6 @@ import { RecordingStateToast } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -79,7 +77,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
@@ -241,7 +238,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
<RecordingStateToast />
|
||||
<SettingsDialogProvider />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { VideoTab } from './tabs/VideoTab'
|
||||
import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -47,9 +44,7 @@ const tabPanelContainerStyle = css({
|
||||
export type SettingsDialogExtended = Pick<
|
||||
DialogProps,
|
||||
'isOpen' | 'onOpenChange'
|
||||
> & {
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
}
|
||||
>
|
||||
|
||||
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
// display only icon on small screen
|
||||
@@ -60,11 +55,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
className={tabsStyle}
|
||||
defaultSelectedKey={props.defaultSelectedTab}
|
||||
>
|
||||
<Tabs orientation="vertical" className={tabsStyle}>
|
||||
<div
|
||||
className={tabListContainerStyle}
|
||||
style={{
|
||||
@@ -78,39 +69,30 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
{t('dialog.heading')}
|
||||
</Heading>
|
||||
)}
|
||||
<TabList border={false}>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
|
||||
<TabList border={false} aria-label="Chat log orientation example">
|
||||
<Tab icon highlight id="1">
|
||||
<RiAccountCircleLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
|
||||
{isWideScreen && t('tabs.account')}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
|
||||
<Tab icon highlight id="2">
|
||||
<RiSpeakerLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
|
||||
{isWideScreen && t('tabs.audio')}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
|
||||
<RiVideoOnLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
|
||||
<Tab icon highlight id="3">
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||
{isWideScreen && t('tabs.general')}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||
<Tab icon highlight id="4">
|
||||
<RiNotification3Line />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
{isWideScreen && t('tabs.notifications')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
<AccountTab
|
||||
id={SettingsDialogExtendedKey.ACCOUNT}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
||||
<AudioTab id="2" />
|
||||
<GeneralTab id="3" />
|
||||
<NotificationsTab id="4" />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { SettingsDialogExtended } from './SettingsDialogExtended'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { settingsStore } from '@/stores/settings'
|
||||
|
||||
export const SettingsDialogProvider = () => {
|
||||
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
|
||||
|
||||
return (
|
||||
<SettingsDialogExtended
|
||||
isOpen={areSettingsOpen}
|
||||
defaultSelectedTab={defaultSelectedTab}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && settingsStore.defaultSelectedTab) {
|
||||
settingsStore.defaultSelectedTab = undefined
|
||||
}
|
||||
settingsStore.areSettingsOpen = v
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DialogProps, Field, Switch, Text } from '@/primitives'
|
||||
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
|
||||
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import {
|
||||
@@ -9,11 +9,78 @@ import {
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SoundTester } from '@/components/SoundTester'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
||||
import { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import posthog from 'posthog-js'
|
||||
import { RowWrapper } from './layout/RowWrapper'
|
||||
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
||||
|
||||
type RowWrapperProps = {
|
||||
heading: string
|
||||
children: ReactNode[]
|
||||
beta?: boolean
|
||||
}
|
||||
|
||||
const BetaBadge = () => (
|
||||
<span
|
||||
className={css({
|
||||
content: '"Beta"',
|
||||
display: 'block',
|
||||
letterSpacing: '-0.02rem',
|
||||
padding: '0 0.25rem',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
margin: '0 0 0.9375rem 0.3125rem',
|
||||
lineHeight: '1rem',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
marginTop: { base: '10px', sm: '5px' },
|
||||
})}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)
|
||||
|
||||
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
|
||||
return (
|
||||
<>
|
||||
<HStack>
|
||||
<H lvl={2}>{heading}</H>
|
||||
{beta && <BetaBadge />}
|
||||
</HStack>
|
||||
<HStack
|
||||
gap={0}
|
||||
style={{
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 215px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children[0]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '10rem',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
paddingLeft: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{children[1]}
|
||||
</div>
|
||||
</HStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -25,19 +92,24 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
const { localParticipant } = useRoomContext()
|
||||
|
||||
const {
|
||||
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
|
||||
userChoices: { noiseReductionEnabled },
|
||||
saveAudioInputDeviceId,
|
||||
saveNoiseReductionEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
|
||||
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
|
||||
useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
const {
|
||||
devices: devicesOut,
|
||||
activeDeviceId: activeDeviceIdOut,
|
||||
setActiveMediaDevice: setActiveMediaDeviceOut,
|
||||
} = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
|
||||
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
||||
useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
const {
|
||||
devices: devicesIn,
|
||||
activeDeviceId: activeDeviceIdIn,
|
||||
setActiveMediaDevice: setActiveMediaDeviceIn,
|
||||
} = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
|
||||
const itemsOut: DeviceItems = devicesOut.map((d) => ({
|
||||
value: d.deviceId,
|
||||
@@ -62,6 +134,15 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
defaultSelectedKey: undefined,
|
||||
}
|
||||
|
||||
// No API to directly query the default audio device; this function heuristically finds it.
|
||||
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
|
||||
const getDefaultSelectedKey = (items: DeviceItems) => {
|
||||
if (!items || items.length === 0) return
|
||||
const defaultItem =
|
||||
items.find((item) => item.value === 'default') || items[0]
|
||||
return defaultItem.value
|
||||
}
|
||||
|
||||
const noiseReductionAvailable = useNoiseReductionAvailable()
|
||||
|
||||
return (
|
||||
@@ -71,9 +152,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
type="select"
|
||||
label={t('audio.microphone.label')}
|
||||
items={itemsIn}
|
||||
selectedKey={audioDeviceId}
|
||||
onSelectionChange={async (key) => {
|
||||
await setActiveMediaDeviceIn(key as string)
|
||||
defaultSelectedKey={
|
||||
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
|
||||
}
|
||||
onSelectionChange={(key) => {
|
||||
setActiveMediaDeviceIn(key as string)
|
||||
saveAudioInputDeviceId(key as string)
|
||||
}}
|
||||
{...disabledProps}
|
||||
@@ -97,11 +180,12 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
type="select"
|
||||
label={t('audio.speakers.label')}
|
||||
items={itemsOut}
|
||||
selectedKey={audioOutputDeviceId}
|
||||
onSelectionChange={async (key) => {
|
||||
await setActiveMediaDeviceOut(key as string)
|
||||
saveAudioOutputDeviceId(key as string)
|
||||
}}
|
||||
defaultSelectedKey={
|
||||
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
|
||||
}
|
||||
onSelectionChange={async (key) =>
|
||||
setActiveMediaDeviceOut(key as string)
|
||||
}
|
||||
{...disabledProps}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { DialogProps, Field } from '@/primitives'
|
||||
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
VideoPresets,
|
||||
VideoQuality,
|
||||
} from 'livekit-client'
|
||||
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
|
||||
import { VideoResolution } from '@/stores/userChoices'
|
||||
import { RowWrapper } from './layout/RowWrapper'
|
||||
|
||||
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||
|
||||
const {
|
||||
userChoices: {
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
videoPublishResolution,
|
||||
videoSubscribeQuality,
|
||||
},
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoPublishResolution,
|
||||
saveVideoSubscribeQuality,
|
||||
} = usePersistentUserChoices()
|
||||
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
|
||||
setVideoElement(element)
|
||||
}, [])
|
||||
|
||||
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
||||
useMediaDeviceSelect({ kind: 'videoinput' })
|
||||
|
||||
const itemsIn: DeviceItems = devicesIn.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))
|
||||
|
||||
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
|
||||
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
|
||||
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
|
||||
const isCamEnabled = devicesIn?.length > 0
|
||||
|
||||
const disabledProps = isCamEnabled
|
||||
? {}
|
||||
: {
|
||||
placeholder: t('permissionsRequired'),
|
||||
isDisabled: true,
|
||||
}
|
||||
|
||||
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||
const videoPublication = localParticipant.getTrackPublication(
|
||||
Track.Source.Camera
|
||||
)
|
||||
const videoTrack = videoPublication?.track
|
||||
if (videoTrack) {
|
||||
saveVideoPublishResolution(key)
|
||||
await videoTrack.restartTrack({
|
||||
resolution: VideoPresets[key].resolution,
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
|
||||
* so this function applies the selected quality to all existing remote video tracks.
|
||||
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
|
||||
*/
|
||||
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
|
||||
remoteParticipants.forEach((participant) => {
|
||||
participant.videoTrackPublications.forEach((publication) => {
|
||||
if (publication.videoQuality !== selectedQuality) {
|
||||
publication.setVideoQuality(selectedQuality)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let videoTrack: LocalVideoTrack | null = null
|
||||
|
||||
const setUpVideoTrack = async () => {
|
||||
if (videoElement) {
|
||||
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
|
||||
videoTrack.attach(videoElement)
|
||||
}
|
||||
}
|
||||
|
||||
setUpVideoTrack()
|
||||
|
||||
return () => {
|
||||
if (videoElement && videoTrack) {
|
||||
videoTrack.detach()
|
||||
videoTrack.stop()
|
||||
}
|
||||
}
|
||||
}, [videoDeviceId, videoElement])
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<RowWrapper heading={t('camera.heading')}>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('camera.label')}
|
||||
items={itemsIn}
|
||||
selectedKey={videoDeviceId}
|
||||
onSelectionChange={async (key) => {
|
||||
await setActiveMediaDeviceIn(key as string)
|
||||
saveVideoInputDeviceId(key as string)
|
||||
}}
|
||||
{...disabledProps}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t(
|
||||
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
|
||||
)}
|
||||
>
|
||||
{localParticipant.isCameraEnabled ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
ref={videoCallbackRef}
|
||||
width="160px"
|
||||
height="56px"
|
||||
style={{
|
||||
display: !localParticipant.isCameraEnabled
|
||||
? 'none'
|
||||
: undefined,
|
||||
}}
|
||||
className={css({
|
||||
transform: 'rotateY(180deg)',
|
||||
height: '69px',
|
||||
width: '160px',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{t('camera.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</RowWrapper>
|
||||
<RowWrapper heading={t('resolution.heading')}>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.publish.label')}
|
||||
items={[
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]}
|
||||
selectedKey={videoPublishResolution}
|
||||
onSelectionChange={async (key) => {
|
||||
await handleVideoResolutionChange(key as VideoResolution)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<></>
|
||||
</RowWrapper>
|
||||
<RowWrapper>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.subscribe.label')}
|
||||
items={[
|
||||
{
|
||||
value: VideoQuality.HIGH.toString(),
|
||||
label: t('resolution.subscribe.items.high'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.MEDIUM.toString(),
|
||||
label: t('resolution.subscribe.items.medium'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.LOW.toString(),
|
||||
label: t('resolution.subscribe.items.low'),
|
||||
},
|
||||
]}
|
||||
selectedKey={videoSubscribeQuality?.toString()}
|
||||
onSelectionChange={(key) => {
|
||||
if (key == undefined) return
|
||||
const selectedQuality = Number(String(key))
|
||||
saveVideoSubscribeQuality(selectedQuality)
|
||||
updateExistingRemoteVideoQuality(selectedQuality)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<></>
|
||||
</RowWrapper>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { H } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export type RowWrapperProps = {
|
||||
heading?: string
|
||||
children: ReactNode[]
|
||||
beta?: boolean
|
||||
}
|
||||
|
||||
const BetaBadge = () => (
|
||||
<span
|
||||
className={css({
|
||||
content: '"Beta"',
|
||||
display: 'block',
|
||||
letterSpacing: '-0.02rem',
|
||||
padding: '0 0.25rem',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
margin: '0 0 0.9375rem 0.3125rem',
|
||||
lineHeight: '1rem',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
marginTop: { base: '10px', sm: '5px' },
|
||||
})}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)
|
||||
|
||||
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
|
||||
return (
|
||||
<>
|
||||
{heading && (
|
||||
<HStack>
|
||||
<H lvl={2}>{heading}</H>
|
||||
{beta && <BetaBadge />}
|
||||
</HStack>
|
||||
)}
|
||||
<HStack
|
||||
gap={0}
|
||||
style={{
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 215px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children[0]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '10rem',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
paddingLeft: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{children[1]}
|
||||
</div>
|
||||
</HStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { settingsStore } from '@/stores/settings'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
export const useSettingsDialog = () => {
|
||||
const { areSettingsOpen } = useSnapshot(settingsStore)
|
||||
|
||||
const openSettingsDialog = (
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
) => {
|
||||
if (areSettingsOpen) return
|
||||
if (defaultSelectedTab)
|
||||
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
|
||||
return {
|
||||
openSettingsDialog,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum SettingsDialogExtendedKey {
|
||||
ACCOUNT = 'account',
|
||||
AUDIO = 'audio',
|
||||
VIDEO = 'video',
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const useKeyboardShortcuts = () => {
|
||||
useEffect(() => {
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
@@ -20,7 +20,7 @@ export const useKeyboardShortcuts = () => {
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
e.preventDefault()
|
||||
await shortcut()
|
||||
shortcut()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
@@ -5,7 +5,6 @@ export type useLongPressProps = {
|
||||
onKeyDown: () => void
|
||||
onKeyUp: () => void
|
||||
longPressThreshold?: number
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useLongPress = ({
|
||||
@@ -13,18 +12,10 @@ export const useLongPress = ({
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
longPressThreshold = 300,
|
||||
isDisabled = false,
|
||||
}: useLongPressProps) => {
|
||||
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current)
|
||||
timeoutIdRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code != keyCode || timeoutIdRef.current) return
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
@@ -47,12 +38,8 @@ export const useLongPress = ({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current)
|
||||
timeoutIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
|
||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,22 +5,15 @@ import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
handler: () => void
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
const formattedKey = formatShortcutKey(shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
}, [handler, shortcut, isDisabled])
|
||||
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
|
||||
}, [handler, shortcut])
|
||||
}
|
||||
|
||||
@@ -7,15 +7,11 @@
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Laden…",
|
||||
"select": "Wählen Sie einen Wert",
|
||||
"permissionsNeeded": "Genehmigung erforderlich",
|
||||
"settings": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Laden…",
|
||||
"permissionsNeeded": "Genehmigung erforderlich"
|
||||
},
|
||||
"effects": "Effekte anwenden",
|
||||
"videoinput": {
|
||||
"choose": "Kamera auswählen",
|
||||
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||
@@ -31,12 +27,6 @@
|
||||
"enable": "Mikrofon aktivieren",
|
||||
"label": "Mikrofon"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Lautsprecher auswählen",
|
||||
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Effekte anwenden",
|
||||
"title": "Effekte",
|
||||
|
||||
@@ -30,37 +30,6 @@
|
||||
},
|
||||
"permissionsRequired": "Berechtigungen erforderlich"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Kamera",
|
||||
"label": "Wählen Sie Ihre Videoeingabe aus",
|
||||
"disabled": "Kamera deaktiviert",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Videovorschau aktiviert",
|
||||
"disabled": "Videovorschau deaktiviert"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Auflösung",
|
||||
"publish": {
|
||||
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
|
||||
"items": {
|
||||
"high": "Hohe Auflösung",
|
||||
"medium": "Standardauflösung",
|
||||
"low": "Niedrige Auflösung"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
|
||||
"items": {
|
||||
"high": "Hohe Auflösung (automatisch)",
|
||||
"medium": "Standardauflösung",
|
||||
"low": "Niedrige Auflösung"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Berechtigungen erforderlich"
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Tonbenachrichtigungen",
|
||||
"label": "Tonbenachrichtigungen für",
|
||||
@@ -85,7 +54,6 @@
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen"
|
||||
}
|
||||
|
||||
@@ -7,15 +7,11 @@
|
||||
"home": "Return to home",
|
||||
"back": "Rejoin the meeting"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Loading…",
|
||||
"select": "Select a value",
|
||||
"permissionsNeeded": "Permission needed",
|
||||
"settings": {
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Loading…",
|
||||
"permissionsNeeded": "Permission needed"
|
||||
},
|
||||
"effects": "Effects",
|
||||
"videoinput": {
|
||||
"choose": "Select camera",
|
||||
"permissionsNeeded": "Select camera - permission needed",
|
||||
@@ -31,12 +27,6 @@
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Select speaker",
|
||||
"permissionsNeeded": "Select speaker - permission needed"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"title": "Effects",
|
||||
|
||||
@@ -30,37 +30,6 @@
|
||||
},
|
||||
"permissionsRequired": "Permissions required"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Camera",
|
||||
"label": "Select your video input",
|
||||
"disabled": "Camera disabled",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Video preview enabled",
|
||||
"disabled": "Video preview disabled"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Resolution",
|
||||
"publish": {
|
||||
"label": "Select your sending resolution (max.)",
|
||||
"items": {
|
||||
"high": "High definition",
|
||||
"medium": "Standard definition",
|
||||
"low": "Low definition"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Select your reception resolution (max.)",
|
||||
"items": {
|
||||
"high": "High definition (automatic)",
|
||||
"medium": "Standard definition",
|
||||
"low": "Low definition"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Permissions required"
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Sound notifications",
|
||||
"label": "sound notifications for",
|
||||
@@ -85,7 +54,6 @@
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications"
|
||||
}
|
||||
|
||||
@@ -7,15 +7,11 @@
|
||||
"home": "Retourner à l'accueil",
|
||||
"back": "Réintégrer la réunion"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Chargement…",
|
||||
"select": "Sélectionnez une valeur",
|
||||
"permissionsNeeded": "Autorisations nécessaires",
|
||||
"settings": {
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Chargement…",
|
||||
"permissionsNeeded": "Autorisations nécessaires"
|
||||
},
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"videoinput": {
|
||||
"choose": "Choisir la webcam",
|
||||
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||
@@ -31,15 +27,9 @@
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Choisir le haut-parleur",
|
||||
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"heading": "Rejoindre la réunion ?",
|
||||
"effects": {
|
||||
"description": "Effets d'arrière-plan",
|
||||
"description": "Effets d'arrière plan",
|
||||
"title": "Effets",
|
||||
"subTitle": "Paramétrez les effets de votre caméra."
|
||||
},
|
||||
@@ -198,7 +188,7 @@
|
||||
"feedback": "Partager votre avis",
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"effects": "Effets d'arrière plan",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
@@ -462,7 +452,7 @@
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
},
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"effects": "Effets d'arrière plan",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
|
||||
@@ -30,37 +30,6 @@
|
||||
},
|
||||
"permissionsRequired": "Autorisations nécessaires"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Caméra",
|
||||
"label": "Sélectionner votre entrée vidéo",
|
||||
"disabled": "Caméra désactivée",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Aperçu vidéo activé",
|
||||
"disabled": "Aperçu vidéo désactivé"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Résolution",
|
||||
"publish": {
|
||||
"label": "Sélectionner votre résolution d'envoi (max.)",
|
||||
"items": {
|
||||
"high": "Haute définition",
|
||||
"medium": "Définition standard",
|
||||
"low": "Basse définition"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Sélectionner votre résolution de réception (max.)",
|
||||
"items": {
|
||||
"high": "Haute définition (automatique)",
|
||||
"medium": "Définition standard",
|
||||
"low": "Basse définition"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Autorisations nécessaires"
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications sonores",
|
||||
"label": "la notification sonore pour",
|
||||
@@ -85,7 +54,6 @@
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications"
|
||||
}
|
||||
|
||||
@@ -7,18 +7,14 @@
|
||||
"home": "Keer terug naar het hoofdscherm",
|
||||
"back": "Sluit weer bij de vergadering aan"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Bezig met laden…",
|
||||
"select": "Selecteer een waarde",
|
||||
"permissionsNeeded": "Toestemming vereist",
|
||||
"settings": {
|
||||
"audio": "Audio-instellingen",
|
||||
"video": "Video-instellingen"
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Bezig met laden…",
|
||||
"permissionNeeded": "Toestemming vereist"
|
||||
},
|
||||
"effects": "Pas effecten toe",
|
||||
"videoinput": {
|
||||
"choose": "Selecteer camera",
|
||||
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
||||
"permissionNeeded": "Selecteer camera - Toestemming vereist",
|
||||
"disable": "Camera uitschakelen",
|
||||
"enable": "Camera inschakelen",
|
||||
"label": "Camera",
|
||||
@@ -26,17 +22,11 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Selecteer microfoon",
|
||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||
"permissionNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||
"disable": "Microfoon dempen",
|
||||
"enable": "Microfoon dempen opheffen",
|
||||
"label": "Microfoon"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Selecteer luidspreker",
|
||||
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Pas effecten toe",
|
||||
"title": "Effecten",
|
||||
|
||||
@@ -30,37 +30,6 @@
|
||||
},
|
||||
"permissionsRequired": "Machtigingen vereist"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Camera",
|
||||
"label": "Selecteer uw video-ingang",
|
||||
"disabled": "Camera uitgeschakeld",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Videovoorbeeld ingeschakeld",
|
||||
"disabled": "Videovoorbeeld uitgeschakeld"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Resolutie",
|
||||
"publish": {
|
||||
"label": "Selecteer uw verzendresolutie (max.)",
|
||||
"items": {
|
||||
"high": "Hoge definitie",
|
||||
"medium": "Standaarddefinitie",
|
||||
"low": "Lage definitie"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Selecteer uw ontvangstresolutie (max.)",
|
||||
"items": {
|
||||
"high": "Hoge definitie (automatisch)",
|
||||
"medium": "Standaarddefinitie",
|
||||
"low": "Lage definitie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Machtigingen vereist"
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Geluidsmeldingen",
|
||||
"label": "Geluidsmeldingen voor",
|
||||
@@ -85,7 +54,6 @@
|
||||
"tabs": {
|
||||
"account": "Profiel",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Algemeen",
|
||||
"notifications": "Meldingen"
|
||||
}
|
||||
|
||||
@@ -46,18 +46,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
transform: 'rotate(180deg) translateY(-1px)',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
light: {},
|
||||
dark: {
|
||||
fill: 'primaryDark.50',
|
||||
stroke: 'primaryDark.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -68,8 +56,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
withArrow = true,
|
||||
...dialogProps
|
||||
}: {
|
||||
children: [
|
||||
@@ -78,24 +64,20 @@ export const Popover = ({
|
||||
| (({ close }: { close: () => void }) => ReactNode)
|
||||
| ReactNode,
|
||||
]
|
||||
variant?: 'dark' | 'light'
|
||||
withArrow?: boolean
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
} & DialogProps) => {
|
||||
const [trigger, popoverContent] = children
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
{withArrow && (
|
||||
<StyledOverlayArrow variant={variant}>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
<path d="M0 0 L6 6 L12 0" />
|
||||
</svg>
|
||||
</StyledOverlayArrow>
|
||||
)}
|
||||
<StyledOverlayArrow>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
<path d="M0 0 L6 6 L12 0" />
|
||||
</svg>
|
||||
</StyledOverlayArrow>
|
||||
<Dialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
<Box size="sm" type="popover">
|
||||
{typeof popoverContent === 'function'
|
||||
? popoverContent({ close })
|
||||
: popoverContent}
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Select as RACSelect,
|
||||
SelectProps as RACSelectProps,
|
||||
SelectProps,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { css } from '@/styled-system/css'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
base: {
|
||||
@@ -42,31 +41,6 @@ const StyledButton = styled(Button, {
|
||||
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
light: {},
|
||||
dark: {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'white',
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primaryDark.900',
|
||||
color: 'primaryDark.100',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primaryDark.300',
|
||||
color: 'white',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'primaryDark.700 !important',
|
||||
color: 'primaryDark.100 !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledSelectValue = styled(SelectValue, {
|
||||
@@ -88,32 +62,23 @@ const StyledIcon = styled('div', {
|
||||
},
|
||||
})
|
||||
|
||||
export type SelectProps<T> = Omit<
|
||||
RACSelectProps<object>,
|
||||
'items' | 'label' | 'errors'
|
||||
> & {
|
||||
iconComponent?: RemixiconComponentType
|
||||
label: ReactNode
|
||||
items: Array<{ value: T; label: ReactNode }>
|
||||
errors?: ReactNode
|
||||
placement?: Placement
|
||||
variant?: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
label,
|
||||
iconComponent,
|
||||
items,
|
||||
errors,
|
||||
placement,
|
||||
variant = 'light',
|
||||
...props
|
||||
}: SelectProps<T>) => {
|
||||
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
|
||||
iconComponent?: RemixiconComponentType
|
||||
label: ReactNode
|
||||
items: Array<{ value: T; label: ReactNode }>
|
||||
errors?: ReactNode
|
||||
}) => {
|
||||
const IconComponent = iconComponent
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{label}
|
||||
<StyledButton variant={variant}>
|
||||
<StyledButton>
|
||||
{!!IconComponent && (
|
||||
<StyledIcon>
|
||||
<IconComponent size={18} />
|
||||
@@ -125,16 +90,13 @@ export const Select = <T extends string | number>({
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
</StyledButton>
|
||||
<StyledPopover placement={placement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
<StyledPopover>
|
||||
<Box size="sm" type="popover" variant="control">
|
||||
<ListBox>
|
||||
{items.map((item) => (
|
||||
<ListBoxItem
|
||||
className={
|
||||
menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
}).item
|
||||
menuRecipe({ extraPadding: true, variant: 'light' }).item
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
|
||||
@@ -22,15 +22,16 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
borderRadius: '1.143rem',
|
||||
transition: 'all 200ms, outline 200ms',
|
||||
_before: {
|
||||
willChange: 'transform',
|
||||
content: '""',
|
||||
display: 'block',
|
||||
margin: '0.125rem',
|
||||
width: '1.063rem',
|
||||
height: '1.063rem',
|
||||
borderRadius: '1.063rem',
|
||||
background: 'primary.800',
|
||||
transition: 'transform 200ms, background-color 200ms',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.800',
|
||||
background: 'white',
|
||||
transition: 'opacity 10ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
},
|
||||
@@ -43,29 +44,14 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
color: 'primary.800',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
'& .cross': {
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
top: '50%',
|
||||
left: '0.375rem',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'white',
|
||||
fontSize: '0.70rem',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: 1,
|
||||
transition: 'opacity 200ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
'&[data-selected] .indicator': {
|
||||
borderColor: 'primary.800',
|
||||
background: 'primary.800',
|
||||
_before: {
|
||||
border: 'none',
|
||||
background: 'white',
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
@@ -75,11 +61,6 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
transition: 'opacity 30ms',
|
||||
transitionDelay: '150ms',
|
||||
},
|
||||
'&[data-selected] .cross': {
|
||||
opacity: 0,
|
||||
transition: 'opacity 10ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
'&[data-disabled] .indicator': {
|
||||
borderColor: 'primary.200',
|
||||
background: 'transparent',
|
||||
@@ -87,9 +68,6 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
background: 'primary.200',
|
||||
},
|
||||
},
|
||||
'&[data-disabled] .cross': {
|
||||
color: 'primary.500',
|
||||
},
|
||||
'&[data-focus-visible] .indicator': {
|
||||
outline: '2px solid!',
|
||||
outlineColor: 'focusRing!',
|
||||
@@ -108,12 +86,7 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
|
||||
export const Switch = ({ children, ...props }: SwitchProps) => (
|
||||
<StyledSwitch {...props}>
|
||||
<div className="indicator">
|
||||
<span className="checkmark" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<span className="cross" aria-hidden="true">
|
||||
✕
|
||||
</span>
|
||||
<span className="checkmark">✓</span>
|
||||
</div>
|
||||
{children}
|
||||
</StyledSwitch>
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ type State = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const screenSharePreferenceStore = proxy<State>({
|
||||
export const ScreenSharePreferenceStore = proxy<State>({
|
||||
enabled: true,
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
type State = {
|
||||
areSettingsOpen: boolean
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
}
|
||||
|
||||
export const settingsStore = proxy<State>({
|
||||
areSettingsOpen: false,
|
||||
defaultSelectedTab: undefined,
|
||||
})
|
||||
@@ -5,24 +5,15 @@ import {
|
||||
saveUserChoices,
|
||||
LocalUserChoices as LocalUserChoicesLK,
|
||||
} from '@livekit/components-core'
|
||||
import { VideoQuality } from 'livekit-client'
|
||||
|
||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorSerialized?: ProcessorSerialized
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
videoPublishResolution?: VideoResolution
|
||||
videoSubscribeQuality?: VideoQuality
|
||||
}
|
||||
|
||||
function getUserChoicesState(): LocalUserChoices {
|
||||
return {
|
||||
noiseReductionEnabled: false,
|
||||
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
||||
videoPublishResolution: 'h720',
|
||||
videoSubscribeQuality: VideoQuality.HIGH,
|
||||
...loadUserChoices(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -78,7 +78,8 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -35,7 +35,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -79,7 +79,15 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
@@ -94,6 +102,13 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -57,7 +57,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -100,7 +100,15 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
@@ -115,6 +123,13 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kc-postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: kc-postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
serviceName: "kc-postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: keycloak
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycloak
|
||||
spec:
|
||||
rules:
|
||||
- host: "keycloak.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycloak
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- hosts:
|
||||
- keycloak.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-keycloak
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: tcp-keycloak
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: realm
|
||||
data:
|
||||
meet.json: |
|
||||
{{ .Values.realm | indent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
serviceName: "keycloak"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: keycloak
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
args:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname=keycloak.127.0.0.1.nip.io
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: tcp-keycloak
|
||||
env:
|
||||
- name: KEYCLOAK_ADMIN
|
||||
value: admin
|
||||
- name: KEYCLOAK_ADMIN_PASSWORD
|
||||
value: admin
|
||||
- name: PROXY_ADDRESS_FORWARDING
|
||||
value: 'true'
|
||||
- name: KC_DB_URL_HOST
|
||||
value: kc_postgresql
|
||||
- name: KC_DB_URL_DATABASE
|
||||
value: keycloak
|
||||
- name: KC_DB_PASSWORD
|
||||
value: pass
|
||||
- name: KC_DB_USERNAME
|
||||
value: dinum
|
||||
- name: KC_DB_SCHEMA
|
||||
value: public
|
||||
volumeMounts:
|
||||
- name: realm
|
||||
mountPath: "/opt/keycloak/data/import"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: realm
|
||||
configMap:
|
||||
name: realm
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio
|
||||
spec:
|
||||
rules:
|
||||
- host: "minio.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9001
|
||||
tls:
|
||||
- hosts:
|
||||
- minio.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
- name: console
|
||||
port: 9001
|
||||
protocol: TCP
|
||||
targetPort: 9001
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
containers:
|
||||
- name: minio
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
minio server --console-address :9001 /data
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
value: meet
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
value: password
|
||||
image: "minio/minio"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
- containerPort: 9001
|
||||
name: console
|
||||
volumeMounts:
|
||||
- mountPath: /data
|
||||
name: data
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir:
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-bucket
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 1
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
serviceName: "postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: meet
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-master
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-redis
|
||||
port: 6379
|
||||
protocol: TCP
|
||||
targetPort: tcp-redis
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: redis
|
||||
data:
|
||||
redis.conf: |
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
user default on >pass ~* &* +@all
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
args:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
image: "redis:8.2-alpine"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: tcp-redis
|
||||
volumeMounts:
|
||||
- name: redis
|
||||
mountPath: "/usr/local/etc/redis"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: redis
|
||||
configMap:
|
||||
name: redis
|
||||
+99
-2
@@ -13,10 +13,109 @@ environments:
|
||||
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||
|
||||
repositories:
|
||||
- name: bitnami
|
||||
url: registry-1.docker.io/bitnamicharts
|
||||
oci: true
|
||||
- name: livekit
|
||||
url: https://helm.livekit.io
|
||||
|
||||
releases:
|
||||
- name: postgres
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/postgresql
|
||||
version: 13.1.5
|
||||
values:
|
||||
- auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: meet
|
||||
- tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
|
||||
- name: keycloak
|
||||
installed: {{ or (eq .Environment.Name "dev-keycloak") (eq .Environment.Name "dev-dinum") | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/keycloak
|
||||
version: 17.3.6
|
||||
values:
|
||||
- postgresql:
|
||||
auth:
|
||||
username: keycloak
|
||||
password: keycloak
|
||||
database: keycloak
|
||||
- extraEnvVars:
|
||||
- name: KEYCLOAK_EXTRA_ARGS
|
||||
value: "--import-realm"
|
||||
- name: KC_HOSTNAME_URL
|
||||
value: https://keycloak.127.0.0.1.nip.io
|
||||
- extraVolumes:
|
||||
- name: import
|
||||
configMap:
|
||||
name: meet-keycloak
|
||||
- extraVolumeMounts:
|
||||
- name: import
|
||||
mountPath: /opt/bitnami/keycloak/data/import/
|
||||
- auth:
|
||||
adminUser: su
|
||||
adminPassword: su
|
||||
- proxy: edge
|
||||
- ingress:
|
||||
enabled: true
|
||||
hostname: keycloak.127.0.0.1.nip.io
|
||||
- extraDeploy:
|
||||
- apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-keycloak
|
||||
data:
|
||||
meet.json: |
|
||||
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 14 }}
|
||||
|
||||
- name: minio
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
namespace: {{ .Namespace }}
|
||||
missingFileHandler: Warn
|
||||
chart: bitnami/minio
|
||||
version: 12.10.10
|
||||
values:
|
||||
- auth:
|
||||
rootUser: meet
|
||||
rootPassword: password
|
||||
- provisioning:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: meet-media-storage
|
||||
versioning: true
|
||||
- ingress:
|
||||
enabled: true
|
||||
hostname: minio-console.127.0.0.1.nip.io
|
||||
servicePort: 9001
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
kubernetes.io/ingress.class: nginx
|
||||
extraVolumes:
|
||||
- name: mkcert
|
||||
secret:
|
||||
secretName: mkcert
|
||||
extraVolumeMounts:
|
||||
- mountPath: /certs/CAs/
|
||||
name: mkcert
|
||||
|
||||
- name: redis
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/redis
|
||||
version: 18.19.2
|
||||
values:
|
||||
- auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
- name: extra
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
@@ -30,8 +129,6 @@ releases:
|
||||
enablePermanentRedirect: {{ .Values | get "enablePermanentRedirect" "False"}}
|
||||
oldDomain: {{ .Values | get "oldDomain" "demo.com" }}
|
||||
newDomain: {{ .Values | get "newDomain" "demo.com" }}
|
||||
- realm: |
|
||||
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 8 }}
|
||||
|
||||
- name: meet
|
||||
version: {{ .Values.version }}
|
||||
|
||||
@@ -17,18 +17,9 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
celery_worker:
|
||||
container_name: celery_worker_transcribe
|
||||
container_name: celery_worker
|
||||
build: .
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe_queue
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
- redis
|
||||
- app
|
||||
celery_summarize_worker:
|
||||
container_name: celery_worker_summarize
|
||||
build: .
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize_queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
|
||||
@@ -6,13 +6,12 @@ from typing import Optional
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from summary.core.config import get_settings
|
||||
|
||||
from summary.core.celery_worker import (
|
||||
process_audio_transcribe_summarize,
|
||||
process_audio_transcribe_summarize_v2,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
class TaskCreation(BaseModel):
|
||||
"""Task data."""
|
||||
@@ -46,8 +45,7 @@ async def create_task(request: TaskCreation):
|
||||
request.room,
|
||||
request.recording_date,
|
||||
request.recording_time,
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
]
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
@@ -48,24 +48,6 @@ class Analytics:
|
||||
except Exception as e:
|
||||
raise AnalyticsException("Failed to capture analytics event") from e
|
||||
|
||||
def is_feature_enabled(
|
||||
self, feature_name: str, distinct_id: str = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a feature flag is enabled in PostHog for a dinstinct_id.
|
||||
"""
|
||||
if self.is_disabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Check feature flag {feature_name} for user {distinct_id}"
|
||||
)
|
||||
return self._client.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking feature flag {feature_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_analytics():
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from celery import Celery
|
||||
from celery import signals
|
||||
import sentry_sdk
|
||||
from summary.core.config import get_settings
|
||||
import summary.core.celery_signals
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
)
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
@@ -1,26 +0,0 @@
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from celery import signals
|
||||
from summary.core.config import get_settings
|
||||
|
||||
analytics = get_analytics()
|
||||
settings = get_settings()
|
||||
metadata_manager = MetadataManager()
|
||||
|
||||
|
||||
@signals.task_prerun.connect
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Celery summarize workers."""
|
||||
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
import openai
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.prompt import (
|
||||
PROMPT_SYSTEM_PLAN,
|
||||
PROMPT_SYSTEM_TLDR,
|
||||
PROMPT_SYSTEM_PART,
|
||||
PROMPT_USER_PART,
|
||||
PROMPT_SYSTEM_CLEANING,
|
||||
PROMPT_SYSTEM_NEXT_STEP,
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.celery_app import celery
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
def post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = create_retry_session()
|
||||
session.headers.update({"Authorization": f"Bearer {settings.webhook_api_token}"})
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def LLM_call(client, system_prompt, user_prompt, retry=2):
|
||||
|
||||
data = {
|
||||
"model": settings.resume_llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(**data)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error("LLM call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True, autoretry_for=[Exception], max_retries=3, queue=settings.summarize_queue
|
||||
)
|
||||
def summarize_transcription(self, transcript: str, email: str, sub: str, title: str):
|
||||
logger.info("Starting summarization task")
|
||||
|
||||
logger.info("Initiating summarize client")
|
||||
|
||||
client_summary = openai.OpenAI(
|
||||
base_url=settings.resume_endpoint, api_key=settings.resume_api_key
|
||||
)
|
||||
|
||||
tldr = LLM_call(client_summary, PROMPT_SYSTEM_TLDR, transcript)
|
||||
|
||||
logger.info("TLDR generated")
|
||||
|
||||
parts = LLM_call(client_summary, PROMPT_SYSTEM_PLAN, transcript)
|
||||
logger.info("Plan generated")
|
||||
|
||||
parts = parts.split("\n")
|
||||
parts = [x for x in parts if x.strip() != ""]
|
||||
logger.info("Empty parts removed")
|
||||
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
logger.info("Summarizing part: %s", part)
|
||||
parts_summarized.append(
|
||||
LLM_call(
|
||||
client_summary, PROMPT_SYSTEM_PART, prompt_user_part.format(part=part)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Parts summarized")
|
||||
|
||||
raw_summary = "\n\n".join(parts_summarized)
|
||||
|
||||
next_steps = LLM_call(client_summary, PROMPT_SYSTEM_NEXT_STEP, transcript)
|
||||
logger.info("Next steps generated")
|
||||
cleaned_summary = LLM_call(client_summary, PROMPT_SYSTEM_CLEANING, raw_summary)
|
||||
logger.info("Summary cleaned")
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
|
||||
data = {
|
||||
"title": title + " - Summary",
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
@@ -19,18 +19,33 @@ from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.prompt import get_instructions
|
||||
from summary.core.celery_summarize_worker import summarize_transcription
|
||||
from summary.core.celery_app import celery
|
||||
from summary.core.celery_signals import metadata_manager
|
||||
from summary.core.analytics import get_analytics
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
|
||||
metadata_manager = MetadataManager()
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
)
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
DEFAULT_EMPTY_TRANSCRIPTION = """
|
||||
**Aucun contenu audio n’a été détecté dans votre transcription.**
|
||||
@@ -122,6 +137,25 @@ def post_with_retries(url, data):
|
||||
session.close()
|
||||
|
||||
|
||||
@signals.task_prerun.connect
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
|
||||
|
||||
@celery.task(max_retries=settings.celery_max_retries)
|
||||
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
@@ -175,7 +209,7 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
|
||||
instructions = get_instructions(transcription)
|
||||
summary_response = openai_client.chat.completions.create(
|
||||
model=settings.resume_llm_model, messages=instructions
|
||||
model=settings.openai_llm_model, messages=instructions
|
||||
)
|
||||
|
||||
summary = summary_response.choices[0].message.content
|
||||
@@ -202,7 +236,6 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
@@ -320,10 +353,5 @@ def process_audio_transcribe_summarize_v2(
|
||||
logger.debug("Response body: %s", response.text)
|
||||
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
if not analytics.is_feature_enabled("summary-enabled", distinct_id=sub):
|
||||
logger.info("Summary generation skipped (feature flag disabled).")
|
||||
else:
|
||||
logger.info("Queuing summary generation task.")
|
||||
summarize_transcription.apply_async(
|
||||
args=[formatted_transcription, email, sub, title], queue=settings.summarize_queue
|
||||
)
|
||||
|
||||
# TODO - integrate summarize the transcript and create a new document.
|
||||
|
||||
@@ -35,10 +35,8 @@ class Settings(BaseSettings):
|
||||
openai_api_key: str
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_asr_model: str = "whisper-1"
|
||||
resume_llm_model: str = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
|
||||
openai_llm_model: str = "gpt-4o"
|
||||
openai_max_retries: int = 0
|
||||
resume_api_key: str
|
||||
resume_endpoint: str
|
||||
|
||||
# Webhook-related settings
|
||||
webhook_max_retries: int = 2
|
||||
@@ -68,9 +66,7 @@ class Settings(BaseSettings):
|
||||
task_tracker_redis_url: str = "redis://redis/0"
|
||||
task_tracker_prefix: str = "task_metadata:"
|
||||
|
||||
# Queue redis
|
||||
summarize_queue: str = "summarize_queue"
|
||||
transcribe_queue: str = "transcribe_queue"
|
||||
|
||||
@lru_cache
|
||||
def get_settings():
|
||||
"""Load and cache application settings."""
|
||||
|
||||
@@ -50,34 +50,3 @@ def get_instructions(transcript):
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
|
||||
PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (résumé très concis) d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est de rédiger un résumé concis et structuré, en te concentrant uniquement sur les informations essentielles et pertinentes. Tu répondras en un paragraphe structuré (3 à 6 phrases), sans rien ajouter d'autre. Tu répondras dans le format suivant sans rien ajouter d'autre:
|
||||
### Résumé TL;DR
|
||||
|
||||
[Résumé concis et structuré]"""
|
||||
|
||||
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et qu’aucun thème ne soit répété. Tu te limitera à 5 ou 6 sujets maximum.
|
||||
L'introducion, ordre du jour, conclusion etc seront rajoutés a posteriori. Tu répondra dans le format suivant sans rien ajouter d'autre:
|
||||
"Titre du sujet 1
|
||||
Titre du sujet 2
|
||||
Titre du sujet 3
|
||||
..."
|
||||
"""
|
||||
|
||||
PROMPT_SYSTEM_PART = """Tu es un agent dont le rôle est de créer une partie du résumé d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript, et le titre du sujet correspondant. Ta tâche est de rédiger un résumé concis de cette partie et uniquement cette partie, en te concentrant uniquement sur les informations essentielles et pertinentes. Le résumé de chaque partie doit tenir en 4 à 6 phrases maximum, sans entrer dans les détails mineurs. Tu répondra dans le format suivant :
|
||||
### Titre du sujet [Traduire ce titre selon la langue du transcript]
|
||||
[Résumé concis et structuré de la partie du transcript]
|
||||
"""
|
||||
|
||||
PROMPT_USER_PART = """Titre de la partie à résumer : {part}
|
||||
|
||||
Transcript complet :
|
||||
{transcript}"""
|
||||
|
||||
PROMPT_SYSTEM_CLEANING = """Tu es un agent dont le rôle est de nettoyer un résumé de compte rendu de réunion. Tu recevras en entrée le résumé brut, potentiellement avec des erreurs de formatage, des incohérences ou des redondances. Ta tâche est de corriger les erreurs de formatage, d'améliorer la clarté et la cohérence du texte, et de t'assurer que le résumé est bien structuré et facile à lire. Ton but principal est de retirer les redondances et les répétitions. Assure la cohérence entre les titres, et homogénéise le style d’écriture entre les parties. Supprime les doublons d’informations entre les parties si présents. Si certaines parties sont plus secondaires, tu peux les fusionner ou les réduire en 1 à 2 phrases. Mets en avant les points centraux qui ont fait l’objet de décisions ou d’actions. Tu répondra uniquement avec le résumé sans rien ajouter d'autre"""
|
||||
|
||||
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite. Les actions doivent suivre ce format strict :
|
||||
### Prochaines étapes
|
||||
|
||||
- [ ] [Action à effectuer] Assignée à : [Nom], Échéance : [Date si mentionnée]"""
|
||||
|
||||
Reference in New Issue
Block a user