Compare commits

..

23 Commits

Author SHA1 Message Date
lebaudantoine 419892d441 wip manually mute/unmute track 2025-07-31 21:48:36 +02:00
lebaudantoine 9b1b51da1e wip arrow up
it makes more sense to have the arrow up as the menu open to the top
2025-07-31 21:10:31 +02:00
lebaudantoine fc54486645 wip handle permission issue in SelectDeviceJoin component 2025-07-31 21:10:31 +02:00
lebaudantoine d669f4b0e6 wip refactor the hook permission
avoid having several event listener, maintain a global state
2025-07-31 21:10:31 +02:00
lebaudantoine 4593728812 wip refacto responsive join screen 2025-07-31 21:10:31 +02:00
lebaudantoine 4d3fac8b56 wip prevent interacting with empty select in device selection 2025-07-31 21:10:31 +02:00
lebaudantoine 727f0c362f wip disable effects configurations when permissions are disabled 2025-07-31 21:10:31 +02:00
lebaudantoine fca58114ed wip import join 2025-07-31 21:10:31 +02:00
lebaudantoine 66e9913c58 refactor usePreview to create track separately when needed 2025-07-31 21:10:31 +02:00
lebaudantoine b3795bc9a3 wip warning 2025-07-31 21:10:31 +02:00
lebaudantoine a3c2406f9d wip encapsulate it in the hook 2025-07-31 21:10:31 +02:00
lebaudantoine 597406f550 vendor the usePreviewTracks to adapt it
only modification I made was on the roomOptionsStringifyReplacer
I ignore processor to avoid stopping / starting local track when a processor
is changed or apply
2025-07-31 21:10:31 +02:00
lebaudantoine 0183afcf77 wip handle permission when toggling device + display modal to guide user 2025-07-31 21:10:31 +02:00
lebaudantoine 1eff4e1477 wip introduce permissionErrorDialog
add svg to guide user to give permissions
2025-07-31 21:10:28 +02:00
lebaudantoine 4a25279555 wip add store and hook to gloablly manage model openness when necessary 2025-07-31 21:10:28 +02:00
lebaudantoine e3251bb980 wip hook to watch permissions 2025-07-31 21:10:28 +02:00
lebaudantoine 1ae08420a4 wip allow selecting audioutput from join screen 2025-07-31 21:09:00 +02:00
lebaudantoine e29c241d4e wip init audioOutput device with user choice and persist it 2025-07-29 14:21:00 +02:00
lebaudantoine f1cf14ae31 wip persist audioOutputDeviceId in local storage 2025-07-29 00:13:44 +02:00
lebaudantoine b9e924a304 wip add an icon to the select primitive 2025-07-29 00:13:13 +02:00
lebaudantoine 35125d965c wip 2025-07-29 00:01:36 +02:00
lebaudantoine f847f07c24 wip refactor effects configuration
- add a button to explicitely enable camera when a room
display selected option even if camera is disabled, and suggest to the user to enable its
camera to allow her configure the effects

- remove useless useSyncAfterDelay
issue with rendering when enabling a processor, livekit is
working on it, it's a bad approach to fix it with a deteministic delay
it doesn't work and complexify the code

I thought about listening to some html event however, we should fix the root cause instead
2025-07-28 18:30:51 +02:00
lebaudantoine 3e94ed0a8b wip refactor effects prejoin
- avoid props drillig, rely on localstorage
- using an explicit flag to trigger the entry instead of depending of userconfig state (better encapsulation)
- init processor from video initiation, avoid weird blink, less hook and callback

overall, simplify and improve the user experience
2025-07-28 12:17:21 +02:00
33 changed files with 1350 additions and 393 deletions
-2
View File
@@ -339,8 +339,6 @@ These are the environmental options available on meet backend.
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
-1
View File
@@ -52,7 +52,6 @@ def get_frontend_configuration(request):
},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
},
}
-3
View File
@@ -492,9 +492,6 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
<defs>
<style>
.st0, .st1, .st2, .st3, .st4, .st5 {
fill: none;
}
.st6 {
fill: #6dd58c;
}
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
stroke-linecap: round;
stroke-linejoin: round;
}
.st6, .st1, .st2, .st7, .st8, .st10 {
stroke-width: 3px;
}
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
stroke: #191c1e;
}
.st11, .st7, .st12, .st9 {
fill: #fff;
}
.st11, .st3 {
stroke: #000;
stroke-miterlimit: 10;
}
.st11, .st3, .st9 {
stroke-width: 5px;
}
.st2, .st9 {
stroke: #898989;
}
.st8 {
fill: #ff8669;
}
.st13 {
fill: #e3e3fb;
}
.st14 {
fill: #f7f7f7;
}
.st4, .st5 {
stroke-width: 4px;
}
.st5 {
stroke-linecap: square;
}
.st15 {
fill: #ca3632;
}
.st10 {
fill: #ffb929;
}
</style>
</defs>
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<g>
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
<path class="st5" d="M624,299h63"/>
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
<path class="st5" d="M728,339h-63"/>
</g>
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
<g>
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
</g>
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

