Compare commits

...

9 Commits

Author SHA1 Message Date
lebaudantoine 80fdf3faf2 🚨(frontend) remove redundant undefined type or optional specifier
Fix TypeScript warning by removing either 'undefined' type annotation
or '?' optional specifier where both were redundantly specified.
2025-08-11 18:48:27 +02:00
lebaudantoine 8d2df4001a 🚨(frontend) fix unnecessary destructuring rename in devices assignment
Remove unnecessary renaming in destructuring assignment where variable
was renamed to the same name, violating TypeScript rule S6650.
2025-08-11 18:48:27 +02:00
lebaudantoine d610c0dff3 💄(frontend) add symmetric shadows for white button contrast enhancement
Add symmetric shadows at top and bottom of white circle buttons to
ensure sufficient visual contrast against varying background colors.

Improves button visibility and accessibility by providing consistent
visual definition regardless of background context.
2025-08-11 18:48:27 +02:00
lebaudantoine bad054fa8c ♻️(frontend) refactor device select for controlled behavior
Major refactor of device select component with several key improvements:

* Set permission=true for Firefox compatibility - without this flag,
  device list returns empty on Firefox
* Implement controlled component pattern for active device selection,
  ensuring sync with preview track state
* Remove default device handling as controlled behavior eliminates need
* Render selectors only after permissions granted to prevent double
  permission prompts (separate for mic/camera)

Ensures usePreviewTrack handles initial permission request, then
selectors allow specific device choice once access is granted.
2025-08-11 18:48:27 +02:00
lebaudantoine 3b398ca3fd 🐛(frontend) fix default device ID mismatch with actual preview track
Update default device IDs when preview track starts to match the
actual device being used. LiveKit returns 'default' string which may
not exist in device list, causing ID mismatch.

Prevents misleading situation where default device ID doesn't
correspond to the device actually used by the preview track. Now
synchronizes IDs once preview starts for accurate device tracking.
2025-08-11 18:48:27 +02:00
lebaudantoine 3c2c1a04db 🐛(frontend) reset video ref on track stop for state transitions
Reset video element reference when track stops to ensure "camera
starting" to "camera started" message transitions work correctly on
repeated camera toggles.

Previously only worked on initial video element load. Now properly
handles state transitions for multiple camera enable/disable cycles.
2025-08-11 18:44:58 +02:00
lebaudantoine cb30048b27 ♻️(frontend) refactor select to show arrow up when menu opens upward
Update select toggle device component used in conference to display
upward arrow when dropdown menu opens above the select component.

Improves visual consistency by matching arrow direction with actual
menu opening direction, providing clearer user interface feedback.
2025-08-11 18:44:58 +02:00
lebaudantoine 945279d504 🩹(frontend) refactor track muting for proper audio/video control
Fix useTrackToggle hook that wasn't properly muting/unmuting tracks
outside room context. Previously only toggled boolean state via
setUpManualToggle without actual track control.

This caused misleading visual feedback where prejoin showed "camera
disabled" while hardware remained active. Users could see camera/mic
LEDs still on despite UI indicating otherwise.

