Compare commits
23 Commits
modal
..
wip-prejoin
| Author | SHA1 | Date | |
|---|---|---|---|
| 419892d441 | |||
| 9b1b51da1e | |||
| fc54486645 | |||
| d669f4b0e6 | |||
| 4593728812 | |||
| 4d3fac8b56 | |||
| 727f0c362f | |||
| fca58114ed | |||
| 66e9913c58 | |||
| b3795bc9a3 | |||
| a3c2406f9d | |||
| 597406f550 | |||
| 0183afcf77 | |||
| 1eff4e1477 | |||
| 4a25279555 | |||
| e3251bb980 | |||
| 1ae08420a4 | |||
| e29c241d4e | |||
| f1cf14ae31 | |||
| b9e924a304 | |||
| 35125d965c | |||
| f847f07c24 | |||
| 3e94ed0a8b |
@@ -35,6 +35,9 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
|
||||
@@ -217,6 +217,9 @@ DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -336,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 |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface ApiConfig {
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
force_wss_protocol: boolean
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -154,14 +155,17 @@ export const MainNotificationToast = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationReceived = (
|
||||
changedAttributes: Record<string, string>,
|
||||
prevMetadataStr: string | undefined,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant) return
|
||||
if (isMobileBrowser()) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
if (!('handRaisedAt' in changedAttributes)) return
|
||||
const prevMetadata = safeParseMetadata(prevMetadataStr)
|
||||
const metadata = safeParseMetadata(participant.metadata)
|
||||
|
||||
if (prevMetadata?.raised == metadata?.raised) return
|
||||
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
@@ -169,12 +173,12 @@ export const MainNotificationToast = () => {
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
)
|
||||
|
||||
if (existingToast && !changedAttributes?.handRaisedAt) {
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
toastQueue.close(existingToast.key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existingToast && !!changedAttributes?.handRaisedAt) {
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
toastQueue.add(
|
||||
{
|
||||
@@ -186,13 +190,10 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
|
||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
|
||||
return () => {
|
||||
room.off(
|
||||
RoomEvent.ParticipantAttributesChanged,
|
||||
handleNotificationReceived
|
||||
)
|
||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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,
|
||||
Room,
|
||||
RoomOptions,
|
||||
supportsAdaptiveStream,
|
||||
supportsDynacast,
|
||||
} from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -31,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])
|
||||
@@ -81,13 +84,10 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isAdaptiveStreamSupported = supportsAdaptiveStream()
|
||||
const isDynacastSupported = supportsDynacast()
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: isAdaptiveStreamSupported,
|
||||
dynacast: isDynacastSupported,
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
@@ -97,13 +97,15 @@ 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,
|
||||
isAdaptiveStreamSupported,
|
||||
isDynacastSupported,
|
||||
userConfig.audioOutputDeviceId,
|
||||
])
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
@@ -162,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.
|
||||
@@ -199,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,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,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,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Participant } from 'livekit-client'
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const useLowerHandParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -10,12 +11,8 @@ export const useLowerHandParticipant = () => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
|
||||
const newAttributes = {
|
||||
...participant.attributes,
|
||||
handRaisedAt: '',
|
||||
}
|
||||
|
||||
const newMetadata = safeParseMetadata(participant.metadata) || {}
|
||||
newMetadata.raised = !newMetadata.raised
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
@@ -27,7 +24,7 @@ export const useLowerHandParticipant = () => {
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
attributes: newAttributes,
|
||||
metadata: JSON.stringify(newMetadata),
|
||||
permission: participant.permissions,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { useRaisedHand } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
@@ -97,10 +97,6 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
@@ -145,32 +141,24 @@ export const ParticipantTile: (
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
|
||||
+5
-9
@@ -11,6 +11,7 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
@@ -34,15 +35,10 @@ export const ParticipantsList = () => {
|
||||
...sortedRemoteParticipants,
|
||||
]
|
||||
|
||||
const raisedHandParticipants = participants
|
||||
.filter((participant) => !!participant.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
||||
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
||||
return timeA - timeB
|
||||
})
|
||||
const raisedHandParticipants = participants.filter((participant) => {
|
||||
const data = safeParseMetadata(participant.metadata)
|
||||
return data.raised
|
||||
})
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
+10
-57
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+94
-41
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,58 +1,23 @@
|
||||
import { LocalParticipant, Participant } from 'livekit-client'
|
||||
import {
|
||||
useParticipantAttribute,
|
||||
useParticipants,
|
||||
} from '@livekit/components-react'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type useRaisedHandProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
|
||||
const participants = useParticipants()
|
||||
|
||||
const positionInQueue = useMemo(() => {
|
||||
if (!isHandRaised) return
|
||||
|
||||
return (
|
||||
participants
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
return dateA.getTime() - dateB.getTime()
|
||||
})
|
||||
.findIndex((p) => p.identity === participant.identity) + 1
|
||||
)
|
||||
}, [participants, participant, isHandRaised])
|
||||
|
||||
return {
|
||||
positionInQueue,
|
||||
firstInQueue: positionInQueue == 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
||||
participant,
|
||||
})
|
||||
// fixme - refactor this part to rely on attributes
|
||||
const { metadata } = useParticipantInfo({ participant })
|
||||
const parsedMetadata = JSON.parse(metadata || '{}')
|
||||
|
||||
const isHandRaised = !!handRaisedAtAttribute
|
||||
|
||||
const toggleRaisedHand = async () => {
|
||||
if (!isLocal(participant)) return
|
||||
const localParticipant = participant as LocalParticipant
|
||||
|
||||
const attributes: Record<string, string> = {
|
||||
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
|
||||
const toggleRaisedHand = () => {
|
||||
if (isLocal(participant)) {
|
||||
parsedMetadata.raised = !parsedMetadata.raised
|
||||
const localParticipant = participant as LocalParticipant
|
||||
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
|
||||
}
|
||||
|
||||
await localParticipant.setAttributes(attributes)
|
||||
}
|
||||
|
||||
return { isHandRaised, toggleRaisedHand }
|
||||
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const safeParseMetadata = (
|
||||
metadataStr: string | null | undefined
|
||||
): Record<string, unknown> => {
|
||||
if (!metadataStr) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataStr)
|
||||
|
||||
// Ensure the result is an object
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
console.warn('Metadata parsed to non-object value:', parsed)
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
console.error('Failed to parse metadata:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -170,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",
|
||||
|
||||
@@ -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"
|
||||
@@ -170,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",
|
||||
|
||||
@@ -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"
|
||||
@@ -170,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",
|
||||
|
||||
@@ -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"
|
||||
@@ -170,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorSerialized?: ProcessorSerialized
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
}
|
||||
|
||||
function getUserChoicesState(): LocalUserChoices {
|
||||
return {
|
||||
noiseReductionEnabled: false,
|
||||
audioOutputDeviceId: 'default',
|
||||
...loadUserChoices(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
|
||||
@@ -40,6 +40,9 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
@@ -49,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
|
||||
|
||||
@@ -62,6 +62,9 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.10
|
||||
version: 0.0.9
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
| `ingress.path` | Path to use for the Ingress | `/` |
|
||||
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
|
||||
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
|
||||
| `ingress.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
|
||||
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
|
||||
@@ -31,7 +30,6 @@
|
||||
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
|
||||
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
|
||||
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
|
||||
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
|
||||
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
|
||||
@@ -165,7 +163,6 @@
|
||||
| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` |
|
||||
| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` |
|
||||
| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
|
||||
| `posthog.ingress.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
|
||||
| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` |
|
||||
| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
|
||||
@@ -175,7 +172,6 @@
|
||||
| `posthog.ingressAssets.path` | URL path prefix for the ingress routes (e.g., /) | `/static` |
|
||||
| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` |
|
||||
| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
|
||||
| `posthog.ingressAssets.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
|
||||
| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` |
|
||||
| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingress.host }}
|
||||
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
- secretName: {{ $fullName }}-tls
|
||||
hosts:
|
||||
- {{ .Values.ingress.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -30,7 +30,6 @@ spec:
|
||||
tls:
|
||||
{{- if .Values.ingressAdmin.host }}
|
||||
- secretName: {{ $fullName }}-tls
|
||||
- secretName: {{ .Values.ingressAdmin.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressAdmin.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.posthog.ingress.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.posthog.ingress.host }}
|
||||
- secretName: {{ .Values.posthog.ingress.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
|
||||
- secretName: {{ $fullName }}-posthog-tls
|
||||
hosts:
|
||||
- {{ .Values.posthog.ingress.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.posthog.ingressAssets.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.posthog.ingressAssets.host }}
|
||||
- secretName: {{ .Values.posthog.ingressAssets.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
|
||||
- secretName: {{ $fullName }}-posthog-tls
|
||||
hosts:
|
||||
- {{ .Values.posthog.ingressAssets.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -38,12 +38,10 @@ ingress:
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
## @param ingress.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingress.tls.secretName Secret name for TLS config
|
||||
## @skip ingress.tls.additional
|
||||
## @extra ingress.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
@@ -64,12 +62,10 @@ ingressAdmin:
|
||||
hosts: [ ]
|
||||
# - chart-example.local
|
||||
## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressAdmin.tls.secretName Secret name for TLS config
|
||||
## @skip ingressAdmin.tls.additional
|
||||
## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
@@ -91,8 +87,8 @@ ingressMedia:
|
||||
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
secretName: null
|
||||
additional: []
|
||||
|
||||
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
|
||||
@@ -362,7 +358,6 @@ posthog:
|
||||
## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /)
|
||||
## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress
|
||||
## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress
|
||||
## @param posthog.ingress.tls.secretName Secret name for TLS config
|
||||
## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates
|
||||
## @param posthog.ingress.customBackends Custom backend service configurations for the ingress
|
||||
## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress
|
||||
@@ -373,7 +368,6 @@ posthog:
|
||||
path: /
|
||||
hosts: [ ]
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: [ ]
|
||||
|
||||
@@ -386,7 +380,6 @@ posthog:
|
||||
## @param posthog.ingressAssets.path URL path prefix for the ingress routes (e.g., /)
|
||||
## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress
|
||||
## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress
|
||||
## @param posthog.ingressAssets.tls.secretName Secret name for TLS config
|
||||
## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates
|
||||
## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress
|
||||
## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress
|
||||
@@ -397,7 +390,6 @@ posthog:
|
||||
path: /static
|
||||
hosts: [ ]
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: [ ]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user