-1
View File
@@ -39,7 +39,6 @@ export interface ApiConfig {
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom } from '@livekit/components-react'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
@@ -29,18 +32,20 @@ import { isFireFox } from '@/utils/livekit'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices
}
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
@@ -92,9 +97,16 @@ export const Conference = ({
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.audioDeviceId])
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -152,20 +164,6 @@ export const Conference = ({
kind: null,
})
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
@@ -189,7 +187,7 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={serverUrl}
serverUrl={apiConfig?.livekit.url}
token={data?.livekit?.token}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
@@ -1,14 +1,12 @@
import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
@@ -27,7 +25,10 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { LocalUserChoices } from '@/stores/userChoices'
import { ToggleDeviceJoin } from '@/features/rooms/components/ToggleDeviceJoin'
import { SelectDeviceJoin } from '@/features/rooms/components/SelectDeviceJoin'
import { usePreviewTracks } from '@/features/rooms/hooks/usePreviewTracks'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
const onError = (e: Error) => console.error('ERROR', e)
@@ -72,45 +73,23 @@ const Effects = ({
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
borderBottomRadius: '1rem',
})}
/>
<RiImageCircleAiFill size={24} />
</Button>
</>
)
}
export const Join = ({
onSubmit,
enterRoom,
roomId,
}: {
onSubmit: (choices: LocalUserChoices) => void
enterRoom: () => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -120,6 +99,7 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
@@ -127,55 +107,43 @@ export const Join = ({
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveAudioOutputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
// fix - permission issue, when permission not set doesn't refresh
const { videoTrack, audioTrack } = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalVideoTrack,
[tracks]
)
const toggleTrack = (track: LocalTrack | undefined, isEnabled: boolean) => {
if (!track) return
if (isEnabled) {
track?.unmute()
} else {
track?.mute()
}
}
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
isVideoInitiated.current = true
videoElement.style.opacity = '1'
}
}
@@ -192,25 +160,6 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
const enterRoom = useCallback(() => {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}, [
onSubmit,
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processor,
])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
@@ -269,15 +218,28 @@ export const Join = ({
enterRoom()
}
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
useEffect(() => {
if (processor && videoTrack && !videoTrack.getProcessor()) {
videoTrack.setProcessor(processor)
const { isCameraGranted, isMicrophoneGranted } = usePermissions()
const hintMessage = useMemo(() => {
if (!isCameraGranted) {
if (!isMicrophoneGranted) {
return 'cameraAndMicNotGranted'
}
return 'cameraNotGranted'
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
if (!videoEnabled) {
return 'cameraDisabled'
}
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [videoTrack, videoEnabled, isCameraGranted, isMicrophoneGranted])
const renderWaitingState = () => {
switch (status) {
@@ -330,6 +292,10 @@ export const Join = ({
submitButtonProps={{
fullWidth: true,
}}
className={css({
width: '100%',
maxWidth: '340px',
})}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
@@ -361,141 +327,265 @@ export const Join = ({
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: 0 },
lg: {
alignItems: 'center',
flexDirection: 'row',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
width: '100%',
minWidth: 0,
maxWidth: '764px',
lg: {
flexDirection: 'row',
width: 'auto',
height: '570px',
height: '540px',
flexGrow: 1,
},
})}
>
<div
className={css({
width: '100%',
lg: {
width: '740px',
},
display: 'inline-flex',
flexDirection: 'column',
flexGrow: 1,
minWidth: 0,
flexShrink: { base: 0, sm: 1 },
})}
>
<div
className={css({
borderRadius: '1rem',
flex: '0 1',
minWidth: '320px',
margin: {
base: '0.5rem',
sm: '1rem',
lg: '1rem 0.5rem 1rem 1rem',
},
overflow: 'hidden',
position: 'relative',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
})}
>
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
disablePictureInPicture
disableRemotePlayback
/>
) : (
<div
className={css({
position: 'relative',
width: '100%',
height: 'fit-content',
aspectRatio: '16 / 9',
})}
>
<div
className={css({
backgroundColor: 'black',
position: 'absolute',
boxSizing: 'border-box',
top: 0,
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
})}
>
<p
<div
aria-label={t(
`videoPreview.${videoEnabled && isCameraGranted ? 'enabled' : 'disabled'}`
)}
role="status"
className={css({
fontSize: '24px',
fontWeight: '300',
position: 'absolute',
top: 0,
width: '100%',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
<div
className={css({
width: '100%',
height: 'auto',
aspectRatio: '16 / 9',
overflow: 'hidden',
position: 'absolute',
top: '-2px',
left: '-2px',
pointerEvents: 'none',
transform: 'scale(1.02)',
})}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoEl}
width="1280"
height="720"
style={{
display:
!videoEnabled || !isCameraGranted
? 'none'
: undefined,
}}
className={css({
position: 'absolute',
transform: 'rotateY(180deg)',
opacity: 0,
height: '100%',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
})}
disablePictureInPicture
disableRemotePlayback
/>
</div>
</div>
<div
role="alert"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
justifyContent: 'center',
textAlign: 'center',
alignItems: 'center',
padding: '0.24rem',
boxSizing: 'border-box',
})}
>
<p
className={css({
fontWeight: '400',
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
textWrap: 'balance',
color: 'white',
})}
>
{hintMessage && t(hintMessage)}
</p>
</div>
</div>
)}
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
<div
className={css({
position: 'absolute',
right: '50%',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1.5rem',
justifyContent: 'center',
marginRight: '-68px',
})}
>
<ToggleDeviceJoin
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(value) => {
toggleTrack(audioTrack, value)
saveAudioInputEnabled(value)
}}
/>
<ToggleDeviceJoin
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(value) => {
const videoElement =
videoEl.current as HTMLVideoElement | null
if (isVideoInitiated.current && !value && videoElement) {
isVideoInitiated.current = false
videoElement.style.opacity = '0'
}
toggleTrack(videoTrack, value)
saveVideoInputEnabled(value)
}}
/>
</div>
<div
className={css({
position: 'absolute',
right: '1rem',
bottom: '1rem',
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
<div
className={css({
display: 'none',
sm: {
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
},
})}
>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="videoinput"
id={videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
{renderWaitingState()}
</div>
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 448px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
>
{renderWaitingState()}
</div>
</div>
</Screen>
)
@@ -0,0 +1,90 @@
import { Dialog, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { usePermissions } from '../hooks/usePermissions'
import { useModal } from '../hooks/useModal'
export const PermissionErrorModal = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
const permissions = usePermissions()
const { isOpen, close } = useModal('permissions')
useEffect(() => {
if (
isOpen() &&
permissions.isCameraGranted &&
permissions.isMicrophoneGranted
) {
close()
}
}, [permissions, isOpen, close])
// Use to split the translation in half to inject an inline icon
const icon_placeholder = 'ICON_PLACEHOLDER'
const [openMenuFirstPart, openMenuSecondPart] = t('body.openMenu', {
icon_placeholder,
}).split(icon_placeholder)
const permissionLabel = useMemo(() => {
if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
return 'cameraAndMicrophone'
} else if (permissions.isCameraDenied) {
return 'camera'
} else if (permissions.isMicrophoneDenied) {
return 'microphone'
} else {
return 'default'
}
}, [permissions])
return (
<Dialog
isOpen={isOpen()}
role="dialog"
type="flex"
title={''}
aria-label={t(`heading.${permissionLabel}`, {
app_title: `${import.meta.env.VITE_APP_TITLE}`,
})}
onClose={() => close()}
>
<HStack>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
minWidth: '290px',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
app_title: `${import.meta.env.VITE_APP_TITLE}`,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{openMenuFirstPart}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{openMenuSecondPart}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</HStack>
</Dialog>
)
}
@@ -0,0 +1,113 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { Select } from '@/primitives/Select.tsx'
import { useMemo } from 'react'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
isGranted: boolean
}
type SelectDeviceJoinProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
export const SelectDeviceJoin = ({ kind, ...props }: SelectDeviceJoinProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { isMicrophoneGranted, isCameraGranted } = usePermissions()
const config = useMemo<DeviceConfig>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
isGranted: isMicrophoneGranted,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
isGranted: isMicrophoneGranted,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
isGranted: isCameraGranted,
}
}
}, [kind, isMicrophoneGranted, isCameraGranted])
if (!config.isGranted) {
return (
<Select
aria-label={'selector disabled - permissions are needed'}
label=""
items={[]}
isDisabled={true}
iconComponent={config.icon}
placeholder={t('selectDevice.permissionNeeded')}
/>
)
}
return <SelectDeviceJoinActive {...props} kind={kind} config={config} />
}
type SelectDeviceJoinActiveProps = {
config: DeviceConfig
} & SelectDeviceJoinProps
const SelectDeviceJoinActive = ({
id,
onSubmit,
kind,
config,
}: SelectDeviceJoinActiveProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const {
devices: devices,
activeDeviceId: activeDeviceId,
setActiveMediaDevice: setActiveMediaDevice,
} = useMediaDeviceSelect({ kind, requestPermissions: false })
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')}
defaultSelectedKey={id || activeDeviceId || getDefaultSelectedKey(items)}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
@@ -0,0 +1,33 @@
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { SelectToggleDeviceProps } from '@/features/rooms/livekit/components/controls/SelectToggleDevice'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice'
import {
SelectToggleSource,
ToggleSource,
} from '@/features/rooms/livekit/types/SelectToggleDevice'
import { SELECT_TOGGLE_DEVICE_CONFIG } from '@/features/rooms/livekit/config/SelectToggleDevice'
type ToggleDeviceJoinProps<T extends ToggleSource> = UseTrackToggleProps<T> &
Pick<SelectToggleDeviceProps<T>, 'track' | 'source' | 'variant'>
export const ToggleDeviceJoin = <T extends ToggleSource>(
props: ToggleDeviceJoinProps<T>
) => {
const config = SELECT_TOGGLE_DEVICE_CONFIG[props.source as SelectToggleSource]
if (!config) {
throw new Error('Invalid source')
}
const trackProps = useTrackToggle(props)
return (
<ToggleDevice
{...trackProps}
config={config}
variant="whiteCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -0,0 +1,22 @@
import { useSnapshot } from 'valtio'
import { ModalsState, modalsStore } from '@/stores/modals'
interface UseModalReturn {
isOpen: () => boolean
open: () => void
close: () => void
}
export const useModal = (name: keyof ModalsState): UseModalReturn => {
const modalsSnap = useSnapshot(modalsStore)
const isOpen = (): boolean => {
return modalsSnap[name] as boolean
}
return {
isOpen,
open: () => (modalsStore[name] = true),
close: () => (modalsStore[name] = false),
}
}
@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { permissionStore } from '@/stores/permissions.ts'
import { useSnapshot } from 'valtio'
export const usePermissionsSync = () => {
useEffect(() => {
// fixme - on safari, 'change' event is not trigger
// fixme - prevent error on old browsers
const checkPermissions = async () => {
try {
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
permissionStore.cameraPermission = cameraPermission.state
permissionStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionStore.cameraPermission = target.state
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionStore.microphonePermission = target.state
}
if (!cameraPermission.onchange) {
cameraPermission.addEventListener('change', handleCameraChange)
}
if (!microphonePermission.onchange) {
microphonePermission.addEventListener(
'change',
handleMicrophoneChange
)
}
return () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
}
} catch (error) {
console.error('Error checking permissions:', error)
}
}
checkPermissions()
}, [])
}
export const usePermissions = () => {
const permissionSnap = useSnapshot(permissionStore)
return {
cameraPermission: permissionSnap.cameraPermission,
microphonePermission: permissionSnap.microphonePermission,
isCameraDenied: permissionSnap.cameraPermission == 'denied',
isCameraPrompted: permissionSnap.cameraPermission == 'prompt',
isCameraGranted: permissionSnap.cameraPermission == 'granted',
isMicrophoneDenied: permissionSnap.microphonePermission == 'denied',
isMicrophonePrompted: permissionSnap.microphonePermission == 'prompt',
isMicrophoneGranted: permissionSnap.microphonePermission == 'granted',
}
}
@@ -0,0 +1,173 @@
import {
AudioCaptureOptions,
createLocalTracks,
CreateLocalTracksOptions,
LocalAudioTrack,
LocalTrack,
LocalVideoTrack,
Mutex,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
function roomOptionsStringifyReplacer(key: string, val: unknown) {
if (key === 'processor') {
return undefined
}
if (key === 'e2ee' && val) {
return 'e2ee-enabled'
}
return val
}
export function usePreviewTracks(
options: CreateLocalTracksOptions,
onError?: (err: Error) => void
) {
const trackLock = useMemo(() => {
return new Mutex()
}, [])
const videoTrackLock = useMemo(() => {
return new Mutex()
}, [])
const audioTrackLock = useMemo(() => {
return new Mutex()
}, [])
const isInitiated = useRef(false)
const videoTrackRef = useRef<LocalVideoTrack | undefined>()
const audioTrackRef = useRef<LocalAudioTrack | undefined>()
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | undefined>()
const [audioTrack, setAudioTrack] = useState<LocalAudioTrack | undefined>()
const extractTracks = (tracks: LocalTrack[]) => {
const video = tracks.find(
(track): track is LocalVideoTrack => track.kind === Track.Kind.Video
)
const audio = tracks.find(
(track): track is LocalAudioTrack => track.kind === Track.Kind.Audio
)
return { video, audio }
}
useEffect(() => {
if (isInitiated.current) {
return
}
let needsCleanup = false
let localTracks: Array<LocalTrack> = []
trackLock.lock().then(async (unlock) => {
try {
if (options.audio || options.video) {
localTracks = await createLocalTracks(options)
if (needsCleanup) {
localTracks.forEach((tr) => tr.stop())
} else {
const { audio, video } = extractTracks(localTracks)
isInitiated.current = true
setVideoTrack(video)
videoTrackRef.current = video
setAudioTrack(audio)
audioTrackRef.current = audio
}
}
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
return () => {
if (isInitiated.current) {
return
}
needsCleanup = true
localTracks.forEach((track) => {
track.stop()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(options, roomOptionsStringifyReplacer),
onError,
trackLock,
])
const videoOptions = options?.video
useEffect(() => {
if (!isInitiated.current) {
return
}
videoTrackLock.lock().then(async (unlock) => {
try {
if (!videoOptions) return
await videoTrackRef.current?.restartTrack(
videoOptions as VideoCaptureOptions
)
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(videoOptions, roomOptionsStringifyReplacer),
onError,
videoTrackLock,
])
const audioOptions = options?.audio
useEffect(() => {
if (!isInitiated.current) {
return
}
audioTrackLock.lock().then(async (unlock) => {
try {
if (!audioOptions) return
await audioTrackRef.current?.restartTrack(
audioOptions as AudioCaptureOptions
)
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(audioOptions, roomOptionsStringifyReplacer),
onError,
audioTrackLock,
])
return {
videoTrack,
audioTrack,
}
}
@@ -5,14 +5,7 @@ import {
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { RiArrowUpSLine } from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
@@ -20,59 +13,19 @@ import {
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import {
SelectToggleSource,
ToggleSource,
} from '../../types/SelectToggleDevice'
import { SELECT_TOGGLE_DEVICE_CONFIG } from '../../config/SelectToggleDevice'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[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,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
export type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
@@ -92,7 +45,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
const config = SELECT_TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
@@ -178,7 +131,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
<RiArrowUpSLine />
</Button>
<MenuList
items={devices.map((d) => ({
@@ -3,7 +3,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
@@ -13,6 +12,10 @@ import {
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { SelectToggleDeviceConfig } from '../../types/SelectToggleDevice'
import { css } from '@/styled-system/css'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
import { useModal } from '@/features/rooms/hooks/useModal'
export type ToggleDeviceProps = {
enabled: boolean
@@ -31,6 +34,7 @@ export const ToggleDevice = ({
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { open } = useModal('permissions')
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
@@ -46,36 +50,78 @@ export const ToggleDevice = ({
setPushToTalk(false)
}
const permissions = usePermissions()
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const isEnabledAndPermitted = enabled && !isPermissionDeniedOrPrompted
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
const label = t(isEnabledAndPermitted ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
}, [isEnabledAndPermitted, kind, shortcut, t])
const Icon = enabled ? iconOn : iconOff
const Icon = isEnabledAndPermitted ? iconOn : iconOff
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
const errorVariant = variant == 'whiteCircle' ? 'errorCircle' : 'error2'
return (
<ToggleButton
isSelected={!enabled}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
<div
className={css({
position: 'relative',
})}
>
<Icon />
</ToggleButton>
<ToggleButton
isSelected={!enabled}
variant={isEnabledAndPermitted ? variant : errorVariant}
shySelected
onPress={() => (isPermissionDeniedOrPrompted ? open() : toggle())}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
{isPermissionDeniedOrPrompted && (
<span
className={css({
position: 'absolute',
height: '1.25rem',
width: '1.25rem',
backgroundColor: 'amber.400',
top: variant == 'whiteCircle' ? '-1px' : '-5px',
left: variant == 'whiteCircle' ? '-2px' : '-5px',
borderRadius: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: 'bold',
})}
>
!
</span>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundProcessorFactory,
@@ -8,16 +8,16 @@ import {
BackgroundOptions,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
import { Text, P, ToggleButton, H, Button } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
import { useModal } from '@/features/rooms/hooks/useModal'
enum BlurRadius {
NONE = 0,
@@ -37,7 +37,7 @@ const Information = styled('div', {
})
export type EffectsConfigurationProps = {
videoTrack: LocalVideoTrack
videoTrack?: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}
@@ -50,10 +50,20 @@ export const EffectsConfiguration = ({
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const { isCameraGranted } = usePermissions()
const { open } = useModal('permissions')
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
// Note: videoTrack.getProcessor() will return undefined during a transition
// but our selectedProcessor state maintains the intended value
const [selectedProcessor, setSelectedProcessor] = useState(
videoTrack?.getProcessor()
)
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
@@ -63,12 +73,13 @@ export const EffectsConfiguration = ({
return () => {
if (!videoElement) return
videoTrack.detach(videoElement)
videoTrack?.detach(videoElement)
}
}, [videoTrack, videoTrack?.isMuted])
const clearEffect = async () => {
await videoTrack.stopProcessor()
await videoTrack?.stopProcessor()
setSelectedProcessor(undefined)
onSubmit?.(undefined)
}
@@ -92,7 +103,6 @@ export const EffectsConfiguration = ({
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
@@ -100,9 +110,10 @@ export const EffectsConfiguration = ({
await toggle(true)
}
const processor = getProcessor()
const processor = videoTrack?.getProcessor() as BackgroundProcessorInterface
try {
if (isSelected(type, options)) {
setSelectedProcessor(undefined)
// Stop processor.
await clearEffect()
} else if (!processor || processor.serialize().type !== type) {
@@ -118,28 +129,27 @@ export const EffectsConfiguration = ({
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
await videoTrack.stopProcessor()
}
await videoTrack.setProcessor(newProcessor)
setSelectedProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
// Update processor.
processor?.update(options)
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
setSelectedProcessor(processor)
}
} catch (error) {
console.error('Error applying effect:', error)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
setProcessorPending(false)
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
if (!selectedProcessor) return false
const processor = selectedProcessor as BackgroundProcessorInterface
const processorSerialized = processor?.serialize()
return (
!!processor &&
@@ -152,6 +162,11 @@ export const EffectsConfiguration = ({
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
const isDisabled = useMemo(
() => processorPending || videoTrack?.isMuted || !isCameraGranted,
[processorPending, videoTrack, isCameraGranted]
)
return (
<div
className={css(
@@ -165,7 +180,7 @@ export const EffectsConfiguration = ({
display: 'flex',
gap: '1.5rem',
flexDirection: 'column',
md: {
lg: {
flexDirection: 'row',
overflow: 'hidden',
},
@@ -175,31 +190,65 @@ export const EffectsConfiguration = ({
<div
className={css({
width: '100%',
aspectRatio: 16 / 9,
position: 'relative',
})}
>
{videoTrack && !videoTrack.isMuted ? (
<video
ref={videoRef}
width="100%"
muted
className={css(
layout === 'vertical'
? {
height: '175px',
width: '100%',
}
: {
minHeight: '175px',
maxWidth: '600px',
width: '100%',
objectFit: 'cover',
sm: {
aspectRatio: 16 / 9,
},
lg: {
maxWidth: '100%',
},
}
)}
style={{
transform: 'rotateY(180deg)',
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
borderRadius: '8px',
}}
className={css(
layout === 'vertical'
? {
height: '175px',
width: '100%',
}
: {
minHeight: '175px',
maxWidth: '600px',
width: '100%',
objectFit: 'cover',
sm: {
aspectRatio: 16 / 9,
},
lg: {
maxWidth: '100%',
},
}
)}
>
<P
style={{
@@ -209,19 +258,23 @@ export const EffectsConfiguration = ({
marginBottom: 0,
}}
>
{t('activateCamera')}
{t(isCameraGranted ? 'activateCamera' : 'permissionsCamera')}
</P>
</div>
)}
{processorPendingReveal && (
<div
className={css({
position: 'absolute',
right: '8px',
bottom: '8px',
})}
>
<Loader />
<Button
size="sm"
variant="tertiary"
onPress={async () => (isCameraGranted ? await toggle() : open())}
aria-label={t(
isCameraGranted ? 'activateButton' : 'permissionsButton'
)}
className={css({
width: 'fit-content',
marginX: 'auto',
marginTop: '1rem',
})}
>
{t(isCameraGranted ? 'activateButton' : 'permissionsButton')}
</Button>
</div>
)}
</div>
@@ -242,7 +295,7 @@ export const EffectsConfiguration = ({
{hasFunnyEffectsAccess && (
<FunnyEffects
videoTrack={videoTrack}
isPending={processorPendingReveal}
isPending={processorPending}
onPending={setProcessorPending}
/>
)}
@@ -270,8 +323,8 @@ export const EffectsConfiguration = ({
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal}
isSelected={!selectedProcessor}
isDisabled={isDisabled}
>
<RiProhibited2Line />
</ToggleButton>
@@ -283,7 +336,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
@@ -304,7 +357,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
@@ -352,7 +405,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
@@ -7,7 +7,7 @@ import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import { LocalVideoTrack } from 'livekit-client'
export type FunnyEffectsProps = {
videoTrack: LocalVideoTrack
videoTrack?: LocalVideoTrack
isPending?: boolean
onPending: (value: boolean) => void
}
@@ -47,12 +47,12 @@ export const FunnyEffects = ({
try {
if (!newOptions.showGlasses && !newOptions.showFrench) {
await videoTrack.stopProcessor()
await videoTrack?.stopProcessor()
} else if (options.showGlasses || options.showFrench) {
await processor?.update(newOptions)
} else {
const newProcessor = new FaceLandmarksProcessor(newOptions)
await videoTrack.setProcessor(newProcessor)
await videoTrack?.setProcessor(newProcessor)
}
} catch (e) {
console.error('could not update processor', e)
@@ -0,0 +1,33 @@
import { Track } from 'livekit-client'
import { SelectToggleDeviceConfigMap } from '../types/SelectToggleDevice'
import {
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
export const SELECT_TOGGLE_DEVICE_CONFIG: SelectToggleDeviceConfigMap = {
[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,
},
},
}
@@ -16,6 +16,9 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
@@ -0,0 +1,22 @@
import { Track } from 'livekit-client'
import { RemixiconComponentType } from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
export type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
export type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
+18 -15
View File
@@ -1,21 +1,27 @@
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { ReactNode, useEffect, useState } from 'react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { PermissionErrorModal } from '../components/PermissionErrorModal'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
import { LocalUserChoices } from '@/stores/userChoices'
import { usePermissionsSync } from '@/features/rooms/hooks/usePermissions'
const BaseRoom = ({ children }: { children: ReactNode }) => (
<UserAware>
<PermissionErrorModal />
{children}
</UserAware>
)
export const Room = () => {
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -24,6 +30,7 @@ export const Room = () => {
const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
usePermissionsSync()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -48,25 +55,21 @@ export const Room = () => {
return <ErrorScreen />
}
if (!userConfig && !skipJoinScreen) {
if (!hasSubmittedEntry && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware>
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</BaseRoom>
)
}
return (
<UserAware>
<BaseRoom>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
</BaseRoom>
)
}
@@ -95,6 +95,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -183,9 +184,10 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
onSelectionChange={async (key) => {
setActiveMediaDeviceOut(key as string)
}
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
style={{
minWidth: 0,
@@ -1,30 +0,0 @@
import { useEffect, useRef, useState } from 'react'
/**
* If value stays truthy for more than waitFor ms, syncValue takes the value of value.
* @param value
* @param waitFor
* @returns
*/
export function useSyncAfterDelay<T>(value: T, waitFor: number = 300) {
const valueRef = useRef(value)
const timeoutRef = useRef<NodeJS.Timeout>()
const [syncValue, setSyncValue] = useState<T>()
useEffect(() => {
valueRef.current = value
if (value) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
setSyncValue(valueRef.current)
timeoutRef.current = undefined
}, waitFor)
}
} else {
setSyncValue(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return syncValue
}
+35 -3
View File
@@ -8,6 +8,10 @@
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Lädt…",
"permissionNeeded": "Genehmigung erforderlich"
},
"videoinput": {
"choose": "Kamera auswählen",
"disable": "Kamera deaktivieren",
@@ -21,6 +25,9 @@
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
@@ -36,8 +43,14 @@
"errors": {
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet.",
"cameraDisabled": "Die Kamera ist deaktiviert",
"cameraStarting": "Die Kamera wird gestartet",
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
"videoPreview": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
},
"waiting": {
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
@@ -51,6 +64,22 @@
"body": "Niemand hat auf Ihre Anfrage reagiert"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} hat keine Berechtigung, Ihre Kamera zu verwenden",
"microphone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon zu verwenden",
"cameraAndMicrophone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon und Ihre Kamera zu verwenden",
"default": "{{app_title}} hat keine Berechtigung für bestimmte Zugriffe"
},
"body": {
"openMenu": "Klicken Sie auf das Einstellungssymbol {{icon_placeholder}} in der Adressleiste Ihres Browsers",
"details": {
"camera": "Zugriff auf die Kamera erlauben",
"microphone": "Zugriff auf das Mikrofon erlauben",
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben"
}
}
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "Meeting-Link kopieren",
@@ -154,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"activateCamera": "Kamera ist aus.",
"activateButton": "Kamera einschalten",
"permissionsCamera": "Bitte erlauben Sie der App den Zugriff auf Ihre Kamera, um fortzufahren.",
"permissionsButton": "Kamerazugriff erlauben",
"notAvailable": "Videoeffekte werden bald in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für die beste Leistung oder Firefox verwenden :(",
"heading": "Unschärfe",
"clear": "Effekt deaktivieren",
+35 -3
View File
@@ -8,6 +8,10 @@
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"permissionNeeded": "Permission needed"
},
"videoinput": {
"choose": "Select camera",
"disable": "Disable camera",
@@ -21,6 +25,9 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speakers"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
@@ -36,8 +43,14 @@
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting.",
"cameraDisabled": "The camera is disabled",
"cameraStarting": "The camera is starting",
"cameraNotGranted": "Do you want others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Do you want others to be able to see and hear you during the meeting?",
"videoPreview": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
},
"waiting": {
"title": "Requesting to join...",
"body": "You will be able to join this call when someone authorizes you"
@@ -51,6 +64,22 @@
"body": "No one responded to your request"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} is not allowed to use your camera",
"microphone": "{{app_title}} is not allowed to use your microphone",
"cameraAndMicrophone": "{{app_title}} is not allowed to use your microphone or camera",
"default": "{{app_title}} is not allowed to use certain permissions"
},
"body": {
"openMenu": "Click the settings icon {{icon_placeholder}} in your browser's address bar",
"details": {
"camera": "Allow access to the camera",
"microphone": "Allow access to the microphone",
"cameraAndMicrophone": "Allow access to the camera and microphone"
}
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy the meeting link",
@@ -154,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"activateCamera": "Your camera is disabled.",
"activateButton": "Turn on camera",
"permissionsCamera": "Please allow the app to access your camera to continue.",
"permissionsButton": "Allow camera access",
"notAvailable": "Video effects will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"heading": "Blur",
"clear": "Disable effect",
+35 -3
View File
@@ -8,6 +8,10 @@
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"permissionNeeded": "Autorisations nécessaires"
},
"videoinput": {
"choose": "Choisir la webcam",
"disable": "Désactiver la webcam",
@@ -21,6 +25,9 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut parleur"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
@@ -36,8 +43,14 @@
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer.",
"cameraDisabled": "La caméra est désactivée",
"cameraStarting": "La caméra va démarrer",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
"videoPreview": {
"enabled": "Apercu vidéo activé",
"disabled": "Apercu vidéo désactivé"
},
"waiting": {
"title": "Demande de participation…",
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
@@ -51,6 +64,22 @@
"body": "Personne n'a répondu à votre demande de participation à l'appel"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} n'est pas autorisé à utiliser votre caméra",
"microphone": "{{app_title}} n'est pas autorisé à utiliser votre micro",
"cameraAndMicrophone": "{{app_title}} n'est pas autorisé à utiliser votre micro ni votre caméra",
"default": "{{app_title}} n'est pas autorisé à utiliser certaines permissions"
},
"body": {
"openMenu": "Cliquez sur l'icône des paramètres {{icon_placeholder}} dans la barre d'adresse de votre navigateur",
"details": {
"camera": "Autorisez l'accès à la caméra",
"microphone": "Autorisez l'accès au microphone",
"cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone"
}
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier le lien de la réunion",
@@ -154,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Votre caméra est désactivée. Choisissez une option pour l'activer.",
"activateCamera": "Caméra désactivée.",
"activateButton": "Activer la caméra",
"permissionsCamera": "Veuillez autoriser l'application à accéder à votre caméra pour continuer.",
"permissionsButton": "Autoriser l'accès à la caméra",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effect",
+35 -3
View File
@@ -8,6 +8,10 @@
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"permissionNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"disable": "Camera uitschakelen",
@@ -21,6 +25,9 @@
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidsprekers"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
@@ -36,8 +43,14 @@
"errors": {
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraStarting": "Camera wordt ingeschakeld.",
"cameraDisabled": "De camera is uitgeschakeld",
"cameraStarting": "De camera wordt gestart…",
"cameraNotGranted": "Wil je dat anderen je kunnen zien tijdens de vergadering?",
"cameraAndMicNotGranted": "Wil je dat anderen je kunnen zien en horen tijdens de vergadering?",
"videoPreview": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
},
"waiting": {
"title": "Verzoek tot deelname...",
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
@@ -51,6 +64,22 @@
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} heeft geen toestemming om je camera te gebruiken",
"microphone": "{{app_title}} heeft geen toestemming om je microfoon te gebruiken",
"cameraAndMicrophone": "{{app_title}} heeft geen toestemming om je microfoon en camera te gebruiken",
"default": "{{app_title}} heeft geen toestemming voor bepaalde rechten"
},
"body": {
"openMenu": "Klik op het instellingenpictogram {{icon_placeholder}} in de adresbalk van je browser",
"details": {
"camera": "Sta toegang tot de camera toe",
"microphone": "Sta toegang tot de microfoon toe",
"cameraAndMicrophone": "Sta toegang tot de camera en microfoon toe"
}
}
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
"shareDialog": {
"copy": "Kopieer de vergaderlink",
@@ -154,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Uw camera is uitgeschakeld. Kies een optie om deze in te schakelen.",
"activateCamera": "Camera is uit.",
"activateButton": "Camera inschakelen",
"permissionsCamera": "Sta de app toe om toegang te krijgen tot je camera om door te gaan.",
"permissionsButton": "Toegang tot camera toestaan",
"notAvailable": "Video-effecten zijn binnenkort beschikbaar in uw browser. We werken eraan! In de tussentijd kunt u Google Chrome gebruiken voor de beste prestaties of Firefox :(",
"heading": "Vervaging",
"clear": "Effect uitschakelen",
+22 -2
View File
@@ -1,6 +1,6 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { RiArrowDropDownLine } from '@remixicon/react'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
ListBox,
@@ -12,12 +12,14 @@ import {
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
const StyledButton = styled(Button, {
base: {
width: 'full',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
@@ -53,22 +55,40 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledIcon = styled('div', {
base: {
marginRight: '0.35rem',
flexShrink: 0,
},
})
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
...props
}: 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>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine aria-hidden="true" />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
@@ -228,6 +228,20 @@ export const buttonRecipe = cva({
color: 'error.200',
},
},
errorCircle: {
width: '56px',
height: '56px',
borderRadius: '100%',
backgroundColor: 'error.500',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
error2: {
backgroundColor: 'error.200',
color: 'error.900',
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
export type ModalsState = {
permissions: boolean
}
export const modalsStore = proxy<ModalsState>({
permissions: false,
})
+13
View File
@@ -0,0 +1,13 @@
import { proxy } from 'valtio'
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
type State = {
cameraPermission: PermissionState
microphonePermission: PermissionState
}
export const permissionStore = proxy<State>({
cameraPermission: undefined,
microphonePermission: undefined,
})
+2
View File
@@ -9,11 +9,13 @@ import {
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default',
...loadUserChoices(),
}
}
@@ -52,7 +52,6 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False