Refactor provides genuine track muting for accurate user feedback.
2025-08-11 18:44:58 +02:00
lebaudantoine 13c9436dc3 ♻️(frontend) refactor prejoin screen for room context flexibility
Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
2025-08-11 18:39:38 +02:00
12 changed files with 397 additions and 86 deletions
@@ -5,7 +5,6 @@ import { Screen } from '@/layout/Screen'
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 { VStack } from '@/styled-system/jsx'
@@ -29,6 +28,9 @@ import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
const onError = (e: Error) => console.error('ERROR', e)
@@ -139,6 +141,11 @@ export const Join = ({
[tracks]
)
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId)
useResolveDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
@@ -160,7 +167,11 @@ export const Join = ({
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
}
}, [videoTrack, videoEnabled])
@@ -399,6 +410,28 @@ export const Join = ({
alignItems: 'center',
})}
>
<div
className={css({
position: 'absolute',
top: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'relative',
@@ -502,6 +535,33 @@ export const Join = ({
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
<ToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
</div>
<div
className={css({
position: 'absolute',
@@ -528,32 +588,26 @@ export const Join = ({
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
className={css({
width: '50%',
})}
>
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</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
className={css({
width: '50%',
})}
>
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
@@ -0,0 +1,115 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useMemo } from 'react'
import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
type SelectDevicePermissionsProps = SelectDeviceProps & {
config: DeviceConfig
}
const SelectDevicePermissions = ({
id,
kind,
config,
onSubmit,
}: SelectDevicePermissionsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={t('selectDevice.loading')}
selectedKey={id || activeDeviceId}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore)
const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
}
}
}, [kind])
const isPermissionDeniedOrPrompted = useMemo(() => {
if (kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
return false
}, [kind, permissions])
if (!config) return null
if (isPermissionDeniedOrPrompted || permissions.isLoading) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
iconComponent={config?.icon}
placeholder={t('selectDevice.permissionsNeeded')}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
config={config}
/>
)
}
@@ -0,0 +1,78 @@
import { UseTrackToggleProps } from '@livekit/components-react'
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useCallback, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
}
export const ToggleDevice = <T extends ToggleSource>({
track,
onChange,
...props
}: ToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const [isTrackEnabled, setIsTrackEnabled] = useState(
props.initialState ?? false
)
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
if (config.kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (config.kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [config, permissions])
const toggle = useCallback(async () => {
if (!track) {
console.error('Track is undefined.')
return
}
try {
if (isTrackEnabled) {
await track.mute()
setIsTrackEnabled(false)
onChange?.(false, true)
} else {
await track.unmute()
setIsTrackEnabled(true)
onChange?.(true, true)
}
} catch (error) {
console.error('Failed to toggle track:', error)
}
}, [track, onChange, isTrackEnabled])
return (
<BaseToggleDevice
enabled={isTrackEnabled}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggle={toggle}
config={config}
variant="whiteCircle"
errorVariant="errorCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -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,8 +13,6 @@ import {
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
@@ -30,56 +21,17 @@ import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
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,
},
},
}
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../config/ToggleDeviceConfig'
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
track?: LocalAudioTrack | LocalVideoTrack
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
@@ -94,7 +46,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
@@ -198,7 +150,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
: '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 { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
@@ -15,13 +14,15 @@ import {
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
export type ToggleDeviceProps = {
enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void
config: SelectToggleDeviceConfig
config: ToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
@@ -30,6 +31,7 @@ export const ToggleDevice = ({
enabled,
toggle,
variant = 'primaryDark',
errorVariant = 'error2',
toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => {
@@ -72,7 +74,9 @@ export const ToggleDevice = ({
{isPermissionDeniedOrPrompted && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !isPermissionDeniedOrPrompted ? variant : 'error2'}
variant={
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
}
shySelected
onPress={() =>
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
@@ -0,0 +1,52 @@
import { Track } from 'livekit-client'
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
| Track.Source.ScreenShareAudio
| Track.Source.Unknown
| Track.Source.ScreenShare
>
export type ToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type ToggleDeviceConfigMap = {
[key in ToggleSource]: ToggleDeviceConfig
}
export const TOGGLE_DEVICE_CONFIG = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
} as const satisfies ToggleDeviceConfigMap
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
export const useResolveDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
useEffect(() => {
if (currentId !== 'default' || !track) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') save(actualDeviceId)
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
+6
View File
@@ -8,8 +8,13 @@
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"permissionsNeeded": "Genehmigung erforderlich"
},
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -17,6 +22,7 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
+6
View File
@@ -8,8 +8,13 @@
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"permissionsNeeded": "Permission needed"
},
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -17,6 +22,7 @@
},
"audioinput": {
"choose": "Select microphone",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
+6
View File
@@ -8,8 +8,13 @@
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"permissionsNeeded": "Autorisations nécessaires"
},
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -17,6 +22,7 @@
},
"audioinput": {
"choose": "Choisir le micro",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
+6
View File
@@ -8,8 +8,13 @@
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Bezig met laden…",
"permissionNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"permissionNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -17,6 +22,7 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
@@ -251,6 +251,20 @@ export const buttonRecipe = cva({
color: 'error.300',
},
},
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',