Compare commits

..

11 Commits

Author SHA1 Message Date
lebaudantoine f5465f3b85 ♻️(frontend) refactor prejoin layout for better extensibility
Major refactoring of prejoin interface to improve user onboarding and
camera/microphone permission handling:

* Create extensible hint message system for easier addition of
  permission-related guidance.
* Design flexible layout structure to accommodate future camera/mic
  options and component selection features.
* Establish foundation for comprehensive prejoin redesign

In upcoming commits, UX will be enhanced to a smoother user
experience when joining calls and requesting media permissions.
2025-08-07 12:39:20 +02:00
lebaudantoine 872ce1ecc6 (frontend) add icon support to select primitive component
Enhance select component by adding icon display capability for better
visual representation of options.
2025-08-01 17:45:48 +02:00
lebaudantoine e2c3b745ca 🩹(frontend) avoid video glitch on the prejoin while init a processor
We've introduced simplifications that improve performance, enhance ux,
and contribute to the overall perception of experience quality.

Previously, our processor handling was overly complex. LiveKit allows us to set
a processor before starting the local video preview track, which eliminates
the black blink glitch that appeared when loading the join component
with a default processor.

This change prevents the unnecessary stopping and restarting
of the local video track.

I'm glad this issue is now resolved.

We also simplified component communication by avoiding props drilling.
Now, we use a single flag to indicate when the user is ready to enter the room.
This significantly reduces the complexity of props passed through components.
2025-08-01 17:40:11 +02:00
lebaudantoine 965d823d08 (frontend) display position of a raised hand in the queue
Users requested an enhanced visual indicator
for raised hands on the participant tile.

Most major video conferencing platforms display the position of a raised hand
in the queue. This helps hosts quickly see who is requesting to speak,
and in what order, without needing to open the full participant list.

While a minor feature, this improvement is especially valuable for power user
2025-08-01 16:54:24 +02:00
lebaudantoine 1db189ace2 🚸(frontend) sort raised hand by order of arrival
Previously, the participant list was sorted alphabetically by name.
This unintentionally affected the raised hands list,
which inherited the same sorting behavior.

Users requested that raised hands be sorted by order of arrival.
This simple change improves the UX by ensuring that raised hands
are displayed in the correct order.
2025-08-01 16:54:24 +02:00
lebaudantoine 199e0908e9 ♻️(frontend) refactor raised hand to rely on participant's attributes
Previously managed participant hand raised using raw metadata. LiveKit
introduced attributes concept, saves as metadata under hood but makes
update/handling easier.

Still pain that attributes must be strings, cannot pass boolean…

Refactor whole app to use attributes instead of metadata for the raised
hand feature. This commit might introduce regressions. I've double
checked participant list side pannel and the notification features.

Previously I persisted a boolean, I now persist the timestamp at which
the hand was raised. This will be useful in upcoming commits, especially
for sorting raised hands by order of arrival.
2025-08-01 16:54:24 +02:00
Jacques ROUSSEL 8518f83211 (helm) add the ability to configure tls secretName
Yesterday during a deployment, we were unable to configure the tls
secretName for ingress.
2025-08-01 16:53:36 +02:00
lebaudantoine 0240d85837 📝(backend) add Firefox proxy workaround parameter to installation guide
Document LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND setting that enables
connection warmup by pre-calling WebSocket endpoint to resolve proxy
connectivity issues.
2025-08-01 16:23:22 +02:00
lebaudantoine 162896c93c 🩹(backend) allow enforcing WSS protocol to resolve browser compatibility
The LiveKit API URL is necessary to interact with the API. It uses https
protocol.

Eplicit wss protocol is necessary in Websocket constructor for some
older browsers.

This resolves critical compatibility issues with legacy browsers
(notably Firefox <124, Chrome <125, Edge <125) that lack support
for HTTPS URLs in the WebSocket() constructor. Without explicit WSS
URLs, WebSocket signaling connections may fail, crash, or be blocked
entirely in these environments.

The setting is optional and defaults to the current behavior when
not specified, ensuring zero breaking changes for existing deployments.
2025-08-01 16:23:22 +02:00
Jacques ROUSSEL 483a219ac4 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 16:14:55 +02:00
lebaudantoine 1b26dea178 🐛(frontend) use feature detection for adaptiveStream and dynacast
Replace hardcoded true values with supportsAdaptiveStream() and
supportsDynacast() checks. LiveKit SDK supports broad browser range but
requires specific APIs - modern features need explicit compatibility checks.

Prevents enabling unsupported WebRTC features on incompatible browsers,
which could led to a poor user experience.

One alternative solution could be to install polyfills.
2025-08-01 15:46:17 +02:00
22 changed files with 383 additions and 259 deletions
+2
View File
@@ -336,6 +336,8 @@ 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,6 +52,7 @@ 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,6 +492,9 @@ 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,
+1
View File
@@ -39,6 +39,7 @@ export interface ApiConfig {
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
@@ -18,7 +18,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -155,17 +154,14 @@ export const MainNotificationToast = () => {
useEffect(() => {
const handleNotificationReceived = (
prevMetadataStr: string | undefined,
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
const prevMetadata = safeParseMetadata(prevMetadataStr)
const metadata = safeParseMetadata(participant.metadata)
if (prevMetadata?.raised == metadata?.raised) return
if (!('handRaisedAt' in changedAttributes)) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
@@ -173,12 +169,12 @@ export const MainNotificationToast = () => {
toast.content.type === NotificationType.HandRaised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
if (existingToast && !changedAttributes?.handRaisedAt) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
if (!existingToast && !!changedAttributes?.handRaisedAt) {
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
@@ -190,10 +186,13 @@ export const MainNotificationToast = () => {
}
}
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
}
}, [room, triggerNotificationSound])
@@ -1,12 +1,17 @@
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'
@@ -29,18 +34,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])
@@ -79,10 +86,13 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -94,7 +104,12 @@ export const Conference = ({
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -152,6 +167,20 @@ 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.
@@ -175,7 +204,7 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={apiConfig?.livekit.url}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
@@ -2,13 +2,13 @@ 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 { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, 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 +27,6 @@ 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'
const onError = (e: Error) => console.error('ERROR', e)
@@ -72,45 +71,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' })
@@ -132,23 +109,14 @@ export const Join = ({
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(
{
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
@@ -170,12 +138,14 @@ export const Join = ({
)
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 +162,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 +220,19 @@ 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 hintMessage = useMemo(() => {
if (!videoEnabled) {
return 'cameraDisabled'
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [videoTrack, videoEnabled])
const renderWaitingState = () => {
switch (status) {
@@ -361,141 +316,216 @@ 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 ? '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 ? '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: '1rem',
bottom: '1rem',
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
})}
>
<div>
<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"
/>
</div>
<div>
<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"
/>
</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>
)
@@ -2,7 +2,6 @@ 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()
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newMetadata = safeParseMetadata(participant.metadata) || {}
newMetadata.raised = !newMetadata.raised
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
metadata: JSON.stringify(newMetadata),
attributes: newAttributes,
permission: participant.permissions,
}),
}
@@ -22,7 +22,7 @@ import {
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '../hooks/useRaisedHand'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -97,6 +97,10 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
@@ -141,24 +145,32 @@ export const ParticipantTile: (
style={{
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare ? 'white' : undefined,
isHandRaised && !isScreenShare
? firstInQueue
? '#fde047'
: 'white'
: undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{isHandRaised && !isScreenShare && (
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
marginLeft: '0.1rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
</>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -11,7 +11,6 @@ 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 = () => {
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata)
return data.raised
})
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 { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
@@ -1,23 +1,58 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react'
import {
useParticipantAttribute,
useParticipants,
} from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}')
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
const { isHandRaised } = useRaisedHand({ participant })
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
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,
})
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() : '',
}
await localParticipant.setAttributes(attributes)
}
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
return { isHandRaised, toggleRaisedHand }
}
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
@@ -10,12 +9,10 @@ import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
import { LocalUserChoices } from '@/stores/userChoices'
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()
@@ -48,10 +45,10 @@ export const Room = () => {
return <ErrorScreen />
}
if (!userConfig && !skipJoinScreen) {
if (!hasSubmittedEntry && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</UserAware>
)
}
@@ -62,10 +59,6 @@ export const Room = () => {
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
)
@@ -1,22 +0,0 @@
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 {}
}
}
+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">
@@ -49,6 +49,7 @@ 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
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.9
version: 0.0.10
+4
View File
@@ -21,6 +21,7 @@
| `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 | `[]` |
@@ -30,6 +31,7 @@
| `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` |
@@ -163,6 +165,7 @@
| `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 | `{}` |
@@ -172,6 +175,7 @@
| `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 | `{}` |
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingress.tls.enabled }}
tls:
{{- if .Values.ingress.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingress.host | quote }}
{{- end }}
@@ -30,6 +30,7 @@ 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 }}
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.posthog.ingress.tls.enabled }}
tls:
{{- if .Values.posthog.ingress.host }}
- secretName: {{ $fullName }}-posthog-tls
- secretName: {{ .Values.posthog.ingress.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
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: {{ $fullName }}-posthog-tls
- secretName: {{ .Values.posthog.ingressAssets.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
hosts:
- {{ .Values.posthog.ingressAssets.host | quote }}
{{- end }}
+9 -1
View File
@@ -38,10 +38,12 @@ 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: []
@@ -62,10 +64,12 @@ 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: []
@@ -87,8 +91,8 @@ ingressMedia:
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
enabled: true
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
@@ -358,6 +362,7 @@ 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
@@ -368,6 +373,7 @@ posthog:
path: /
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]
@@ -380,6 +386,7 @@ 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
@@ -390,6 +397,7 @@ posthog:
path: /static
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]