feat(api): add new exports for components, hooks, and utilities
ZhenYi committed
Added web/src/features/chat/model/voice-connection.ts web/src/features/chat/model/voice-connection.ts +92 −0 View web/src/features/chat/model/voice-connection.ts
@@ -0,0 +1,92 @@
1/**
2 * The wiring seam between the tab-wide voice session (`@/shared/voice`) and the
3 * chat API (`@/entities/chat`).
4 *
5 * `shared/voice` must stay free of entities imports, so the credential minting
6 * lives here in the feature that owns both sides. Everything the session needs
7 * to survive - a refresh restore, a rejoin with a *fresh* token after a signal
8 * drop, the pre-join capability gate - funnels through this module.
9 */
10import { getVoiceConfig, requestVoiceToken } from '@/entities/chat'
11import { storedVoiceSession, voiceSession, type VoiceCredentials } from '@/shared/voice'
12
13let installed = false
14
15/** Mint a LiveKit access token + endpoint for one voice channel. */
16export async function voiceCredentials(roomId: string): Promise<VoiceCredentials> {
17 const token = await requestVoiceToken(roomId)
18 return {
19 url: token.url,
20 token: token.token,
21 canPublish: token.can_publish,
22 isStage: token.is_stage,
23 }
24}
25
26/**
27 * Restores an in-progress call after a page refresh.
28 *
29 * The connection cannot survive a reload, so the room id lives in
30 * sessionStorage and the tab re-joins silently - **muted**, because a browser
31 * blocks remote audio until a gesture anyway and rejoining with an open
32 * microphone would be worse than one extra click. The call bar then offers to
33 * restore sound.
34 */
35async function restoreStoredSession(): Promise<void> {
36 const stored = storedVoiceSession()
37 if (!stored) return
38 try {
39 const credentials = await voiceCredentials(stored.roomId)
40 await voiceSession.join({
41 roomId: stored.roomId,
42 roomLabel: stored.roomLabel,
43 spaceName: stored.spaceName,
44 ...credentials,
45 startWithMic: false,
46 })
47 } catch {
48 // A stale room (deleted channel, revoked access) simply does not restore;
49 // leaving clears the stored id so the next load stays quiet.
50 await voiceSession.leave()
51 }
52}
53
54/**
55 * Install the session's credential source and kick off the refresh restore.
56 * Called once from the app layer; idempotent, so a double render (StrictMode)
57 * cannot tear down and rebuild the connection the first pass just made.
58 */
59export function installVoiceSession(): void {
60 if (installed) return
61 installed = true
62 voiceSession.setCredentialSource(voiceCredentials)
63 void restoreStoredSession()
64}
65
66export interface JoinVoiceRoomOptions {
67 roomId: string
68 roomLabel?: string
69 spaceName?: string
70 /** Voice channels open the mic; Stage audiences join in listen-only mode. */
71 startWithMic: boolean
72 audioDeviceId?: string | null
73 videoDeviceId?: string | null
74 audioOutputDeviceId?: string | null
75}
76
77/** Capability gate + credential minting + connect, as one awaitable action. */
78export async function joinVoiceRoom(options: JoinVoiceRoomOptions): Promise<void> {
79 const config = await getVoiceConfig()
80 if (!config.enabled) throw new Error('livekit is not enabled')
81 const credentials = await voiceCredentials(options.roomId)
82 await voiceSession.join({
83 roomId: options.roomId,
84 roomLabel: options.roomLabel,
85 spaceName: options.spaceName,
86 ...credentials,
87 startWithMic: options.startWithMic,
88 audioDeviceId: options.audioDeviceId,
89 videoDeviceId: options.videoDeviceId,
90 audioOutputDeviceId: options.audioOutputDeviceId,
91 })
92}
Added web/src/features/chat/ui/voice/voice-device-sheet.tsx web/src/features/chat/ui/voice/voice-device-sheet.tsx +249 −0 View web/src/features/chat/ui/voice/voice-device-sheet.tsx
@@ -0,0 +1,249 @@
1/**
2 * Pre-join device sheet.
3 *
4 * Deliberately not the official full-screen `PreJoin`: a GitData voice channel
5 * is opened from the sidebar and the member should stay inside the channel. The
6 * sheet still uses the official building blocks - `usePreviewTracks`,
7 * `useMediaDevices` and the same `usePersistentUserChoices` store that the
8 * in-call device menus write to - so there is exactly one remembered device set.
9 *
10 * Enumerating devices *with labels* needs a capture permission, so every picker
11 * is its own component: a Stage audience only sees the loudspeaker, and joining
12 * to listen never opens a microphone prompt.
13 */
14import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
15import { Loader2, Mic, Volume2, Video } from 'lucide-react'
16import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
17import {
18 useMediaDevices,
19 usePersistentUserChoices,
20 usePreviewTracks,
21 useTrackVolume,
22} from '@/shared/livekit'
23import { useT } from '@/shared/i18n'
24import { readOutputDevice, rememberOutputDevice } from './voice-devices'
25
26export interface VoiceJoinChoice {
27 /** Voice channels open the mic; Stage audiences join in listen-only mode. */
28 startWithMic: boolean
29 audioDeviceId: string | null
30 videoDeviceId: string | null
31 audioOutputDeviceId: string | null
32}
33
34export interface VoiceDeviceSheetProps {
35 open: boolean
36 busy: boolean
37 /** Stage channels cannot open the mic on join, so no capture is requested. */
38 allowMicrophone: boolean
39 error: string | null
40 onCancel: () => void
41 onConfirm: (choice: VoiceJoinChoice) => void
42}
43
44/** The official store keeps `default` for "whatever the browser prefers". */
45const normalize = (value: string | undefined): string | null =>
46 !value || value === 'default' ? null : value
47
48function DeviceSelect({
49 id,
50 kind,
51 icon,
52 label,
53 value,
54 emptyLabel,
55 onChange,
56}: {
57 id: string
58 kind: MediaDeviceKind
59 icon: ReactNode
60 label: string
61 value: string | null
62 emptyLabel: string
63 onChange: (deviceId: string | null) => void
64}) {
65 const { t } = useT()
66 const devices = useMediaDevices({ kind })
67 // A device that disappeared (headset unplugged between two calls) must not
68 // block the join, so the effective selection is derived, not stored.
69 const effective = value && devices.some(device => device.deviceId === value) ? value : null
70 return (
71 <label className="voice-sheet-row" htmlFor={id}>
72 <span className="voice-sheet-caption">
73 {icon} {label}
74 </span>
75 <select
76 id={id}
77 value={effective ?? ''}
78 onChange={event => onChange(event.target.value || null)}
79 >
80 <option value="">{t('chat.voice.deviceSystemDefault')}</option>
81 {devices.map(device => (
82 <option key={device.deviceId} value={device.deviceId}>
83 {device.label || device.deviceId}
84 </option>
85 ))}
86 {devices.length === 0 ? (
87 <option value="" disabled>
88 {emptyLabel}
89 </option>
90 ) : null}
91 </select>
92 </label>
93 )
94}
95
96export function VoiceDeviceSheet({
97 open,
98 busy,
99 allowMicrophone,
100 error,
101 onCancel,
102 onConfirm,
103}: VoiceDeviceSheetProps) {
104 const { t } = useT()
105 const { userChoices, saveAudioInputDeviceId, saveVideoInputDeviceId } =
106 usePersistentUserChoices()
107 const [audioDeviceId, setAudioDeviceId] = useState<string | null>(() =>
108 normalize(userChoices.audioDeviceId),
109 )
110 const [videoDeviceId, setVideoDeviceId] = useState<string | null>(() =>
111 normalize(userChoices.videoDeviceId),
112 )
113 const [outputDeviceId, setOutputDeviceId] = useState<string | null>(readOutputDevice)
114 const [micOn, setMicOn] = useState(allowMicrophone)
115 const [previewError, setPreviewError] = useState<string | null>(null)
116 const videoRef = useRef<HTMLVideoElement | null>(null)
117
118 const requestAudio = open && allowMicrophone && micOn
119 const requestVideo = open && allowMicrophone && Boolean(videoDeviceId)
120 const tracks = usePreviewTracks(
121 {
122 audio: requestAudio ? { deviceId: audioDeviceId ?? undefined } : false,
123 video: requestVideo ? { deviceId: videoDeviceId ?? undefined } : false,
124 },
125 (cause: Error) => setPreviewError(cause.message),
126 )
127
128 const micTrack = useMemo(
129 () => tracks?.find((track): track is LocalAudioTrack => track instanceof LocalAudioTrack),
130 [tracks],
131 )
132 const cameraTrack = useMemo(
133 () => tracks?.find((track): track is LocalVideoTrack => track instanceof LocalVideoTrack),
134 [tracks],
135 )
136 const level = useTrackVolume(micTrack)
137
138 useEffect(() => {
139 const element = videoRef.current
140 if (!element || !cameraTrack) return
141 cameraTrack.attach(element)
142 return () => {
143 cameraTrack.detach(element)
144 }
145 }, [cameraTrack])
146
147 if (!open) return null
148
149 const confirm = () => {
150 if (allowMicrophone) {
151 saveAudioInputDeviceId(audioDeviceId ?? 'default')
152 saveVideoInputDeviceId(videoDeviceId ?? 'default')
153 }
154 rememberOutputDevice(outputDeviceId)
155 onConfirm({
156 startWithMic: allowMicrophone && micOn,
157 audioDeviceId: allowMicrophone ? audioDeviceId : null,
158 videoDeviceId: allowMicrophone ? videoDeviceId : null,
159 audioOutputDeviceId: outputDeviceId,
160 })
161 }
162
163 const notice = previewError ?? error
164
165 return (
166 <div className="voice-sheet-backdrop" role="presentation" onClick={busy ? undefined : onCancel}>
167 <div
168 className="voice-sheet"
169 role="dialog"
170 aria-modal="true"
171 aria-label={t('chat.voice.deviceTitle')}
172 onClick={event => event.stopPropagation()}
173 >
174 <h3 className="voice-sheet-title">{t('chat.voice.deviceTitle')}</h3>
175 <p className="voice-sheet-hint">{t('chat.voice.deviceHint')}</p>
176
177 {allowMicrophone ? (
178 <>
179 <DeviceSelect
180 id="voice-sheet-mic"
181 kind="audioinput"
182 icon={<Mic className="h-3.5 w-3.5" aria-hidden />}
183 label={t('chat.voice.mic')}
184 value={audioDeviceId}
185 emptyLabel={t('chat.voice.deviceNoMic')}
186 onChange={setAudioDeviceId}
187 />
188 <div className="voice-sheet-level" aria-hidden>
189 <span
190 className="voice-sheet-level-bar"
191 style={{ transform: 'scaleX(' + Math.min(1, level * 3) + ')' }}
192 />
193 </div>
194 <p className="voice-sheet-caption">{t('chat.voice.micTest')}</p>
195 <DeviceSelect
196 id="voice-sheet-camera"
197 kind="videoinput"
198 icon={<Video className="h-3.5 w-3.5" aria-hidden />}
199 label={t('chat.voice.camera')}
200 value={videoDeviceId}
201 emptyLabel={t('chat.voice.deviceNoCamera')}
202 onChange={setVideoDeviceId}
203 />
204 {cameraTrack ? (
205 <video ref={videoRef} className="voice-sheet-preview" autoPlay playsInline muted />
206 ) : null}
207 <label className="voice-sheet-check">
208 <input
209 type="checkbox"
210 checked={micOn}
211 onChange={event => setMicOn(event.target.checked)}
212 />
213 <span>{t('chat.voice.joinWithMic')}</span>
214 </label>
215 </>
216 ) : (
217 <p className="voice-sheet-caption">{t('chat.voice.noPublish')}</p>
218 )}
219
220 <DeviceSelect
221 id="voice-sheet-speaker"
222 kind="audiooutput"
223 icon={<Volume2 className="h-3.5 w-3.5" aria-hidden />}
224 label={t('chat.voice.speaker')}
225 value={outputDeviceId}
226 emptyLabel={t('chat.voice.deviceNoOutput')}
227 onChange={setOutputDeviceId}
228 />
229
230 {notice ? <p className="voice-sheet-notice">{notice}</p> : null}
231
232 <div className="voice-sheet-actions">
233 <button type="button" className="voice-ghost-button" onClick={onCancel} disabled={busy}>
234 {t('common.action.cancel')}
235 </button>
236 <button type="button" className="voice-primary-button" onClick={confirm} disabled={busy}>
237 {busy ? (
238 <>
239 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
240 </>
241 ) : (
242 t('chat.voice.deviceJoin')
243 )}
244 </button>
245 </div>
246 </div>
247 </div>
248 )
249}
Added web/src/features/chat/ui/voice/voice-devices.ts web/src/features/chat/ui/voice/voice-devices.ts +26 −0 View web/src/features/chat/ui/voice/voice-devices.ts
@@ -0,0 +1,26 @@
1/**
2 * Output-device memory.
3 *
4 * Microphone/camera choices are persisted through the official
5 * `usePersistentUserChoices` store so the vendored `ControlBar` device menus and
6 * the pre-join sheet agree. The loudspeaker route is not part of that store
7 * (`LocalUserChoices` has no output field), so it gets its own key here.
8 */
9const OUTPUT_DEVICE_KEY = 'gitdata.voice.outputDeviceId'
10
11export function readOutputDevice(): string | null {
12 try {
13 return localStorage.getItem(OUTPUT_DEVICE_KEY)
14 } catch {
15 return null
16 }
17}
18
19export function rememberOutputDevice(deviceId: string | null): void {
20 try {
21 if (deviceId) localStorage.setItem(OUTPUT_DEVICE_KEY, deviceId)
22 else localStorage.removeItem(OUTPUT_DEVICE_KEY)
23 } catch {
24 // Storage is a convenience here, never a requirement.
25 }
26}
Added web/src/features/chat/ui/voice/voice-format.ts web/src/features/chat/ui/voice/voice-format.ts +8 −0 View web/src/features/chat/ui/voice/voice-format.ts
@@ -0,0 +1,8 @@
1/** Small display helpers shared by the voice surfaces. */
2
3/** Two-letter monogram for a member who has no avatar and no camera. */
4export function initials(name: string | undefined | null): string {
5 const trimmed = (name ?? '').trim()
6 if (!trimmed) return '?'
7 return [...trimmed].slice(0, 2).join('').toUpperCase()
8}
Added web/src/features/chat/ui/voice/voice-tile-layout.ts web/src/features/chat/ui/voice/voice-tile-layout.ts +410 −0 View web/src/features/chat/ui/voice/voice-tile-layout.ts
@@ -0,0 +1,410 @@
1/**
2 * Shared participant-window layout.
3 *
4 * Coordinates are normalized against the free span of the tile canvas (0..1)
5 * instead of pixels, so one placement stays inside the bounds for every member
6 * on every screen size. The layout is a *shared* artefact: it is stored per
7 * channel on the server and broadcast over the chat socket, so the room looks
8 * the same for everyone.
9 *
10 * Concurrency rules (this is what used to make windows jump under a second
11 * drag):
12 * - a write carries exactly one tile - the one the local user moved - and the
13 * server merges per key, so two people dragging different windows can never
14 * erase each other;
15 * - every tile stores the server's write stamp, and a snapshot only replaces a
16 * local tile when it is at least as new, which keeps an in-flight GET from
17 * rewinding a window the user just parked;
18 * - while a window is under the pointer it is frozen: no remote value is merged
19 * into the key being dragged.
20 */
21import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
22import {
23 getVoiceLayout,
24 removeVoiceLayoutTiles,
25 saveVoiceLayoutTiles,
26 type VoiceTileWrite,
27 type VoiceLayoutTiles,
28} from '@/entities/chat'
29
30export interface TilePlacement {
31 /** 0 -> glued to the left edge, 1 -> glued to the right edge. */
32 x: number
33 /** 0 -> glued to the top edge, 1 -> glued to the bottom edge. */
34 y: number
35 /** 0 -> smallest window, 1 -> largest window. */
36 w: number
37}
38
39export interface PlacedTile extends TilePlacement {
40 /** Server write stamp in seconds; 0 for a placement that was never pushed. */
41 updatedAt: number
42}
43
44interface TileBox {
45 left: number
46 top: number
47 width: number
48}
49
50interface TileGesture {
51 mode: 'move' | 'resize'
52 key: string
53 origin: TileBox
54 startX: number
55 startY: number
56}
57
58interface CanvasBox {
59 width: number
60 height: number
61}
62
63const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
64const TILE_MIN_WIDTH = 140
65const TILE_MAX_WIDTH = 640
66const TILE_SNAP = 4
67const TILE_RATIO = 3 / 4
68/** Mirrors the padding of the tile canvas: windows never cross it. */
69const TILE_PAD_X = 16
70const TILE_PAD_Y = 18
71/** Shared-layout write throttle, so a drag stays live without flooding. */
72const LAYOUT_PUSH_MS = 140
73
74const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0)
75
76function spansOf(box: CanvasBox) {
77 const usableW = Math.max(0, box.width - TILE_PAD_X * 2)
78 const usableH = Math.max(0, box.height - TILE_PAD_Y * 2)
79 const maxW = Math.max(0, Math.min(TILE_MAX_WIDTH, usableW, usableH / TILE_RATIO))
80 return { usableW, usableH, maxW, minW: Math.min(TILE_MIN_WIDTH, maxW) }
81}
82
83/** Normalized placement -> pixels; clamping to the span makes overflow impossible. */
84export function pixelsFor(box: CanvasBox, placement: TilePlacement): TileBox | null {
85 const { usableW, usableH, maxW, minW } = spansOf(box)
86 if (maxW <= 0) return null
87 const width = Math.round(minW + clamp01(placement.w) * (maxW - minW))
88 const height = width * TILE_RATIO
89 return {
90 left: Math.round(TILE_PAD_X + clamp01(placement.x) * Math.max(0, usableW - width)),
91 top: Math.round(TILE_PAD_Y + clamp01(placement.y) * Math.max(0, usableH - height)),
92 width,
93 }
94}
95
96function normalizedFor(box: CanvasBox, tile: TileBox): TilePlacement {
97 const { usableW, usableH, maxW, minW } = spansOf(box)
98 const width = Math.min(Math.max(minW, tile.width), maxW)
99 const height = width * TILE_RATIO
100 const spanW = Math.max(1, usableW - width)
101 const spanH = Math.max(1, usableH - height)
102 return {
103 x: clamp01((tile.left - TILE_PAD_X) / spanW),
104 y: clamp01((tile.top - TILE_PAD_Y) / spanH),
105 w: maxW <= minW ? 1 : clamp01((width - minW) / (maxW - minW)),
106 }
107}
108
109function readLocal(roomId: string): Record<string, PlacedTile> {
110 try {
111 const raw = localStorage.getItem(LAYOUT_PREFIX + roomId)
112 if (!raw) return {}
113 const parsed = JSON.parse(raw) as Record<string, Partial<PlacedTile>>
114 const out: Record<string, PlacedTile> = {}
115 for (const [key, value] of Object.entries(parsed)) {
116 if (
117 typeof value?.x === 'number' &&
118 typeof value?.y === 'number' &&
119 typeof value?.w === 'number'
120 ) {
121 out[key] = {
122 x: clamp01(value.x),
123 y: clamp01(value.y),
124 w: clamp01(value.w),
125 updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
126 }
127 }
128 }
129 return out
130 } catch {
131 return {}
132 }
133}
134
135function writeLocal(roomId: string, layout: Record<string, PlacedTile>): void {
136 try {
137 const key = LAYOUT_PREFIX + roomId
138 if (Object.keys(layout).length === 0) localStorage.removeItem(key)
139 else localStorage.setItem(key, JSON.stringify(layout))
140 } catch {
141 // A layout that cannot persist is not worth failing the call over.
142 }
143}
144
145function toTile(placement: PlacedTile): VoiceTileWrite {
146 return { x: placement.x, y: placement.y, w: placement.w }
147}
148
149export interface VoiceTileLayout {
150 layout: Record<string, PlacedTile>
151 /** Callback ref for the tile canvas: the observer follows it in and out. */
152 setCanvas: (element: HTMLDivElement | null) => void
153 canvasSize: CanvasBox
154 /** Key currently under the pointer, or null. Tiles lose their placement while focused. */
155 draggingKey: string | null
156 /** Pixels for a member's window, or null when it belongs to the automatic grid. */
157 boxOf: (key: string) => TileBox | null
158 beginGesture: (event: ReactPointerEvent<HTMLElement>, key: string, mode: 'move' | 'resize') => void
159 moveGesture: (event: ReactPointerEvent<HTMLElement>, key: string) => void
160 endGesture: (key: string) => void
161 releaseTile: (key: string) => void
162 /** True right after a window was dragged, so the click can be swallowed. */
163 didDrag: (key: string) => boolean
164 /** Pull the shared layout; call it on the socket event and when members change. */
165 pullRemote: () => Promise<void>
166}
167
168export function useVoiceTileLayout(roomId: string): VoiceTileLayout {
169 const [layout, setLayout] = useState<Record<string, PlacedTile>>(() => readLocal(roomId))
170 const layoutRef = useRef(layout)
171 const draggingRef = useRef<string | null>(null)
172 const gestureRef = useRef<TileGesture | null>(null)
173 /** Keys whose last gesture moved; consumed once by `didDrag`. */
174 const movedKeys = useRef(new Set<string>())
175 const [canvasEl, setCanvasEl] = useState<HTMLDivElement | null>(null)
176 const [canvasSize, setCanvasSize] = useState<CanvasBox>({ width: 0, height: 0 })
177 const setCanvas = useCallback((element: HTMLDivElement | null) => setCanvasEl(element), [])
178 const [draggingKey, setDraggingKey] = useState<string | null>(null)
179 /** Per-key push throttle: one in-flight timer, always for the key being dragged. */
180 const pushRef = useRef<{ key: string | null; last: number; timer: number }>({
181 key: null,
182 last: 0,
183 timer: 0,
184 })
185
186 useEffect(() => {
187 layoutRef.current = layout
188 writeLocal(roomId, layout)
189 }, [roomId, layout])
190
191 // The canvas belongs to the board, which mounts after this hook, so the
192 // observed element arrives as state rather than being read off a ref.
193 useEffect(() => {
194 const el = canvasEl
195 const measure = () =>
196 setCanvasSize(previous =>
197 !el || (previous.width === el.clientWidth && previous.height === el.clientHeight)
198 ? previous
199 : { width: el.clientWidth, height: el.clientHeight },
200 )
201 measure()
202 if (!el) return
203 const observer = new ResizeObserver(measure)
204 observer.observe(el)
205 return () => observer.disconnect()
206 }, [canvasEl])
207
208 const commit = useCallback((next: Record<string, PlacedTile>) => {
209 layoutRef.current = next
210 setLayout(next)
211 }, [])
212
213 /** Write the single tile that changed; the server merges it into the room map. */
214 const pushKey = useCallback(
215 (key: string, final: boolean) => {
216 const flush = () => {
217 const placement = layoutRef.current[key]
218 if (!placement) return
219 pushRef.current.last = Date.now()
220 void saveVoiceLayoutTiles(roomId, { [key]: toTile(placement) })
221 .then(stamp => {
222 if (!stamp) return
223 const current = layoutRef.current[key]
224 if (!current) return
225 const next = { ...layoutRef.current, [key]: { ...current, updatedAt: stamp } }
226 layoutRef.current = next
227 setLayout(next)
228 })
229 .catch(() => undefined)
230 }
231 const push = pushRef.current
232 if (push.timer) {
233 clearTimeout(push.timer)
234 push.timer = 0
235 }
236 // Switching keys mid-drag cannot happen (one pointer), but a stale timer
237 // for a tile the user has let go of must still fire.
238 if (push.key && push.key !== key) flush()
239 push.key = key
240 const wait = LAYOUT_PUSH_MS - (Date.now() - push.last)
241 if (final || wait <= 0) flush()
242 else
243 push.timer = window.setTimeout(() => {
244 push.timer = 0
245 flush()
246 }, wait)
247 },
248 [roomId],
249 )
250
251 const pullRemote = useCallback(async () => {
252 let tiles: VoiceLayoutTiles
253 try {
254 tiles = await getVoiceLayout(roomId)
255 } catch {
256 // No shared layout yet simply means the automatic grid.
257 return
258 }
259 const next = { ...layoutRef.current }
260 let changed = false
261 const frozen = draggingRef.current
262 for (const [key, tile] of Object.entries(tiles)) {
263 if (key === frozen) continue
264 const local = next[key]
265 const updatedAt = tile.updated_at ?? 0
266 // A window we wrote after the snapshot we are merging wins; otherwise the
267 // room does. Both stamps come from the same server clock, so a slow GET
268 // can never rewind a window the user has just parked.
269 if (local && local.updatedAt > updatedAt) continue
270 const placement: PlacedTile = {
271 x: clamp01(tile.x),
272 y: clamp01(tile.y),
273 w: clamp01(tile.w),
274 updatedAt,
275 }
276 if (
277 !local ||
278 local.x !== placement.x ||
279 local.y !== placement.y ||
280 local.w !== placement.w
281 ) {
282 next[key] = placement
283 changed = true
284 }
285 }
286 if (changed) commit(next)
287 }, [roomId, commit])
288
289 const beginGesture = useCallback(
290 (event: ReactPointerEvent<HTMLElement>, key: string, mode: 'move' | 'resize') => {
291 const canvas = canvasEl
292 if (!canvas || event.button !== 0) return
293 // The tile menu stays clickable: grabbing a button is not grabbing the
294 // window, and pointer capture on the tile would swallow the click.
295 if (event.target instanceof Element && event.target.closest('button')) return
296 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
297 if (!el) return
298 const canvasRect = canvas.getBoundingClientRect()
299 const tileRect = el.getBoundingClientRect()
300 const rectBox: TileBox = {
301 left: tileRect.left - canvasRect.left + canvas.scrollLeft,
302 top: tileRect.top - canvasRect.top + canvas.scrollTop,
303 width: tileRect.width,
304 }
305 const parked = layoutRef.current[key]
306 const origin = (parked ? pixelsFor(canvasSize, parked) : null) ?? rectBox
307 movedKeys.current.delete(key)
308 gestureRef.current = {
309 mode,
310 key,
311 origin,
312 startX: event.clientX,
313 startY: event.clientY,
314 }
315 draggingRef.current = key
316 setDraggingKey(key)
317 // A grid window lifts out of the flow at exactly where it was drawn, so
318 // the first pointer-down never makes it jump.
319 if (!parked) {
320 commit({ ...layoutRef.current, [key]: { ...normalizedFor(canvasSize, rectBox), updatedAt: 0 } })
321 }
322 el.setPointerCapture(event.pointerId)
323 },
324 [canvasEl, canvasSize, commit],
325 )
326
327 const moveGesture = useCallback(
328 (event: ReactPointerEvent<HTMLElement>, key: string) => {
329 const active = gestureRef.current
330 if (!active || active.key !== key) return
331 const dx = event.clientX - active.startX
332 const dy = event.clientY - active.startY
333 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) movedKeys.current.add(key)
334 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
335 const next: TileBox =
336 active.mode === 'move'
337 ? { left: snap(active.origin.left + dx), top: snap(active.origin.top + dy), width: active.origin.width }
338 : { ...active.origin, width: snap(active.origin.width + dx) }
339 commit({
340 ...layoutRef.current,
341 [key]: { ...normalizedFor(canvasSize, next), updatedAt: 0 },
342 })
343 pushKey(key, false)
344 },
345 [canvasSize, commit, pushKey],
346 )
347
348 const endGesture = useCallback(
349 (key: string) => {
350 const active = gestureRef.current
351 if (!active || active.key !== key) return
352 gestureRef.current = null
353 draggingRef.current = null
354 setDraggingKey(null)
355 pushKey(key, true)
356 },
357 [pushKey],
358 )
359
360 /** One-shot: true the first time it is asked after that window was dragged. */
361 const didDrag = useCallback((key: string) => movedKeys.current.delete(key), [])
362
363 const releaseTile = useCallback(
364 (key: string) => {
365 const next = { ...layoutRef.current }
366 delete next[key]
367 commit(next)
368 const push = pushRef.current
369 if (push.timer) {
370 clearTimeout(push.timer)
371 push.timer = 0
372 }
373 // Dropping a window is a write too, otherwise it resurrects on the
374 // next pull and the member cannot get rid of a parked tile.
375 void removeVoiceLayoutTiles(roomId, [key]).catch(() => undefined)
376 },
377 [roomId, commit],
378 )
379
380 const boxOf = useCallback(
381 (key: string) => {
382 const placement = layout[key]
383 return placement ? pixelsFor(canvasSize, placement) : null
384 },
385 [layout, canvasSize],
386 )
387
388 useEffect(() => {
389 const push = pushRef.current
390 return () => {
391 if (push.timer) clearTimeout(push.timer)
392 push.timer = 0
393 push.last = 0
394 }
395 }, [])
396
397 return {
398 layout,
399 setCanvas,
400 canvasSize,
401 draggingKey,
402 boxOf,
403 beginGesture,
404 moveGesture,
405 endGesture,
406 releaseTile,
407 didDrag,
408 pullRemote,
409 }
410}
Added web/src/features/chat/ui/voice/voice-tiles.tsx web/src/features/chat/ui/voice/voice-tiles.tsx +435 −0 View web/src/features/chat/ui/voice/voice-tiles.tsx
@@ -0,0 +1,435 @@
1/**
2 * The in-call board: one window per participant, built from the vendored
3 * LiveKit components.
4 *
5 * Every piece of media state is read from the Room itself, which is what fixes
6 * the class of bugs this view used to have: the speaking ring, the "their mic
7 * is off" flag and the connection quality come from the live participant
8 * objects, not from a webhook snapshot; a screen share is rendered exactly once
9 * (as the focused window), and a second sharer no longer loses to Map iteration
10 * order because the stage holds the newest subscribed share.
11 *
12 * The one thing the official layouts do not have is GitData's shared window
13 * placement, so a placed window is lifted out of the grid and positioned by
14 * `useVoiceTileLayout`; while any window is focused, placements are ignored.
15 */
16import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
17import { LogOut, Maximize2, Mic, MicOff, Minimize2, Volume2 } from 'lucide-react'
18import { RoomEvent, Track, type RemoteParticipant } from 'livekit-client'
19import {
20 CarouselLayout,
21 TrackMutedIndicator,
22 FocusLayout,
23 FocusLayoutContainer,
24 GridLayout,
25 ParticipantAudioTile,
26 LayoutContextProvider,
27 ParticipantTile,
28 getTrackReferenceId,
29 isTrackReference,
30 useCreateLayoutContext,
31 useLayoutContext,
32 useMaybeTrackRefContext,
33 useParticipants,
34 usePinnedTracks,
35 useRoomContext,
36 useTracks,
37 type TrackReferenceOrPlaceholder,
38} from '@/shared/livekit'
39import { useT } from '@/shared/i18n'
40import { initials } from './voice-format'
41import type { VoiceTileLayout } from './voice-tile-layout'
42
43const VOLUME_PREFIX = 'gitdata.voice.volume.'
44
45interface BoardProps {
46 roomId: string
47 viewerId?: string
48 canModerate: boolean
49 layout: VoiceTileLayout
50 /** Server-side moderator mutes, keyed by user id (identity === user id). */
51 mutedIds: Set<string>
52 onMute: (userId: string, muted: boolean) => void
53 onKick: (userId: string) => void
54}
55
56/** Microphone reference per member, so a camera tile can still show "muted". */
57export type MicRefIndex = Map<string, TrackReferenceOrPlaceholder>
58
59function isShare(ref: TrackReferenceOrPlaceholder): boolean {
60 return ref.source === Track.Source.ScreenShare
61}
62
63/** Stable key for a share, used to remember when this tab first saw it. */
64function shareKeyOf(ref: TrackReferenceOrPlaceholder): string {
65 return isTrackReference(ref)
66 ? ref.publication.trackSid
67 : ref.participant.identity + ':' + String(ref.source)
68}
69
70function displayNameOf(ref: TrackReferenceOrPlaceholder): string {
71 return ref.participant.name || initials(ref.participant.identity)
72}
73
74/** Per-window actions: stage focus, this member's volume, moderation. */
75function TileMenu({
76 trackRef,
77 roomId,
78 viewerId,
79 canModerate,
80 moderatorMuted,
81 onMute,
82 onKick,
83}: {
84 trackRef: TrackReferenceOrPlaceholder
85 roomId: string
86 viewerId?: string
87 canModerate: boolean
88 moderatorMuted: boolean
89 onMute: (userId: string, muted: boolean) => void
90 onKick: (userId: string) => void
91}) {
92 const { t } = useT()
93 const room = useRoomContext()
94 const layoutContext = useLayoutContext()
95 const identity = trackRef.participant.identity
96 const pinned = usePinnedTracks(layoutContext)
97 const isPinned = pinned.some(entry => entry.participant.identity === identity)
98 const isSelf = identity === viewerId
99 const remembered = useMemo(() => {
100 try {
101 const raw = Number(localStorage.getItem(VOLUME_PREFIX + roomId + '.' + identity))
102 return Number.isFinite(raw) && raw > 0 ? raw : 1
103 } catch {
104 return 1
105 }
106 }, [roomId, identity])
107 const [volume, setVolume] = useState(remembered)
108
109 const applyVolume = (next: number) => {
110 setVolume(next)
111 const participant = room.getParticipantByIdentity(identity)
112 if (participant instanceof Object && 'setVolume' in participant) {
113 ;(participant as RemoteParticipant).setVolume(next)
114 }
115 try {
116 localStorage.setItem(VOLUME_PREFIX + roomId + '.' + identity, String(next))
117 } catch {
118 // Volume memory is a convenience, never a requirement.
119 }
120 }
121
122 const name = displayNameOf(trackRef)
123
124 return (
125 <div className="voice-tile-menu" onPointerDown={event => event.stopPropagation()}>
126 <button
127 type="button"
128 className={isPinned ? 'is-active' : ''}
129 title={isPinned ? t('chat.voice.clearFocus') : t('chat.voice.focus', { name })}
130 onClick={() =>
131 layoutContext.pin.dispatch?.(
132 isPinned ? { msg: 'clear_pin' } : { msg: 'set_pin', trackReference: trackRef },
133 )
134 }
135 >
136 {isPinned ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
137 </button>
138 {!isSelf ? (
139 <label className="voice-tile-volume">
140 <Volume2 className="h-3.5 w-3.5" aria-hidden />
141 <input
142 type="range"
143 min={0}
144 max={1}
145 step={0.05}
146 value={volume}
147 aria-label={t('chat.voice.volume', { name })}
148 onChange={event => applyVolume(Number(event.target.value))}
149 />
150 </label>
151 ) : null}
152 {canModerate && !isSelf ? (
153 <>
154 <button
155 type="button"
156 title={moderatorMuted ? t('chat.voice.forceUnmute') : t('chat.voice.forceMute')}
157 onClick={() => onMute(identity, !moderatorMuted)}
158 >
159 {moderatorMuted ? <Mic className="h-3.5 w-3.5" /> : <MicOff className="h-3.5 w-3.5" />}
160 </button>
161 <button type="button" title={t('chat.voice.kick')} onClick={() => onKick(identity)}>
162 <LogOut className="h-3.5 w-3.5" />
163 </button>
164 </>
165 ) : null}
166 </div>
167 )
168}
169
170/**
171 * One window. Inside `GridLayout`/`CarouselLayout` the track reference comes
172 * from the loop context; floated (placed) windows are rendered directly and get
173 * it as a property.
174 */
175function Window({
176 trackRef: explicitRef,
177 roomId,
178 viewerId,
179 canModerate,
180 mutedIds,
181 onMute,
182 onKick,
183 layout,
184 micRefs,
185}: Omit<BoardProps, 'layout'> & {
186 layout: VoiceTileLayout
187 trackRef?: TrackReferenceOrPlaceholder
188 micRefs: MicRefIndex
189}): ReactElement | null {
190 const { t } = useT()
191 const contextRef = useMaybeTrackRefContext()
192 const trackRef = explicitRef ?? contextRef
193 if (!trackRef) return null
194 const identity = trackRef.participant.identity
195 const micRef = micRefs.get(identity)
196 const box = layout.boxOf(identity)
197 const dragging = layout.draggingKey === identity
198 return (
199 <div
200 data-tile={identity}
201 className={[
202 'voice-window',
203 box ? 'is-floating' : '',
204 dragging ? 'is-dragging' : '',
205 ]
206 .filter(Boolean)
207 .join(' ')}
208 style={box ? { left: box.left, top: box.top, width: box.width } : undefined}
209 title={box ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
210 onPointerDown={event => layout.beginGesture(event, identity, 'move')}
211 onPointerMove={event => layout.moveGesture(event, identity)}
212 onPointerUp={() => layout.endGesture(identity)}
213 onPointerCancel={() => layout.endGesture(identity)}
214 onClickCapture={event => {
215 // Swallow the click that closes a drag: the official tile pins on
216 // click, and nobody expects moving a window to change the stage.
217 if (!layout.didDrag(identity)) return
218 event.stopPropagation()
219 event.preventDefault()
220 }}
221 onDoubleClick={event => {
222 if (event.target instanceof Element && event.target.closest('button,input')) return
223 layout.releaseTile(identity)
224 }}
225 >
226 {trackRef.source === Track.Source.Microphone ? (
227 /* The generic tile labels every non-camera source as a screen share, so
228 audio-only members get the official audio tile instead: level bars,
229 microphone state, name and quality. */
230 <ParticipantAudioTile trackRef={trackRef} className="voice-window-tile" />
231 ) : (
232 <ParticipantTile trackRef={trackRef} className="voice-window-tile" />
233 )}
234 {micRef && trackRef.source !== Track.Source.Microphone ? (
235 // The official tile only marks the source it renders, so a member whose
236 // window shows their camera still needs the microphone state.
237 <TrackMutedIndicator trackRef={micRef} show="muted" className="voice-window-mic" />
238 ) : null}
239 {box ? (
240 <span
241 className="voice-window-resize"
242 aria-hidden
243 onPointerDown={event => {
244 event.stopPropagation()
245 layout.beginGesture(event, identity, 'resize')
246 }}
247 />
248 ) : null}
249 <TileMenu
250 trackRef={trackRef}
251 roomId={roomId}
252 viewerId={viewerId}
253 canModerate={canModerate}
254 moderatorMuted={mutedIds.has(identity)}
255 onMute={onMute}
256 onKick={onKick}
257 />
258 </div>
259 )
260}
261
262/** Join/leave chatter: the visual tile set cannot announce it to a screen reader. */
263function VoiceAnnouncements({ viewerId }: { viewerId?: string }) {
264 const { t } = useT()
265 const participants = useParticipants()
266 const known = useRef<Map<string, string> | null>(null)
267 const [message, setMessage] = useState('')
268
269 useEffect(() => {
270 const ids = participants.map(participant => participant.identity)
271 if (known.current === null) {
272 known.current = new Map(participants.map(p => [p.identity, p.name || p.identity]))
273 return
274 }
275 for (const participant of participants) {
276 if (participant.identity === viewerId) continue
277 if (!known.current.has(participant.identity)) {
278 setMessage(t('chat.voice.aria.joined', { name: participant.name || participant.identity }))
279 }
280 }
281 for (const [identity, name] of known.current) {
282 if (!ids.includes(identity) && identity !== viewerId) {
283 setMessage(t('chat.voice.aria.left', { name }))
284 }
285 }
286 known.current = new Map(participants.map(p => [p.identity, p.name || p.identity]))
287 }, [participants, viewerId, t])
288
289 return (
290 <div className="voice-sr-only" role="status" aria-live="polite">
291 {message}
292 </div>
293 )
294}
295
296export function VoiceTileBoard(props: BoardProps) {
297 const { roomId, viewerId, canModerate, layout, mutedIds, onMute, onKick } = props
298 const { t } = useT()
299 const layoutContext = useCreateLayoutContext()
300 const pinnedTracks = usePinnedTracks(layoutContext)
301
302 const tracks = useTracks(
303 [
304 { source: Track.Source.Camera, withPlaceholder: false },
305 { source: Track.Source.Microphone, withPlaceholder: true },
306 { source: Track.Source.ScreenShare, withPlaceholder: false },
307 ],
308 { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
309 )
310
311 // One window per member: a camera wins over the microphone placeholder, and
312 // shares are kept out of the face list so they can never render twice.
313 const faceRefs = useMemo(() => {
314 const byIdentity = new Map<string, TrackReferenceOrPlaceholder>()
315 for (const ref of tracks) {
316 if (isShare(ref)) continue
317 const current = byIdentity.get(ref.participant.identity)
318 const isCamera = ref.source === Track.Source.Camera
319 if (!current || (isCamera && current.source !== Track.Source.Camera)) {
320 byIdentity.set(ref.participant.identity, ref)
321 }
322 }
323 return [...byIdentity.values()]
324 }, [tracks])
325
326 const shareRefs = useMemo(() => tracks.filter(ref => isShare(ref) && isTrackReference(ref)), [tracks])
327
328 const micRefs = useMemo(() => {
329 const index: MicRefIndex = new Map()
330 for (const ref of tracks) {
331 if (ref.source === Track.Source.Microphone) index.set(ref.participant.identity, ref)
332 }
333 return index
334 }, [tracks])
335 const boardRefs = useMemo(() => [...faceRefs, ...shareRefs], [faceRefs, shareRefs])
336
337 const focused = pinnedTracks[0]
338 const autoFocused = useRef<TrackReferenceOrPlaceholder | null>(null)
339 /**
340 * When each share first reached this tab. The SDK exposes no publish timestamp
341 * on a track publication, so recency is observed here instead - which also
342 * makes the stage choice independent of server clock drift.
343 */
344 const shareSeenAt = useRef(new Map<string, number>())
345
346 // Nobody pinned anything: the newest share takes the stage and steps down when
347 // it ends. A manual unpin is respected until a different share appears.
348 useEffect(() => {
349 if (pinnedTracks.length > 0) return
350 const autoSid = isTrackReference(autoFocused.current)
351 ? autoFocused.current.publication.trackSid
352 : null
353 const autoStillLive =
354 autoSid !== null &&
355 shareRefs.some(ref => isTrackReference(ref) && ref.publication.trackSid === autoSid)
356 if (autoStillLive) return
357 const now = Date.now()
358 const seen = shareSeenAt.current
359 const liveKeys = new Set(shareRefs.map(shareKeyOf))
360 for (const key of [...seen.keys()]) if (!liveKeys.has(key)) seen.delete(key)
361 for (const ref of shareRefs) {
362 const key = shareKeyOf(ref)
363 if (!seen.has(key)) seen.set(key, now)
364 }
365 const newest = [...shareRefs].sort(
366 (a, b) => (seen.get(shareKeyOf(b)) ?? 0) - (seen.get(shareKeyOf(a)) ?? 0),
367 )[0]
368 if (newest) {
369 autoFocused.current = newest
370 layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: newest })
371 } else if (autoFocused.current) {
372 autoFocused.current = null
373 layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
374 }
375 }, [shareRefs, pinnedTracks.length, layoutContext])
376
377 // While somebody owns the stage, placements are ignored: the rail and the
378 // focused window are laid out by the official components instead.
379 const windowProps = {
380 roomId,
381 viewerId,
382 canModerate,
383 mutedIds,
384 onMute,
385 onKick,
386 micRefs,
387 layout: focused ? { ...layout, boxOf: () => null } : layout,
388 }
389
390 if (focused) {
391 const rest = boardRefs.filter(
392 ref => !(ref.participant.identity === focused.participant.identity && ref.source === focused.source),
393 )
394 return (
395 <LayoutContextProvider value={layoutContext}>
396 <div className="voice-board">
397 <FocusLayoutContainer>
398 <CarouselLayout tracks={rest}>
399 <Window {...windowProps} />
400 </CarouselLayout>
401 <FocusLayout trackRef={focused} />
402 </FocusLayoutContainer>
403 </div>
404 <VoiceAnnouncements viewerId={viewerId} />
405 </LayoutContextProvider>
406 )
407 }
408
409 // Callback ref, not a ref read: the board owns the canvas element, so the
410 // layout hook learns about it when React attaches it (see useVoiceTileLayout).
411 const setCanvas = layout.setCanvas
412 const placedRefs = boardRefs.filter(ref => layout.layout[ref.participant.identity])
413 const gridRefs = boardRefs.filter(ref => !layout.layout[ref.participant.identity])
414
415 return (
416 <LayoutContextProvider value={layoutContext}>
417 <div className="voice-board" ref={setCanvas}>
418 <GridLayout tracks={gridRefs} className="voice-grid">
419 <Window {...windowProps} />
420 </GridLayout>
421 <div className="voice-floats">
422 {placedRefs.map(ref => (
423 <Window key={getTrackReferenceId(ref)} {...windowProps} trackRef={ref} />
424 ))}
425 </div>
426 {boardRefs.length <= 1 ? (
427 <p className="voice-empty" aria-hidden={boardRefs.length > 1}>
428 {t('chat.voice.solo')}
429 </p>
430 ) : null}
431 </div>
432 <VoiceAnnouncements viewerId={viewerId} />
433 </LayoutContextProvider>
434 )
435}
Added web/src/shared/i18n/messages/en/livekit.ts web/src/shared/i18n/messages/en/livekit.ts +21 −0 View web/src/shared/i18n/messages/en/livekit.ts
@@ -0,0 +1,21 @@
1/** Copy owned by the vendored LiveKit component sources in shared/livekit. */
2export const livekit = {
3 'livekit.microphone': 'Microphone',
4 'livekit.camera': 'Camera',
5 'livekit.shareScreen': 'Share screen',
6 'livekit.stopScreenShare': 'Stop screen share',
7 'livekit.chat': 'Chat',
8 'livekit.settings': 'Settings',
9 'livekit.leave': 'Leave',
10 'livekit.audioInputs': 'Audio inputs',
11 'livekit.videoInputs': 'Video inputs',
12 'livekit.allowAudio': 'Allow Audio',
13 'livekit.startAudio': 'Start Audio',
14 'livekit.startVideo': 'Start Video',
15 'livekit.reconnecting': 'Reconnecting...',
16 'livekit.connecting': 'Connecting...',
17 'livekit.disconnected': 'Disconnected',
18 'livekit.chatPlaceholder': 'Enter a message...',
19 'livekit.screenSuffix': ' ’s screen',
20 'livekit.participantPlaceholder': 'No video yet',
21}
Added web/src/shared/i18n/messages/zh-CN/livekit.ts web/src/shared/i18n/messages/zh-CN/livekit.ts +21 −0 View web/src/shared/i18n/messages/zh-CN/livekit.ts
@@ -0,0 +1,21 @@
1/** Copy owned by the vendored LiveKit component sources in shared/livekit. */
2export const livekit = {
3 'livekit.microphone': '麦克风',
4 'livekit.camera': '摄像头',
5 'livekit.shareScreen': '共享屏幕',
6 'livekit.stopScreenShare': '停止共享',
7 'livekit.chat': '聊天',
8 'livekit.settings': '设置',
9 'livekit.leave': '离开',
10 'livekit.audioInputs': '音频输入',
11 'livekit.videoInputs': '视频输入',
12 'livekit.allowAudio': '允许播放声音',
13 'livekit.startAudio': '开启声音',
14 'livekit.startVideo': '开启视频',
15 'livekit.reconnecting': '正在重新连接…',
16 'livekit.connecting': '正在连接…',
17 'livekit.disconnected': '连接已断开',
18 'livekit.chatPlaceholder': '输入消息…',
19 'livekit.screenSuffix': '的屏幕共享',
20 'livekit.participantPlaceholder': '暂无画面',
21}
Added web/src/shared/livekit/core/components/chat.ts web/src/shared/livekit/core/components/chat.ts +309 −0 View web/src/shared/livekit/core/components/chat.ts
@@ -0,0 +1,309 @@
1/* eslint-disable camelcase */
2import type { Room, SendTextOptions } from 'livekit-client';
3import { compareVersions, RoomEvent } from 'livekit-client';
4import {
5 BehaviorSubject,
6 Subject,
7 scan,
8 map,
9 takeUntil,
10 from,
11 filter,
12 concatMap,
13 mergeMap,
14 finalize,
15 of,
16} from 'rxjs';
17import {
18 DataTopic,
19 LegacyDataTopic,
20 sendMessage,
21 setupDataMessageHandler,
22} from '../observables/dataChannel';
23import { log } from '../logger';
24import { type ChatMessage, type ReceivedChatMessage } from '../messages/types';
25import { Future } from '../helper/future';
26
27/** @public */
28export type { ChatMessage, ReceivedChatMessage };
29
30export interface LegacyChatMessage extends ChatMessage {
31 ignoreLegacy?: boolean;
32}
33
34export interface LegacyReceivedChatMessage extends ReceivedChatMessage {
35 ignoreLegacy?: boolean;
36}
37
38/**
39 * @public
40 * @deprecated the new chat API doesn't rely on encoders and decoders anymore and uses a dedicated chat API instead
41 */
42export type MessageEncoder = (message: LegacyChatMessage) => Uint8Array;
43/**
44 * @public
45 * @deprecated the new chat API doesn't rely on encoders and decoders anymore and uses a dedicated chat API instead
46 */
47export type MessageDecoder = (message: Uint8Array) => LegacyReceivedChatMessage;
48/** @public */
49export type ChatOptions = {
50 /** @deprecated the new chat API doesn't rely on encoders and decoders anymore and uses a dedicated chat API instead */
51 messageEncoder?: (message: LegacyChatMessage) => Uint8Array;
52 /** @deprecated the new chat API doesn't rely on encoders and decoders anymore and uses a dedicated chat API instead */
53 messageDecoder?: (message: Uint8Array) => LegacyReceivedChatMessage;
54 channelTopic?: string;
55 /** @deprecated the new chat API doesn't rely on update topics anymore and uses a dedicated chat API instead */
56 updateChannelTopic?: string;
57};
58
59const topicSubjectMap: WeakMap<Room, Map<string, Subject<ReceivedChatMessage>>> = new WeakMap();
60const streamIdToAttachments = new Map<
61 string /* stream id */,
62 Map<
63 string /* attachment id */,
64 Future<
65 {
66 fileName: string;
67 mimeType: string;
68 buffer: Array<Uint8Array>;
69 },
70 never
71 >
72 >
73>();
74
75function isIgnorableChatMessage(msg: ReceivedChatMessage | LegacyReceivedChatMessage) {
76 return (msg as LegacyChatMessage).ignoreLegacy == true;
77}
78
79const decodeLegacyMsg = (message: Uint8Array) =>
80 JSON.parse(new TextDecoder().decode(message)) as
81 LegacyReceivedChatMessage | Exclude<ReceivedChatMessage, 'type'>;
82
83const encodeLegacyMsg = (message: LegacyChatMessage) =>
84 new TextEncoder().encode(JSON.stringify(message));
85
86export function setupChat(room: Room, options?: ChatOptions) {
87 const serverSupportsDataStreams = () =>
88 room.serverInfo?.edition === 1 ||
89 (!!room.serverInfo?.version && compareVersions(room.serverInfo?.version, '1.8.2') > 0);
90
91 const onDestroyObservable = new Subject<void>();
92
93 const topic = options?.channelTopic ?? DataTopic.CHAT;
94 const legacyTopic = options?.channelTopic ?? LegacyDataTopic.CHAT;
95
96 let needsSetup = false;
97 if (!topicSubjectMap.has(room)) {
98 needsSetup = true;
99 }
100 const topicMap = topicSubjectMap.get(room) ?? new Map<string, Subject<ReceivedChatMessage>>();
101 const messageSubject = topicMap.get(topic) ?? new Subject<ReceivedChatMessage>();
102 topicMap.set(topic, messageSubject);
103 topicSubjectMap.set(room, topicMap);
104
105 const finalMessageDecoder = options?.messageDecoder ?? decodeLegacyMsg;
106 if (needsSetup) {
107 room.registerTextStreamHandler(topic, async (reader, participantInfo) => {
108 const { id, timestamp, attributes, attachedStreamIds } = reader.info;
109
110 // Store a future for each attachment to be later resolved once the corresponding file data
111 // stream completes.
112 const attachments = new Map(
113 (attachedStreamIds ?? []).map((id) => [
114 id,
115 new Future<{ fileName: string; mimeType: string; buffer: Array<Uint8Array> }, never>(),
116 ]),
117 );
118 streamIdToAttachments.set(id, attachments);
119
120 const streamObservable = from(reader).pipe(
121 scan((acc: string, chunk: string) => {
122 return acc + chunk;
123 }),
124 mergeMap((chunk: string) => {
125 if (attachments.size === 0) {
126 return of({ chunk, attachedFiles: [] });
127 } else {
128 // Aggregate all attachments into memory and transform them into a list of files
129 return from(attachments.values()).pipe(
130 // `concatMap`, not `mergeMap`: `attachments` is built from `attachedStreamIds`, so it is
131 // already in the sender's order, but `mergeMap` emits each promise as it RESOLVES — which
132 // ordered `attachedFiles` by download completion (i.e. smallest file first). All the
133 // promises are in flight either way; `concatMap` only sequences the emissions.
134 concatMap((attachment) => from(attachment.promise)),
135 scan(
136 (acc, attachment) => [
137 ...acc,
138 // Preserve the MIME type: `new File(buffer, name)` leaves `File.type` empty, and
139 // consumers (including `ChatEntry`) test `file.type.startsWith('image/')`, which could
140 // never be true for a received attachment.
141 new File(attachment.buffer as BlobPart[], attachment.fileName, { type: attachment.mimeType }),
142 ],
143 [] as Array<File>,
144 ),
145 map((attachedFiles) => ({ chunk, attachedFiles })),
146 );
147 }
148 }),
149 map(({ chunk, attachedFiles }) => {
150 return {
151 id,
152 timestamp,
153 message: chunk,
154 from: room.getParticipantByIdentity(participantInfo.identity),
155 type: 'chatMessage',
156 attributes,
157 attachedFiles,
158 // editTimestamp: type === 'update' ? timestamp : undefined,
159 } satisfies ReceivedChatMessage;
160 }),
161 finalize(() => streamIdToAttachments.delete(id)),
162 );
163 streamObservable.subscribe({
164 next: (value) => messageSubject.next(value),
165 });
166 });
167 // NOTE: Attachment byte streams are guaranteed to arrive after their parent text stream
168 // has initialized the attachment map (per client SDK sending implementation)
169 room.registerByteStreamHandler(topic, async (reader) => {
170 const { id: attachmentStreamId } = reader.info;
171 const foundStreamAttachmentPair = Array.from(streamIdToAttachments).find(([, attachments]) =>
172 attachments.has(attachmentStreamId),
173 );
174 if (!foundStreamAttachmentPair) {
175 return;
176 }
177 const streamId = foundStreamAttachmentPair[0];
178
179 const bufferList = [];
180 for await (const buffer of reader) {
181 bufferList.push(buffer);
182 }
183
184 const attachment = streamIdToAttachments.get(streamId)?.get(attachmentStreamId);
185 if (!attachment) {
186 return;
187 }
188
189 attachment.resolve?.({
190 fileName: reader.info.name,
191 mimeType: reader.info.mimeType,
192 buffer: bufferList,
193 });
194 });
195
196 /** legacy chat protocol handling */
197 const { messageObservable } = setupDataMessageHandler(room, [legacyTopic]);
198 messageObservable
199 .pipe(
200 map((msg) => {
201 const parsedMessage = finalMessageDecoder(msg.payload);
202 if (isIgnorableChatMessage(parsedMessage)) {
203 return undefined;
204 }
205 const newMessage: ReceivedChatMessage = {
206 ...parsedMessage,
207 type: 'chatMessage',
208 from: msg.from,
209 };
210 return newMessage;
211 }),
212 filter((msg) => !!msg),
213 takeUntil(onDestroyObservable),
214 )
215 .subscribe(messageSubject);
216 }
217
218 /** Build up the message array over time. */
219 const messagesObservable = messageSubject.pipe(
220 scan<ReceivedChatMessage, ReceivedChatMessage[]>((acc, value) => {
221 if (
222 'id' in value &&
223 acc.find((msg) => msg.from?.identity === value.from?.identity && msg.id === value.id)
224 ) {
225 const replaceIndex = acc.findIndex((msg) => msg.id === value.id);
226 if (replaceIndex > -1) {
227 const originalMsg = acc[replaceIndex];
228 acc[replaceIndex] = {
229 ...value,
230 timestamp: originalMsg.timestamp,
231 editTimestamp: value.timestamp,
232 };
233 }
234 return [...acc];
235 }
236 return [...acc, value];
237 }, []),
238 takeUntil(onDestroyObservable),
239 );
240
241 const isSending$ = new BehaviorSubject<boolean>(false);
242 const finalMessageEncoder = options?.messageEncoder ?? encodeLegacyMsg;
243
244 const send = async (message: string, options?: SendTextOptions) => {
245 if (!options) {
246 options = {};
247 }
248 options.topic ??= topic;
249 isSending$.next(true);
250
251 try {
252 const info = await room.localParticipant.sendText(message, options);
253
254 const legacyChatMsg: LegacyChatMessage = {
255 id: info.id,
256 timestamp: Date.now(),
257 message,
258 };
259
260 const chatMsg: ChatMessage = {
261 ...legacyChatMsg,
262 attachedFiles: options.attachments,
263 };
264
265 const receivedChatMsg: ReceivedChatMessage = {
266 ...chatMsg,
267 type: 'chatMessage',
268 from: room.localParticipant,
269 attributes: options.attributes,
270 };
271
272 messageSubject.next(receivedChatMsg);
273
274 const encodedLegacyMsg = finalMessageEncoder({
275 ...legacyChatMsg,
276 ignoreLegacy: serverSupportsDataStreams(),
277 });
278
279 try {
280 await sendMessage(room.localParticipant, encodedLegacyMsg, {
281 reliable: true,
282 topic: legacyTopic,
283 });
284 } catch (error) {
285 log.info('could not send message in legacy chat format', error);
286 }
287
288 return receivedChatMsg;
289 } finally {
290 isSending$.next(false);
291 }
292 };
293
294 function destroy() {
295 onDestroyObservable.next();
296 onDestroyObservable.complete();
297 messageSubject.complete();
298 topicSubjectMap.delete(room);
299 room.unregisterTextStreamHandler(topic);
300 room.unregisterByteStreamHandler(topic);
301 }
302 room.once(RoomEvent.Disconnected, destroy);
303
304 return {
305 messageObservable: messagesObservable,
306 isSendingObservable: isSending$,
307 send,
308 };
309}
Added web/src/shared/livekit/core/components/chatToggle.ts web/src/shared/livekit/core/components/chatToggle.ts +6 −0 View web/src/shared/livekit/core/components/chatToggle.ts
@@ -0,0 +1,6 @@
1import { prefixClass } from '../styles-interface';
2
3export function setupChatToggle() {
4 const className: string = [prefixClass('button'), prefixClass('chat-toggle')].join(' ');
5 return { className };
6}
Added web/src/shared/livekit/core/components/clearPinButton.ts web/src/shared/livekit/core/components/clearPinButton.ts +7 −0 View web/src/shared/livekit/core/components/clearPinButton.ts
@@ -0,0 +1,7 @@
1// import { prefixClass } from '../styles-interface';
2
3export function setupClearPinButton() {
4 // const className = prefixClass('clear-pin-button');
5 const className = 'lk-clear-pin-button lk-button';
6 return { className };
7}
Added web/src/shared/livekit/core/components/connectionQualityIndicator.ts web/src/shared/livekit/core/components/connectionQualityIndicator.ts +9 −0 View web/src/shared/livekit/core/components/connectionQualityIndicator.ts
@@ -0,0 +1,9 @@
1import type { Participant } from 'livekit-client';
2import { createConnectionQualityObserver } from '../observables/participant';
3import { prefixClass } from '../styles-interface';
4
5export function setupConnectionQualityIndicator(participant: Participant) {
6 const className = prefixClass('connection-quality');
7 const connectionQualityObserver = createConnectionQualityObserver(participant);
8 return { className, connectionQualityObserver };
9}
Added web/src/shared/livekit/core/components/disconnectButton.ts web/src/shared/livekit/core/components/disconnectButton.ts +10 −0 View web/src/shared/livekit/core/components/disconnectButton.ts
@@ -0,0 +1,10 @@
1import type { Room } from 'livekit-client';
2import { prefixClass } from '../styles-interface';
3
4export function setupDisconnectButton(room: Room) {
5 const disconnect = (stopTracks?: boolean) => {
6 room.disconnect(stopTracks);
7 };
8 const className: string = prefixClass('disconnect-button');
9 return { className, disconnect };
10}
Added web/src/shared/livekit/core/components/focusToggle.ts web/src/shared/livekit/core/components/focusToggle.ts +6 −0 View web/src/shared/livekit/core/components/focusToggle.ts
@@ -0,0 +1,6 @@
1import { prefixClass } from '../styles-interface';
2
3export function setupFocusToggle() {
4 const className: string = [prefixClass('button'), prefixClass('focus-toggle-button')].join(' ');
5 return { className };
6}
Added web/src/shared/livekit/core/components/mediaDeviceSelect.ts web/src/shared/livekit/core/components/mediaDeviceSelect.ts +70 −0 View web/src/shared/livekit/core/components/mediaDeviceSelect.ts
@@ -0,0 +1,70 @@
1import {
2 Track,
3 type LocalAudioTrack,
4 type LocalVideoTrack,
5 type Room,
6 type LocalTrack,
7 getBrowser,
8} from 'livekit-client';
9import { BehaviorSubject } from 'rxjs';
10import { log } from '../logger';
11import { prefixClass } from '../styles-interface';
12import { createActiveDeviceObservable } from '../observables/room';
13
14export type SetMediaDeviceOptions = {
15 /**
16 * If true, adds an `exact` constraint to the getUserMedia request.
17 * The request will fail if this option is true and the device specified is not actually available
18 */
19 exact?: boolean;
20};
21
22export function setupDeviceSelector(
23 kind: MediaDeviceKind,
24 room: Room,
25 localTrack?: LocalAudioTrack | LocalVideoTrack,
26) {
27 const activeDeviceSubject = new BehaviorSubject<string | undefined>(undefined);
28
29 const activeDeviceObservable = createActiveDeviceObservable(room, kind);
30
31 const setActiveMediaDevice = async (id: string, options: SetMediaDeviceOptions = {}) => {
32 if (localTrack) {
33 await localTrack.setDeviceId(options.exact ? { exact: id } : id);
34 const actualId = await localTrack.getDeviceId(false);
35 activeDeviceSubject.next(
36 id === 'default' && localTrack.mediaStreamTrack.label.startsWith('Default') ? id : actualId,
37 );
38 } else if (room) {
39 const browser = getBrowser();
40 if (kind === 'audiooutput' && (browser?.name === 'Safari' || browser?.os === 'iOS')) {
41 log.warn(`Switching audio output device is not supported on Safari and iOS.`);
42 return;
43 }
44 log.debug(`Switching active device of kind "${kind}" with id ${id}.`);
45 await room.switchActiveDevice(kind, id, options.exact);
46 const actualDeviceId: string | undefined = room.getActiveDevice(kind) ?? id;
47 if (actualDeviceId !== id && id !== 'default') {
48 log.info(
49 `We tried to select the device with id (${id}), but the browser decided to select the device with id (${actualDeviceId}) instead.`,
50 );
51 }
52 let targetTrack: LocalTrack | undefined = undefined;
53 if (kind === 'audioinput')
54 targetTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track;
55 else if (kind === 'videoinput') {
56 targetTrack = room.localParticipant.getTrackPublication(Track.Source.Camera)?.track;
57 }
58 const useDefault =
59 (id === 'default' && !targetTrack) ||
60 (id === 'default' && targetTrack?.mediaStreamTrack.label.startsWith('Default'));
61 activeDeviceSubject.next(useDefault ? id : actualDeviceId);
62 }
63 };
64 const className: string = prefixClass('media-device-select');
65 return {
66 className,
67 activeDeviceObservable,
68 setActiveMediaDevice,
69 };
70}
Added web/src/shared/livekit/core/components/mediaToggle.ts web/src/shared/livekit/core/components/mediaToggle.ts +145 −0 View web/src/shared/livekit/core/components/mediaToggle.ts
@@ -0,0 +1,145 @@
1import type {
2 AudioCaptureOptions,
3 LocalParticipant,
4 Room,
5 ScreenShareCaptureOptions,
6 TrackPublishOptions,
7 VideoCaptureOptions,
8} from 'livekit-client';
9import { Track } from 'livekit-client';
10import type { Observable } from 'rxjs';
11import { Subject, map, startWith } from 'rxjs';
12import { observeParticipantMedia } from '../observables/participant';
13import { prefixClass } from '../styles-interface';
14
15export type CaptureOptionsBySource<T extends ToggleSource> = T extends Track.Source.Camera
16 ? VideoCaptureOptions
17 : T extends Track.Source.Microphone
18 ? AudioCaptureOptions
19 : T extends Track.Source.ScreenShare
20 ? ScreenShareCaptureOptions
21 : never;
22
23export type MediaToggleType<T extends ToggleSource> = {
24 pendingObserver: Observable<boolean>;
25 toggle: (
26 forceState?: boolean,
27 captureOptions?: CaptureOptionsBySource<T>,
28 ) => Promise<boolean | undefined>;
29 className: string;
30 enabledObserver: Observable<boolean>;
31};
32
33export type ToggleSource = Exclude<
34 Track.Source,
35 Track.Source.ScreenShareAudio | Track.Source.Unknown
36>;
37
38export function setupMediaToggle<T extends ToggleSource>(
39 source: T,
40 room: Room,
41 options?: CaptureOptionsBySource<T>,
42 publishOptions?: TrackPublishOptions,
43 onError?: (error: Error) => void,
44): MediaToggleType<T> {
45 const { localParticipant } = room;
46
47 const getSourceEnabled = (source: ToggleSource, localParticipant: LocalParticipant) => {
48 let isEnabled = false;
49 switch (source) {
50 case Track.Source.Camera:
51 isEnabled = localParticipant.isCameraEnabled;
52 break;
53 case Track.Source.Microphone:
54 isEnabled = localParticipant.isMicrophoneEnabled;
55 break;
56 case Track.Source.ScreenShare:
57 isEnabled = localParticipant.isScreenShareEnabled;
58 break;
59 default:
60 break;
61 }
62 return isEnabled;
63 };
64
65 const enabledObserver = observeParticipantMedia(localParticipant).pipe(
66 map((media) => {
67 return getSourceEnabled(source, media.participant as LocalParticipant);
68 }),
69 startWith(getSourceEnabled(source, localParticipant)),
70 );
71
72 const pendingSubject = new Subject<boolean>();
73 const toggle = async (forceState?: boolean, captureOptions?: CaptureOptionsBySource<T>) => {
74 try {
75 captureOptions ??= options;
76 // trigger observable update
77 pendingSubject.next(true);
78 switch (source) {
79 case Track.Source.Camera:
80 await localParticipant.setCameraEnabled(
81 forceState ?? !localParticipant.isCameraEnabled,
82 captureOptions as VideoCaptureOptions,
83 publishOptions,
84 );
85 return localParticipant.isCameraEnabled;
86 case Track.Source.Microphone:
87 await localParticipant.setMicrophoneEnabled(
88 forceState ?? !localParticipant.isMicrophoneEnabled,
89 captureOptions as AudioCaptureOptions,
90 publishOptions,
91 );
92 return localParticipant.isMicrophoneEnabled;
93 case Track.Source.ScreenShare:
94 await localParticipant.setScreenShareEnabled(
95 forceState ?? !localParticipant.isScreenShareEnabled,
96 captureOptions as ScreenShareCaptureOptions,
97 publishOptions,
98 );
99 return localParticipant.isScreenShareEnabled;
100 default:
101 throw new TypeError('Tried to toggle unsupported source');
102 }
103 } catch (e) {
104 if (onError && e instanceof Error) {
105 onError?.(e);
106 return undefined;
107 } else {
108 throw e;
109 }
110 } finally {
111 pendingSubject.next(false);
112 // trigger observable update
113 }
114 };
115
116 const className: string = prefixClass('button');
117 return {
118 className,
119 toggle,
120 enabledObserver,
121 pendingObserver: pendingSubject.asObservable(),
122 };
123}
124
125export function setupManualToggle() {
126 let state = false;
127
128 const enabledSubject = new Subject<boolean>();
129
130 const pendingSubject = new Subject<boolean>();
131
132 const toggle = async (forceState?: boolean) => {
133 pendingSubject.next(true);
134 state = forceState ?? !state;
135 enabledSubject.next(state);
136 pendingSubject.next(false);
137 };
138 const className: string = prefixClass('button');
139 return {
140 className,
141 toggle,
142 enabledObserver: enabledSubject.asObservable(),
143 pendingObserver: pendingSubject.asObservable(),
144 };
145}
Added web/src/shared/livekit/core/components/mediaTrack.ts web/src/shared/livekit/core/components/mediaTrack.ts +42 −0 View web/src/shared/livekit/core/components/mediaTrack.ts
@@ -0,0 +1,42 @@
1import { Track } from 'livekit-client';
2import { map, startWith } from 'rxjs';
3import { observeParticipantMedia } from '../observables/participant';
4import { prefixClass } from '../styles-interface';
5import { isTrackReference } from '../track-reference/track-reference.types';
6import type { TrackIdentifier } from '../types';
7
8export function setupMediaTrack(trackIdentifier: TrackIdentifier) {
9 const initialPub = getTrackByIdentifier(trackIdentifier);
10 const trackObserver = observeParticipantMedia(trackIdentifier.participant).pipe(
11 map(() => {
12 return getTrackByIdentifier(trackIdentifier);
13 }),
14 startWith(initialPub),
15 );
16 const className: string = prefixClass(
17 trackIdentifier.source === Track.Source.Camera ||
18 trackIdentifier.source === Track.Source.ScreenShare
19 ? 'participant-media-video'
20 : 'participant-media-audio',
21 );
22 return { className, trackObserver };
23}
24
25export function getTrackByIdentifier(options: TrackIdentifier) {
26 if (isTrackReference(options)) {
27 return options.publication;
28 } else {
29 const { source, name, participant } = options;
30 if (source && name) {
31 return participant
32 .getTrackPublications()
33 .find((pub) => pub.source === source && pub.trackName === name);
34 } else if (name) {
35 return participant.getTrackPublicationByName(name);
36 } else if (source) {
37 return participant.getTrackPublication(source);
38 } else {
39 throw new Error('At least one of source and name needs to be defined');
40 }
41 }
42}
Added web/src/shared/livekit/core/components/participantName.ts web/src/shared/livekit/core/components/participantName.ts +7 −0 View web/src/shared/livekit/core/components/participantName.ts
@@ -0,0 +1,7 @@
1import type { Participant } from 'livekit-client';
2import { participantInfoObserver } from '../observables/participant';
3
4export function setupParticipantName(participant: Participant) {
5 const infoObserver = participantInfoObserver(participant);
6 return { className: 'lk-participant-name', infoObserver };
7}
Added web/src/shared/livekit/core/components/participantTile.ts web/src/shared/livekit/core/components/participantTile.ts +8 −0 View web/src/shared/livekit/core/components/participantTile.ts
@@ -0,0 +1,8 @@
1import { prefixClass } from '../styles-interface';
2
3export function setupParticipantTile() {
4 const className: string = prefixClass('participant-tile');
5 return {
6 className,
7 };
8}
Added web/src/shared/livekit/core/components/room.ts web/src/shared/livekit/core/components/room.ts +4 −0 View web/src/shared/livekit/core/components/room.ts
@@ -0,0 +1,4 @@
1export function setupLiveKitRoom() {
2 const className = 'lk-room-container';
3 return { className };
4}
Added web/src/shared/livekit/core/components/startAudio.ts web/src/shared/livekit/core/components/startAudio.ts +13 −0 View web/src/shared/livekit/core/components/startAudio.ts
@@ -0,0 +1,13 @@
1import type { Room } from 'livekit-client';
2import { log } from '../logger';
3import { roomAudioPlaybackAllowedObservable } from '../observables/room';
4import { prefixClass } from '../styles-interface';
5
6export function setupStartAudio() {
7 const handleStartAudioPlayback = async (room: Room) => {
8 log.info('Start Audio for room: ', room);
9 await room.startAudio();
10 };
11 const className: string = prefixClass('start-audio-button');
12 return { className, roomAudioPlaybackAllowedObservable, handleStartAudioPlayback };
13}
Added web/src/shared/livekit/core/components/startVideo.ts web/src/shared/livekit/core/components/startVideo.ts +13 −0 View web/src/shared/livekit/core/components/startVideo.ts
@@ -0,0 +1,13 @@
1import type { Room } from 'livekit-client';
2import { log } from '../logger';
3import { roomVideoPlaybackAllowedObservable } from '../observables/room';
4import { prefixClass } from '../styles-interface';
5
6export function setupStartVideo() {
7 const handleStartVideoPlayback = async (room: Room) => {
8 log.info('Start Video for room: ', room);
9 await room.startVideo();
10 };
11 const className: string = prefixClass('start-audio-button');
12 return { className, roomVideoPlaybackAllowedObservable, handleStartVideoPlayback };
13}
Added web/src/shared/livekit/core/components/textStream.ts web/src/shared/livekit/core/components/textStream.ts +131 −0 View web/src/shared/livekit/core/components/textStream.ts
@@ -0,0 +1,131 @@
1import { RoomEvent, type Room, type TextStreamInfo } from 'livekit-client';
2import { from, scan, Subject, type Observable } from 'rxjs';
3import { share, tap } from 'rxjs/operators';
4import { ParticipantAgentAttributes } from '../helper';
5
6export interface TextStreamData {
7 text: string;
8 participantInfo: { identity: string }; // Replace with the correct type from livekit-client
9 streamInfo: TextStreamInfo;
10}
11
12// Singleton getters for lazy initialization
13let observableCacheInstance: Map<string, Observable<TextStreamData[]>> | null = null;
14let roomInstanceMapInstance: WeakMap<Room, string> | null = null;
15let nextRoomId = 0;
16
17// Get or create the observable cache
18function getObservableCache(): Map<string, Observable<TextStreamData[]>> {
19 if (!observableCacheInstance) {
20 observableCacheInstance = new Map<string, Observable<TextStreamData[]>>();
21 }
22 return observableCacheInstance;
23}
24
25// Get or create the room instance map
26function getRoomInstanceMap(): WeakMap<Room, string> {
27 if (!roomInstanceMapInstance) {
28 roomInstanceMapInstance = new WeakMap<Room, string>();
29 }
30 return roomInstanceMapInstance;
31}
32
33// Helper to generate cache key
34function getCacheKey(room: Room, topic: string): string {
35 const instanceMap = getRoomInstanceMap();
36
37 // Get or create a unique ID for this room instance
38 let roomId = instanceMap.get(room);
39 if (!roomId) {
40 roomId = `room_${nextRoomId++}`;
41 instanceMap.set(room, roomId);
42 }
43
44 return `${roomId}:${topic}`;
45}
46
47export function setupTextStream(room: Room, topic: string): Observable<TextStreamData[]> {
48 const cacheKey = getCacheKey(room, topic);
49 const observableCache = getObservableCache();
50
51 // Check if we already have an observable for this room and topic
52 const existingObservable = observableCache.get(cacheKey);
53 if (existingObservable) {
54 return existingObservable;
55 }
56
57 const textStreamsSubject = new Subject<TextStreamData[]>();
58 let textStreams: TextStreamData[] = [];
59
60 const segmentAttribute = ParticipantAgentAttributes.TranscriptionSegmentId;
61
62 // Create shared observable and store in cache
63 const sharedObservable = textStreamsSubject.pipe(
64 tap({
65 subscribe: () => {
66 room.registerTextStreamHandler(topic, async (reader, participantInfo) => {
67 // Create an observable from the reader
68 const streamObservable = from(reader).pipe(
69 scan((acc: string, chunk: string) => {
70 return acc + chunk;
71 }, ''),
72 );
73
74 const isTranscription = !!reader.info.attributes?.[segmentAttribute];
75
76 // Subscribe to the stream and update our array when new chunks arrive
77 streamObservable.subscribe((accumulatedText) => {
78 // Find and update the stream in our array
79 const index = textStreams.findIndex(
80 (stream) =>
81 stream.streamInfo.id === reader.info.id ||
82 (isTranscription &&
83 stream.streamInfo.attributes?.[segmentAttribute] ===
84 reader.info.attributes?.[segmentAttribute]),
85 );
86 if (index !== -1) {
87 textStreams[index] = {
88 ...textStreams[index],
89 text: accumulatedText,
90 // Carry the latest streamInfo forward. Transcription updates for a
91 // segment arrive as separate streams sharing the same lk.segment_id;
92 // keeping the original streamInfo would freeze attributes that change
93 // over the segment's lifetime — notably lk.transcription_final flipping
94 // "false" -> "true" on the final user STT result.
95 streamInfo: reader.info,
96 };
97
98 // Emit the updated array
99 textStreamsSubject.next([...textStreams]);
100 } else {
101 // Handle case where stream ID wasn't found (new stream)
102 textStreams.push({
103 text: accumulatedText,
104 participantInfo,
105 streamInfo: reader.info,
106 });
107
108 // Emit the updated array with the new stream
109 textStreamsSubject.next([...textStreams]);
110 }
111 });
112 });
113 },
114 finalize: () => {
115 room.unregisterTextStreamHandler(topic);
116 },
117 }),
118 share(),
119 );
120
121 observableCache.set(cacheKey, sharedObservable);
122
123 // Add cleanup when room is disconnected
124 room.on(RoomEvent.Disconnected, () => {
125 getObservableCache().delete(cacheKey);
126 textStreams = [];
127 textStreamsSubject.next([]);
128 });
129
130 return sharedObservable;
131}
Added web/src/shared/livekit/core/components/trackMutedIndicator.ts web/src/shared/livekit/core/components/trackMutedIndicator.ts +25 −0 View web/src/shared/livekit/core/components/trackMutedIndicator.ts
@@ -0,0 +1,25 @@
1// @ts-ignore some module resolutions (other than 'node') choke on this
2import type { Styles } from '@livekit/components-styles/dist/types_unprefixed/index.scss';
3import { Track } from 'livekit-client';
4import { mutedObserver } from '../observables/participant';
5import { prefixClass } from '../styles-interface';
6import type { TrackReferenceOrPlaceholder } from '../track-reference';
7
8export function setupTrackMutedIndicator(trackRef: TrackReferenceOrPlaceholder) {
9 let classForSource: keyof Styles = 'track-muted-indicator-camera';
10 switch (trackRef.source) {
11 case Track.Source.Camera:
12 classForSource = 'track-muted-indicator-camera';
13 break;
14 case Track.Source.Microphone:
15 classForSource = 'track-muted-indicator-microphone';
16 break;
17
18 default:
19 break;
20 }
21 const className: string = prefixClass(classForSource);
22 const mediaMutedObserver = mutedObserver(trackRef);
23
24 return { className, mediaMutedObserver };
25}
Added web/src/shared/livekit/core/helper/array-helper.ts web/src/shared/livekit/core/helper/array-helper.ts +19 −0 View web/src/shared/livekit/core/helper/array-helper.ts
@@ -0,0 +1,19 @@
1export function chunk<T>(input: Array<T>, size: number) {
2 return input.reduce(
3 (arr, item, idx) => {
4 return idx % size === 0
5 ? [...arr, [item]]
6 : [...arr.slice(0, -1), [...arr.slice(-1)[0], item]];
7 },
8 [] as Array<Array<T>>,
9 );
10}
11
12export function zip<T, U>(a1: Array<T>, a2: Array<U>) {
13 const resultLength = Math.max(a1.length, a2.length);
14 return new Array(resultLength).fill([]).map((_val, idx) => [a1[idx], a2[idx]]);
15}
16
17export function differenceBy<T>(a1: Array<T>, a2: Array<T>, by: (arg: T) => string) {
18 return a1.filter((c) => !a2.map((v) => by(v)).includes(by(c)));
19}
Added web/src/shared/livekit/core/helper/detectMobileBrowser.ts web/src/shared/livekit/core/helper/detectMobileBrowser.ts +19 −0 View web/src/shared/livekit/core/helper/detectMobileBrowser.ts
@@ -0,0 +1,19 @@
1/**
2 * @internal
3 */
4export function isWeb(): boolean {
5 return typeof document !== 'undefined';
6}
7
8/**
9 * Mobile browser detection based on `navigator.userAgent` string.
10 * Defaults to returning `false` if not in a browser.
11 *
12 * @remarks
13 * This should only be used if feature detection or other methods do not work!
14 *
15 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
16 */
17export function isMobileBrowser(): boolean {
18 return isWeb() ? /Mobi/i.test(window.navigator.userAgent) : false;
19}
Added web/src/shared/livekit/core/helper/emailRegex.ts web/src/shared/livekit/core/helper/emailRegex.ts +8 −0 View web/src/shared/livekit/core/helper/emailRegex.ts
@@ -0,0 +1,8 @@
1// source code adapted from https://github.com/sindresorhus/email-regex due to ESM import incompatibilities when trying to serve a CJS version of components
2
3const regex = '[^\\.\\s@:](?:[^\\s@:]*[^\\s@:\\.])?@[^\\.\\s@]+(?:\\.[^\\.\\s@]+)*';
4
5function createEmailRegExp({ exact }: { exact?: boolean } = {}) {
6 return exact ? new RegExp(`^${regex}$`) : new RegExp(regex, 'g');
7}
8export { createEmailRegExp };
Added web/src/shared/livekit/core/helper/eventGroups.ts web/src/shared/livekit/core/helper/eventGroups.ts +67 −0 View web/src/shared/livekit/core/helper/eventGroups.ts
@@ -0,0 +1,67 @@
1import { ParticipantEvent, RoomEvent } from 'livekit-client';
2
3export const allRemoteParticipantRoomEvents = [
4 RoomEvent.ConnectionStateChanged,
5 RoomEvent.RoomMetadataChanged,
6
7 RoomEvent.ActiveSpeakersChanged,
8 RoomEvent.ConnectionQualityChanged,
9
10 RoomEvent.ParticipantConnected,
11 RoomEvent.ParticipantDisconnected,
12 RoomEvent.ParticipantPermissionsChanged,
13 RoomEvent.ParticipantMetadataChanged,
14 RoomEvent.ParticipantNameChanged,
15 RoomEvent.ParticipantAttributesChanged,
16
17 RoomEvent.TrackMuted,
18 RoomEvent.TrackUnmuted,
19 RoomEvent.TrackPublished,
20 RoomEvent.TrackUnpublished,
21 RoomEvent.TrackStreamStateChanged,
22 RoomEvent.TrackSubscriptionFailed,
23 RoomEvent.TrackSubscriptionPermissionChanged,
24 RoomEvent.TrackSubscriptionStatusChanged,
25];
26
27export const allParticipantRoomEvents = [
28 ...allRemoteParticipantRoomEvents,
29 RoomEvent.LocalTrackPublished,
30 RoomEvent.LocalTrackUnpublished,
31];
32
33export const participantTrackEvents = [
34 ParticipantEvent.TrackPublished,
35 ParticipantEvent.TrackUnpublished,
36 ParticipantEvent.TrackMuted,
37 ParticipantEvent.TrackUnmuted,
38 ParticipantEvent.TrackStreamStateChanged,
39 ParticipantEvent.TrackSubscribed,
40 ParticipantEvent.TrackUnsubscribed,
41 ParticipantEvent.TrackSubscriptionPermissionChanged,
42 ParticipantEvent.TrackSubscriptionFailed,
43 ParticipantEvent.LocalTrackPublished,
44 ParticipantEvent.LocalTrackUnpublished,
45];
46
47export const allRemoteParticipantEvents = [
48 ParticipantEvent.ConnectionQualityChanged,
49 ParticipantEvent.IsSpeakingChanged,
50 ParticipantEvent.ParticipantMetadataChanged,
51 ParticipantEvent.ParticipantPermissionsChanged,
52
53 ParticipantEvent.TrackMuted,
54 ParticipantEvent.TrackUnmuted,
55 ParticipantEvent.TrackPublished,
56 ParticipantEvent.TrackUnpublished,
57 ParticipantEvent.TrackStreamStateChanged,
58 ParticipantEvent.TrackSubscriptionFailed,
59 ParticipantEvent.TrackSubscriptionPermissionChanged,
60 ParticipantEvent.TrackSubscriptionStatusChanged,
61];
62
63export const allParticipantEvents = [
64 ...allRemoteParticipantEvents,
65 ParticipantEvent.LocalTrackPublished,
66 ParticipantEvent.LocalTrackUnpublished,
67];
Added web/src/shared/livekit/core/helper/featureDetection.ts web/src/shared/livekit/core/helper/featureDetection.ts +10 −0 View web/src/shared/livekit/core/helper/featureDetection.ts
@@ -0,0 +1,10 @@
1/**
2 * Returns `true` if the browser supports screen sharing.
3 */
4export function supportsScreenSharing(): boolean {
5 return (
6 typeof navigator !== 'undefined' &&
7 navigator.mediaDevices &&
8 !!navigator.mediaDevices.getDisplayMedia
9 );
10}
Added web/src/shared/livekit/core/helper/floating-menu.ts web/src/shared/livekit/core/helper/floating-menu.ts +22 −0 View web/src/shared/livekit/core/helper/floating-menu.ts
@@ -0,0 +1,22 @@
1import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
2
3export function computeMenuPosition(
4 button: HTMLElement,
5 menu: HTMLElement,
6 onUpdate?: (x: number, y: number) => void,
7): () => void {
8 const cleanup = autoUpdate(button, menu, async () => {
9 const { x, y } = await computePosition(button, menu, {
10 placement: 'top',
11 middleware: [offset(6), flip(), shift({ padding: 5 })],
12 });
13
14 onUpdate?.(x, y);
15 });
16 return cleanup;
17}
18
19export function wasClickOutside(insideElement: HTMLElement, event: MouseEvent): boolean {
20 const isOutside = !insideElement.contains(event.target as Node);
21 return isOutside;
22}
Added web/src/shared/livekit/core/helper/future.ts web/src/shared/livekit/core/helper/future.ts +32 −0 View web/src/shared/livekit/core/helper/future.ts
@@ -0,0 +1,32 @@
1export class Future<T, E extends Error> {
2 promise: Promise<T>;
3
4 resolve?: (arg: T) => void;
5
6 reject?: (e: E) => void;
7
8 onFinally?: () => void;
9
10 get isResolved(): boolean {
11 return this._isResolved;
12 }
13
14 private _isResolved: boolean = false;
15
16 constructor(
17 futureBase?: (resolve: (arg: T) => void, reject: (e: E) => void) => void,
18 onFinally?: () => void,
19 ) {
20 this.onFinally = onFinally;
21 this.promise = new Promise<T>(async (resolve, reject) => {
22 this.resolve = resolve;
23 this.reject = reject;
24 if (futureBase) {
25 await futureBase(resolve, reject);
26 }
27 }).finally(() => {
28 this._isResolved = true;
29 this.onFinally?.();
30 });
31 }
32}
Added web/src/shared/livekit/core/helper/grid-layouts.ts web/src/shared/livekit/core/helper/grid-layouts.ts +170 −0 View web/src/shared/livekit/core/helper/grid-layouts.ts
@@ -0,0 +1,170 @@
1import { log } from '../logger';
2
3/**
4 * @public
5 */
6export type GridLayoutDefinition = {
7 /** Column count of the grid layout. */
8 columns: number;
9 /** Row count of the grid layout. */
10 rows: number;
11 // # Constraints that have to be meet to use this layout.
12 /**
13 * Minimum grid container width required to use this layout.
14 * @remarks
15 * If this constraint is not met, we try to select a layout with fewer tiles
16 * (`tiles=columns*rows`) that is within the constraint.
17 */
18 minWidth?: number;
19 /**
20 * Minimum grid container height required to use this layout.
21 * @remarks
22 * If this constraint is not met, we try to select a layout with fewer tiles
23 * (`tiles=columns*rows`) that is within the constraint.
24 */
25 minHeight?: number;
26 /**
27 * For which orientation the layout definition should be applied.
28 * Will be used for both landscape and portrait if no value is specified.
29 */
30 orientation?: 'landscape' | 'portrait';
31};
32
33export type GridLayoutInfo = {
34 /** Layout name (convention `<column_count>x<row_count>`). */
35 name: string;
36 /** Column count of the layout. */
37 columns: number;
38 /** Row count of the layout. */
39 rows: number;
40 // # Constraints that have to be meet to use this layout.
41 // ## 1. Participant range:
42 /** Maximum tiles that fit into this layout. */
43 maxTiles: number;
44 // ## 2. Screen size limits:
45 /** Minimum width required to use this layout. */
46 minWidth: number;
47 /** Minimum height required to use this layout. */
48 minHeight: number;
49 orientation?: 'landscape' | 'portrait';
50};
51
52export const GRID_LAYOUTS: GridLayoutDefinition[] = [
53 {
54 columns: 1,
55 rows: 1,
56 },
57 {
58 columns: 1,
59 rows: 2,
60 orientation: 'portrait',
61 },
62 {
63 columns: 2,
64 rows: 1,
65 orientation: 'landscape',
66 },
67 {
68 columns: 2,
69 rows: 2,
70 minWidth: 560,
71 },
72 {
73 columns: 3,
74 rows: 3,
75 minWidth: 700,
76 },
77 {
78 columns: 4,
79 rows: 4,
80 minWidth: 960,
81 },
82 {
83 columns: 5,
84 rows: 5,
85 minWidth: 1100,
86 },
87] as const;
88
89export function selectGridLayout(
90 layoutDefinitions: GridLayoutDefinition[],
91 participantCount: number,
92 width: number,
93 height: number,
94): GridLayoutInfo {
95 if (layoutDefinitions.length < 1) {
96 throw new Error('At least one grid layout definition must be provided.');
97 }
98 const layouts = expandAndSortLayoutDefinitions(layoutDefinitions);
99 if (width <= 0 || height <= 0) {
100 return layouts[0];
101 }
102 // Find the best layout to fit all participants.
103 let currentLayoutIndex = 0;
104 const containerOrientation = width / height > 1 ? 'landscape' : 'portrait';
105 let layout = layouts.find((layout_, index, allLayouts) => {
106 currentLayoutIndex = index;
107 const isBiggerLayoutAvailable =
108 allLayouts.findIndex((l, i) => {
109 const fitsOrientation = !l.orientation || l.orientation === containerOrientation;
110 const layoutIsBiggerThanCurrent = i > index;
111 const layoutFitsSameAmountOfParticipants = l.maxTiles === layout_.maxTiles;
112 return layoutIsBiggerThanCurrent && layoutFitsSameAmountOfParticipants && fitsOrientation;
113 }) !== -1;
114 return layout_.maxTiles >= participantCount && !isBiggerLayoutAvailable;
115 });
116 if (layout === undefined) {
117 layout = layouts[layouts.length - 1];
118 if (layout) {
119 log.warn(
120 `No layout found for: participantCount: ${participantCount}, width/height: ${width}/${height} fallback to biggest available layout (${layout}).`,
121 );
122 } else {
123 throw new Error(`No layout or fallback layout found.`);
124 }
125 }
126
127 // Check if the layout fits into the screen constraints. If not, recursively check the next smaller layout.
128 if (width < layout.minWidth || height < layout.minHeight) {
129 // const currentLayoutIndex = layouts.indexOf(layout);
130 if (currentLayoutIndex > 0) {
131 const smallerLayout = layouts[currentLayoutIndex - 1];
132 layout = selectGridLayout(
133 layouts.slice(0, currentLayoutIndex),
134 smallerLayout.maxTiles,
135 width,
136 height,
137 );
138 }
139 }
140 return layout;
141}
142
143/**
144 * @internal
145 */
146export function expandAndSortLayoutDefinitions(layouts: GridLayoutDefinition[]): GridLayoutInfo[] {
147 return [...layouts]
148 .map((layout) => {
149 return {
150 name: `${layout.columns}x${layout.rows}`,
151 columns: layout.columns,
152 rows: layout.rows,
153 maxTiles: layout.columns * layout.rows,
154 minWidth: layout.minWidth ?? 0,
155 minHeight: layout.minHeight ?? 0,
156 orientation: layout.orientation,
157 } satisfies GridLayoutInfo;
158 })
159 .sort((a, b) => {
160 if (a.maxTiles !== b.maxTiles) {
161 return a.maxTiles - b.maxTiles;
162 } else if (a.minWidth !== 0 || b.minWidth !== 0) {
163 return a.minWidth - b.minWidth;
164 } else if (a.minHeight !== 0 || b.minHeight !== 0) {
165 return a.minHeight - b.minHeight;
166 } else {
167 return 0;
168 }
169 });
170}
Added web/src/shared/livekit/core/helper/index.ts web/src/shared/livekit/core/helper/index.ts +16 −0 View web/src/shared/livekit/core/helper/index.ts
@@ -0,0 +1,16 @@
1export * from './detectMobileBrowser';
2export * from './url-regex';
3export * from './emailRegex';
4export * from './floating-menu';
5export * from './tokenizer';
6export * from './eventGroups';
7export {
8 selectGridLayout,
9 GRID_LAYOUTS,
10 type GridLayoutDefinition,
11 type GridLayoutInfo,
12} from './grid-layouts';
13export { setDifference } from './set-helper';
14export { supportsScreenSharing } from './featureDetection';
15export * from './transcriptions';
16export * from './participant-attributes';
Added web/src/shared/livekit/core/helper/participant-attributes.ts web/src/shared/livekit/core/helper/participant-attributes.ts +11 −0 View web/src/shared/livekit/core/helper/participant-attributes.ts
@@ -0,0 +1,11 @@
1/** An enum of first party livekit attributes generated by the serverside agents sdk */
2export const ParticipantAgentAttributes = {
3 AgentState: 'lk.agent.state',
4 PublishOnBehalf: 'lk.publish_on_behalf',
5
6 TranscriptionFinal: 'lk.transcription_final',
7 TranscriptionSegmentId: 'lk.segment_id',
8 TranscribedTrackId: 'lk.transcribed_track_id',
9
10 Expression: 'lk.expression',} as const;
11export type ParticipantAgentAttributes = (typeof ParticipantAgentAttributes)[keyof typeof ParticipantAgentAttributes];
Added web/src/shared/livekit/core/helper/set-helper.ts web/src/shared/livekit/core/helper/set-helper.ts +7 −0 View web/src/shared/livekit/core/helper/set-helper.ts
@@ -0,0 +1,7 @@
1export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
2 const _difference = new Set(setA);
3 for (const elem of setB) {
4 _difference.delete(elem);
5 }
6 return _difference;
7}
Added web/src/shared/livekit/core/helper/tokenizer.ts web/src/shared/livekit/core/helper/tokenizer.ts +43 −0 View web/src/shared/livekit/core/helper/tokenizer.ts
@@ -0,0 +1,43 @@
1import { createEmailRegExp } from './emailRegex';
2import { createUrlRegExp } from './url-regex';
3
4export type TokenizeGrammar = { [type: string]: RegExp };
5
6export const createDefaultGrammar = () => {
7 return {
8 email: createEmailRegExp(),
9 url: createUrlRegExp({}),
10 } satisfies TokenizeGrammar;
11};
12
13export function tokenize<T extends TokenizeGrammar>(input: string, grammar: T) {
14 const matches = Object.entries(grammar)
15 .map(([type, rx], weight) =>
16 Array.from(input.matchAll(rx)).map(({ index, 0: content }) => ({
17 type: type as keyof T,
18 weight,
19 content,
20 index: index ?? 0,
21 })),
22 )
23 .flat()
24 .sort((a, b) => {
25 const d = a.index - b.index;
26 return d !== 0 ? d : a.weight - b.weight;
27 })
28 .filter(({ index }, i, arr) => {
29 if (i === 0) return true;
30 const prev = arr[i - 1];
31 return prev.index + prev.content.length <= index;
32 });
33
34 const tokens = [];
35 let pos = 0;
36 for (const { type, content, index } of matches) {
37 if (index > pos) tokens.push(input.substring(pos, index));
38 tokens.push({ type, content });
39 pos = index + content.length;
40 }
41 if (input.length > pos) tokens.push(input.substring(pos));
42 return tokens;
43}
Added web/src/shared/livekit/core/helper/transcriptions.ts web/src/shared/livekit/core/helper/transcriptions.ts +75 −0 View web/src/shared/livekit/core/helper/transcriptions.ts
@@ -0,0 +1,75 @@
1import type { TranscriptionSegment } from 'livekit-client';
2
3export type ReceivedTranscriptionSegment = TranscriptionSegment & {
4 receivedAtMediaTimestamp: number;
5 receivedAt: number;
6};
7
8export function getActiveTranscriptionSegments(
9 segments: ReceivedTranscriptionSegment[],
10 syncTimes: { timestamp: number; rtpTimestamp?: number },
11 maxAge = 0,
12) {
13 return segments.filter((segment) => {
14 const hasTrackSync = !!syncTimes.rtpTimestamp;
15 const currentTrackTime = syncTimes.rtpTimestamp ?? performance.timeOrigin + performance.now();
16 // if a segment arrives late, consider startTime to be the media timestamp from when the segment was received client side
17 const displayStartTime = hasTrackSync
18 ? Math.max(segment.receivedAtMediaTimestamp, segment.startTime)
19 : segment.receivedAt;
20 // "active" duration is computed by the diff between start and end time, so we don't rely on displayStartTime to always be the same as the segment's startTime
21 const segmentDuration = maxAge + segment.endTime - segment.startTime;
22 return (
23 currentTrackTime >= displayStartTime && currentTrackTime <= displayStartTime + segmentDuration
24 );
25 });
26}
27
28export function addMediaTimestampToTranscription(
29 segment: TranscriptionSegment,
30 timestamps: { timestamp: number; rtpTimestamp?: number },
31): ReceivedTranscriptionSegment {
32 return {
33 ...segment,
34 receivedAtMediaTimestamp: timestamps.rtpTimestamp ?? 0,
35 receivedAt: timestamps.timestamp,
36 };
37}
38
39/**
40 * @returns An array of unique (by id) `TranscriptionSegment`s. Latest wins. If the resulting array would be longer than `windowSize`, the array will be reduced to `windowSize` length
41 */
42export function dedupeSegments<T extends TranscriptionSegment>(
43 prevSegments: T[],
44 newSegments: T[],
45 windowSize: number,
46) {
47 return [...prevSegments, ...newSegments]
48 .reduceRight((acc, segment) => {
49 if (!acc.find((val) => val.id === segment.id)) {
50 acc.unshift(segment);
51 }
52 return acc;
53 }, [] as Array<T>)
54 .slice(0 - windowSize);
55}
56
57export function didActiveSegmentsChange<T extends TranscriptionSegment>(
58 prevActive: T[],
59 newActive: T[],
60) {
61 if (newActive.length !== prevActive.length) {
62 return true;
63 }
64 return !newActive.every((newSegment) => {
65 return prevActive.find(
66 (prevSegment) =>
67 prevSegment.id === newSegment.id &&
68 prevSegment.text === newSegment.text &&
69 prevSegment.final === newSegment.final &&
70 prevSegment.language === newSegment.language &&
71 prevSegment.startTime === newSegment.startTime &&
72 prevSegment.endTime === newSegment.endTime,
73 );
74 });
75}
Added web/src/shared/livekit/core/helper/url-regex.ts web/src/shared/livekit/core/helper/url-regex.ts +50 −0 View web/src/shared/livekit/core/helper/url-regex.ts
@@ -0,0 +1,50 @@
1// The MIT License (MIT)
2
3// Copyright (c) Kevin Mårtensson <kevinmartensson@gmail.com> and Diego Perini
4
5// Permission is hereby granted, free of charge, to any person obtaining a copy
6// of this software and associated documentation files (the "Software"), to deal
7// in the Software without restriction, including without limitation the rights
8// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9// copies of the Software, and to permit persons to whom the Software is
10// furnished to do so, subject to the following conditions:
11
12// The above copyright notice and this permission notice shall be included in
13// all copies or substantial portions of the Software.
14
15// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21// THE SOFTWARE.
22
23interface RegExOptions {
24 /**
25 Only match an exact string. Useful with `RegExp#test` to check if a string is a URL.
26 @defaultValue false
27 */
28 readonly exact?: boolean;
29}
30
31export function createUrlRegExp(options: RegExOptions) {
32 options = {
33 ...options,
34 };
35
36 const protocol = `(?:(?:[a-z]+:)?//)?`;
37 const auth = '(?:\\S+(?::\\S*)?@)?';
38 const ip = new RegExp(
39 '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}',
40 'g',
41 ).source;
42 const host = '(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)';
43 const domain = '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*';
44 const tld = `(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))\\.?`;
45 const port = '(?::\\d{2,5})?';
46 const path = '(?:[/?#][^\\s"]*)?';
47 const regex = `(?:${protocol}|www\\.)${auth}(?:localhost|${ip}|${host}${domain}${tld})${port}${path}`;
48
49 return options.exact ? new RegExp(`(?:^${regex}$)`, 'i') : new RegExp(regex, 'ig');
50}
Added web/src/shared/livekit/core/messages/index.ts web/src/shared/livekit/core/messages/index.ts +1 −0 View web/src/shared/livekit/core/messages/index.ts
@@ -0,0 +1 @@
1export * from './types';
Added web/src/shared/livekit/core/messages/types.ts web/src/shared/livekit/core/messages/types.ts +49 −0 View web/src/shared/livekit/core/messages/types.ts
@@ -0,0 +1,49 @@
1import type { Participant, ChatMessage } from 'livekit-client';
2
3/** @public */
4export type { ChatMessage };
5
6export type SentMessage = ChatMessage;
7
8type ReceivedMessageWithType<Type extends string, Metadata extends object = object> = {
9 id: string;
10 timestamp: number;
11
12 type: Type;
13
14 from?: Participant;
15 attributes?: Record<string, string>;
16} & Metadata;
17
18/** @private */
19type ReceivedChatMessageWithRequiredType = ReceivedMessageWithType<
20 'chatMessage',
21 ChatMessage & {
22 from?: Participant;
23 attributes?: Record<string, string>;
24 }
25>;
26
27/** @public */
28export type ReceivedChatMessage = Omit<ReceivedChatMessageWithRequiredType, 'type'> & {
29 type?: 'chatMessage';
30};
31
32export type ReceivedUserTranscriptionMessage = ReceivedMessageWithType<
33 'userTranscript',
34 {
35 message: string;
36 }
37>;
38
39export type ReceivedAgentTranscriptionMessage = ReceivedMessageWithType<
40 'agentTranscript',
41 {
42 message: string;
43 }
44>;
45
46/** @beta */
47export type ReceivedMessage =
48 ReceivedUserTranscriptionMessage | ReceivedAgentTranscriptionMessage | ReceivedChatMessage;
49// TODO: images? attachments? rpc?
Added web/src/shared/livekit/core/observables/dataChannel.ts web/src/shared/livekit/core/observables/dataChannel.ts +113 −0 View web/src/shared/livekit/core/observables/dataChannel.ts
@@ -0,0 +1,113 @@
1import {
2 type ChatMessage,
3 type DataPublishOptions,
4 type LocalParticipant,
5 type Participant,
6 type Room,
7 type SendTextOptions,
8} from 'livekit-client';
9import type { Subscriber } from 'rxjs';
10import { Observable, filter, map } from 'rxjs';
11import { createChatObserver, createDataObserver } from './room';
12import { type ReceivedChatMessage } from '../components/chat';
13
14export const DataTopic = {
15 CHAT: 'lk.chat',
16 TRANSCRIPTION: 'lk.transcription',
17} as const;
18
19/** @deprecated */
20export const LegacyDataTopic = {
21 CHAT: 'lk-chat-topic',
22} as const;
23
24/** Publish data from the LocalParticipant. */
25export async function sendMessage(
26 localParticipant: LocalParticipant,
27 payload: Uint8Array,
28 options: DataPublishOptions = {},
29) {
30 const { reliable, destinationIdentities, topic } = options;
31
32 // `publishData` requires the non-shared `Uint8Array<ArrayBuffer>` variant (livekit-client >=2.19),
33 // but this helper intentionally accepts any consumer-supplied `Uint8Array` and forwards it
34 // unchanged (as it did before that type tightening). Data-channel payloads are not shared-backed.
35 await localParticipant.publishData(payload as Uint8Array<ArrayBuffer>, {
36 destinationIdentities,
37 topic,
38 reliable,
39 });
40}
41
42export interface BaseDataMessage<T extends string | undefined> {
43 topic?: T;
44 payload: Uint8Array;
45}
46
47export interface ReceivedDataMessage<
48 T extends string | undefined = string,
49> extends BaseDataMessage<T> {
50 from?: Participant;
51}
52
53export function setupDataMessageHandler<T extends string>(
54 room: Room,
55 topic?: T | [T, ...T[]],
56 onMessage?: (msg: ReceivedDataMessage<T>) => void,
57) {
58 const topics = Array.isArray(topic) ? topic : [topic];
59 /** Setup a Observable that returns all data messages belonging to a topic. */
60 const messageObservable = createDataObserver(room).pipe(
61 filter(
62 ([, , , messageTopic]) =>
63 topic === undefined || (messageTopic !== undefined && topics.includes(messageTopic as T)),
64 ),
65 map(([payload, participant, , messageTopic]) => {
66 const msg = {
67 payload,
68 topic: messageTopic as T,
69 from: participant,
70 } satisfies ReceivedDataMessage<T>;
71 onMessage?.(msg);
72 return msg;
73 }),
74 );
75
76 let isSendingSubscriber: Subscriber<boolean>;
77 const isSendingObservable = new Observable<boolean>((subscriber) => {
78 isSendingSubscriber = subscriber;
79 });
80
81 const send = async (payload: Uint8Array, options: DataPublishOptions = {}) => {
82 isSendingSubscriber.next(true);
83 try {
84 await sendMessage(room.localParticipant, payload, { topic: topics[0], ...options });
85 } finally {
86 isSendingSubscriber.next(false);
87 }
88 };
89
90 return { messageObservable, isSendingObservable, send };
91}
92
93export function setupChatMessageHandler(room: Room) {
94 const chatObservable = createChatObserver(room);
95
96 const send = async (text: string, options: SendTextOptions): Promise<ReceivedChatMessage> => {
97 const msg = await room.localParticipant.sendChatMessage(text, options);
98 await room.localParticipant.sendText(text, options);
99 return {
100 ...msg,
101 type: 'chatMessage',
102 from: room.localParticipant,
103 attachedFiles: options.attachments,
104 };
105 };
106
107 const edit = async (text: string, originalMsg: ChatMessage) => {
108 const msg = await room.localParticipant.editChatMessage(text, originalMsg);
109 return msg;
110 };
111
112 return { chatObservable, send, edit };
113}
Added web/src/shared/livekit/core/observables/dom-event.ts web/src/shared/livekit/core/observables/dom-event.ts +21 −0 View web/src/shared/livekit/core/observables/dom-event.ts
@@ -0,0 +1,21 @@
1import type { Observable } from 'rxjs';
2import { concat, distinctUntilChanged, fromEvent, map, of, skipUntil, timeout } from 'rxjs';
3
4/**
5 * Returns true if the user is interacting with the HTML element,
6 * and returns false if there is no interaction for a specified period of time.
7 *
8 * @internal
9 */
10export function createInteractingObservable(htmlElement: HTMLElement | null, inactiveAfter = 1000) {
11 if (htmlElement === null) return of(false);
12 const move$ = fromEvent(htmlElement, 'mousemove', { passive: true }).pipe(map(() => true));
13 const moveAndStop$: Observable<boolean> = move$.pipe(
14 timeout({
15 each: inactiveAfter,
16 with: () => concat(of(false), moveAndStop$.pipe(skipUntil(move$))),
17 }),
18 distinctUntilChanged(),
19 );
20 return moveAndStop$;
21}
Added web/src/shared/livekit/core/observables/participant.ts web/src/shared/livekit/core/observables/participant.ts +321 −0 View web/src/shared/livekit/core/observables/participant.ts
@@ -0,0 +1,321 @@
1import type { ParticipantPermission } from '@livekit/protocol';
2import {
3 Participant,
4 RemoteParticipant,
5 Room,
6 TrackPublication,
7 ParticipantEvent,
8 RoomEvent,
9 Track,
10 type ParticipantEventCallbacks,
11} from 'livekit-client';
12import type { Subscriber } from 'rxjs';
13import { Observable, map, startWith, switchMap } from 'rxjs';
14import { getTrackByIdentifier } from '../components/mediaTrack';
15import { allParticipantEvents, allParticipantRoomEvents } from '../helper/eventGroups';
16import type { TrackReferenceOrPlaceholder } from '../track-reference';
17import type { ParticipantIdentifier, TrackIdentifier } from '../types';
18import { observeRoomEvents } from './room';
19
20export function observeParticipantEvents<T extends Participant>(
21 participant: T,
22 ...events: ParticipantEvent[]
23) {
24 const observable = new Observable<T>((subscribe) => {
25 const onParticipantUpdate = () => {
26 subscribe.next(participant);
27 };
28
29 events.forEach((evt) => {
30 participant.on(evt as keyof ParticipantEventCallbacks, onParticipantUpdate);
31 });
32
33 const unsubscribe = () => {
34 events.forEach((evt) => {
35 participant.off(evt as keyof ParticipantEventCallbacks, onParticipantUpdate);
36 });
37 };
38 return unsubscribe;
39 }).pipe(startWith(participant));
40
41 return observable;
42}
43
44export interface ParticipantMedia<T extends Participant = Participant> {
45 isCameraEnabled: boolean;
46 isMicrophoneEnabled: boolean;
47 isScreenShareEnabled: boolean;
48 microphoneTrack?: TrackPublication;
49 cameraTrack?: TrackPublication;
50 participant: T;
51}
52
53export function observeParticipantMedia<T extends Participant>(participant: T) {
54 const participantObserver = observeParticipantEvents(
55 participant,
56 ParticipantEvent.TrackMuted,
57 ParticipantEvent.TrackUnmuted,
58 ParticipantEvent.ParticipantPermissionsChanged,
59 // ParticipantEvent.IsSpeakingChanged,
60 ParticipantEvent.TrackPublished,
61 ParticipantEvent.TrackUnpublished,
62 ParticipantEvent.LocalTrackPublished,
63 ParticipantEvent.LocalTrackUnpublished,
64 ParticipantEvent.MediaDevicesError,
65 ParticipantEvent.TrackSubscriptionStatusChanged,
66 // ParticipantEvent.ConnectionQualityChanged,
67 ).pipe(
68 map((p) => {
69 const { isMicrophoneEnabled, isCameraEnabled, isScreenShareEnabled } = p;
70 const microphoneTrack = p.getTrackPublication(Track.Source.Microphone);
71 const cameraTrack = p.getTrackPublication(Track.Source.Camera);
72 const participantMedia: ParticipantMedia<T> = {
73 isCameraEnabled,
74 isMicrophoneEnabled,
75 isScreenShareEnabled,
76 cameraTrack,
77 microphoneTrack,
78 participant: p,
79 };
80 return participantMedia;
81 }),
82 );
83
84 return participantObserver;
85}
86
87export function createTrackObserver(participant: Participant, options: TrackIdentifier) {
88 return observeParticipantMedia(participant).pipe(
89 map(() => {
90 return { publication: getTrackByIdentifier(options) };
91 }),
92 );
93}
94
95export function participantInfoObserver(participant?: Participant) {
96 if (!participant) {
97 return undefined;
98 }
99 const observer = observeParticipantEvents(
100 participant,
101 ParticipantEvent.ParticipantMetadataChanged,
102 ParticipantEvent.ParticipantNameChanged,
103 ).pipe(
104 map(({ name, identity, metadata }) => {
105 return {
106 name,
107 identity,
108 metadata,
109 };
110 }),
111 startWith({
112 name: participant.name,
113 identity: participant.identity,
114 metadata: participant.metadata,
115 }),
116 );
117 return observer;
118}
119
120export function createConnectionQualityObserver(participant: Participant) {
121 const observer = participantEventSelector(
122 participant,
123 ParticipantEvent.ConnectionQualityChanged,
124 ).pipe(
125 map(([quality]) => quality),
126 startWith(participant.connectionQuality),
127 );
128 return observer;
129}
130
131export function participantEventSelector<T extends ParticipantEvent>(
132 participant: Participant,
133 event: T,
134) {
135 const observable = new Observable<
136 Parameters<ParticipantEventCallbacks[Extract<T, keyof ParticipantEventCallbacks>]>
137 >((subscribe) => {
138 const update = (
139 ...params: Parameters<ParticipantEventCallbacks[Extract<T, keyof ParticipantEventCallbacks>]>
140 ) => {
141 subscribe.next(params);
142 };
143 // @ts-expect-error not a perfect overlap between ParticipantEvent and keyof ParticipantEventCallbacks
144 participant.on(event, update);
145
146 const unsubscribe = () => {
147 // @ts-expect-error not a perfect overlap between ParticipantEvent and keyof ParticipantEventCallbacks
148 participant.off(event, update);
149 };
150 return unsubscribe;
151 });
152
153 return observable;
154}
155
156export function mutedObserver(trackRef: TrackReferenceOrPlaceholder) {
157 return observeParticipantEvents(
158 trackRef.participant,
159 ParticipantEvent.TrackMuted,
160 ParticipantEvent.TrackUnmuted,
161 ParticipantEvent.TrackSubscribed,
162 ParticipantEvent.TrackUnsubscribed,
163 ParticipantEvent.LocalTrackPublished,
164 ParticipantEvent.LocalTrackUnpublished,
165 ).pipe(
166 map((participant) => {
167 const pub = trackRef.publication ?? participant.getTrackPublication(trackRef.source);
168 return pub?.isMuted ?? true;
169 }),
170 startWith(
171 trackRef.publication?.isMuted ??
172 trackRef.participant.getTrackPublication(trackRef.source)?.isMuted ??
173 true,
174 ),
175 );
176}
177
178export function createIsSpeakingObserver(participant: Participant) {
179 return participantEventSelector(participant, ParticipantEvent.IsSpeakingChanged).pipe(
180 map(([isSpeaking]) => isSpeaking),
181 );
182}
183
184type ConnectedParticipantsObserverOptions = {
185 additionalRoomEvents?: RoomEvent[];
186};
187
188export function connectedParticipantsObserver(
189 room: Room,
190 options: ConnectedParticipantsObserverOptions = {},
191) {
192 let subscriber: Subscriber<RemoteParticipant[]> | undefined;
193
194 const observable = new Observable<RemoteParticipant[]>((sub) => {
195 subscriber = sub;
196 return () => listener.unsubscribe();
197 }).pipe(startWith(Array.from(room.remoteParticipants.values())));
198
199 const additionalRoomEvents = options.additionalRoomEvents ?? allParticipantRoomEvents;
200
201 const roomEvents = Array.from(
202 new Set([
203 RoomEvent.ParticipantConnected,
204 RoomEvent.ParticipantDisconnected,
205 RoomEvent.ConnectionStateChanged,
206 ...additionalRoomEvents,
207 ]),
208 );
209
210 const listener = observeRoomEvents(room, ...roomEvents).subscribe((r) =>
211 subscriber?.next(Array.from(r.remoteParticipants.values())),
212 );
213 if (room.remoteParticipants.size > 0) {
214 subscriber?.next(Array.from(room.remoteParticipants.values()));
215 }
216 return observable;
217}
218
219export type ConnectedParticipantObserverOptions = {
220 additionalEvents?: ParticipantEvent[];
221};
222
223export function connectedParticipantObserver(
224 room: Room,
225 identity: string,
226 options: ConnectedParticipantObserverOptions = {},
227) {
228 const additionalEvents = options.additionalEvents ?? allParticipantEvents;
229 const observable = observeRoomEvents(
230 room,
231 RoomEvent.ParticipantConnected,
232 RoomEvent.ParticipantDisconnected,
233 RoomEvent.ConnectionStateChanged,
234 ).pipe(
235 switchMap((r) => {
236 const participant = r.getParticipantByIdentity(identity) as RemoteParticipant | undefined;
237 if (participant) {
238 return observeParticipantEvents(participant, ...additionalEvents);
239 } else {
240 return new Observable<undefined>((subscribe) => subscribe.next(undefined));
241 }
242 }),
243 startWith(room.getParticipantByIdentity(identity) as RemoteParticipant | undefined),
244 );
245
246 return observable;
247}
248
249export function participantPermissionObserver(
250 participant: Participant,
251): Observable<ParticipantPermission | undefined> {
252 const observer = participantEventSelector(
253 participant,
254 ParticipantEvent.ParticipantPermissionsChanged,
255 ).pipe(
256 map(() => participant.permissions),
257 startWith(participant.permissions),
258 );
259 return observer;
260}
261
262export function participantByIdentifierObserver(
263 room: Room,
264 { kind, identity }: ParticipantIdentifier,
265 options: ConnectedParticipantObserverOptions = {},
266): Observable<RemoteParticipant | undefined> {
267 const additionalEvents = options.additionalEvents ?? allParticipantEvents;
268 const matchesIdentifier = (participant: RemoteParticipant) => {
269 let isMatch = true;
270 if (kind) {
271 isMatch = isMatch && participant.kind === kind;
272 }
273 if (identity) {
274 isMatch = isMatch && participant.identity === identity;
275 }
276 return isMatch;
277 };
278 const observable = observeRoomEvents(
279 room,
280 RoomEvent.ParticipantConnected,
281 RoomEvent.ParticipantDisconnected,
282 RoomEvent.ConnectionStateChanged,
283 ).pipe(
284 switchMap((r) => {
285 const participant = Array.from(r.remoteParticipants.values()).find((p) =>
286 matchesIdentifier(p),
287 );
288 if (participant) {
289 return observeParticipantEvents(participant, ...additionalEvents);
290 } else {
291 return new Observable<undefined>((subscribe) => subscribe.next(undefined));
292 }
293 }),
294 startWith(Array.from(room.remoteParticipants.values()).find((p) => matchesIdentifier(p))),
295 );
296
297 return observable;
298}
299
300export function participantAttributesObserver(participant: Participant): Observable<{
301 changed: Readonly<Record<string, string>>;
302 attributes: Readonly<Record<string, string>>;
303}>;
304export function participantAttributesObserver(participant: undefined): Observable<{
305 changed: undefined;
306 attributes: undefined;
307}>;
308export function participantAttributesObserver(participant: Participant | undefined) {
309 if (typeof participant === 'undefined') {
310 return new Observable<{ changed: undefined; attributes: undefined }>();
311 }
312 return participantEventSelector(participant, ParticipantEvent.AttributesChanged).pipe(
313 map(([changedAttributes]) => {
314 return {
315 changed: changedAttributes as Readonly<Record<string, string>>,
316 attributes: participant.attributes,
317 };
318 }),
319 startWith({ changed: participant.attributes, attributes: participant.attributes }),
320 );
321}
Added web/src/shared/livekit/core/observables/room.ts web/src/shared/livekit/core/observables/room.ts +291 −0 View web/src/shared/livekit/core/observables/room.ts
@@ -0,0 +1,291 @@
1import {
2 LocalParticipant,
3 Room,
4 RoomEvent,
5 Track,
6 type Participant,
7 type TrackPublication,
8 type RoomEventCallbacks,
9} from 'livekit-client';
10import type { Subscriber, Subscription } from 'rxjs';
11import { concat, filter, map, Observable, startWith } from 'rxjs';
12import { log } from '../logger';
13export function observeRoomEvents(room: Room, ...events: RoomEvent[]): Observable<Room> {
14 const observable = new Observable<Room>((subscribe) => {
15 const onRoomUpdate = () => {
16 subscribe.next(room);
17 };
18
19 events.forEach((evt) => {
20 room.on(evt, onRoomUpdate);
21 });
22
23 const unsubscribe = () => {
24 events.forEach((evt) => {
25 room.off(evt, onRoomUpdate);
26 });
27 };
28 return unsubscribe;
29 }).pipe(startWith(room));
30
31 return observable;
32}
33
34export function roomEventSelector<T extends RoomEvent>(room: Room, event: T) {
35 const observable = new Observable<Parameters<RoomEventCallbacks[T]>>((subscribe) => {
36 const update = (...params: Parameters<RoomEventCallbacks[T]>) => {
37 subscribe.next(params);
38 };
39 room.on(event as keyof RoomEventCallbacks, update);
40
41 const unsubscribe = () => {
42 room.off(event as keyof RoomEventCallbacks, update);
43 };
44 return unsubscribe;
45 });
46
47 return observable;
48}
49
50export function roomObserver(room: Room) {
51 const observable = observeRoomEvents(
52 room,
53 RoomEvent.ParticipantConnected,
54 RoomEvent.ParticipantDisconnected,
55 RoomEvent.ActiveSpeakersChanged,
56 RoomEvent.TrackSubscribed,
57 RoomEvent.TrackUnsubscribed,
58 RoomEvent.LocalTrackPublished,
59 RoomEvent.LocalTrackUnpublished,
60 RoomEvent.AudioPlaybackStatusChanged,
61 RoomEvent.ConnectionStateChanged,
62 );
63
64 return observable;
65}
66
67export function connectionStateObserver(room: Room) {
68 return roomEventSelector(room, RoomEvent.ConnectionStateChanged).pipe(
69 map(([connectionState]) => connectionState),
70 startWith(room.state),
71 );
72}
73export type ScreenShareTrackMap = Array<{
74 participant: Participant;
75 tracks: Array<TrackPublication>;
76}>;
77
78export function screenShareObserver(room: Room) {
79 let screenShareSubscriber: Subscriber<ScreenShareTrackMap>;
80 const observers: Array<Subscription> = [];
81
82 const observable = new Observable<ScreenShareTrackMap>((subscriber) => {
83 screenShareSubscriber = subscriber;
84 return () => {
85 observers.forEach((observer) => {
86 observer.unsubscribe();
87 });
88 };
89 });
90 const screenShareTracks: ScreenShareTrackMap = [];
91
92 const handleSub = (publication: TrackPublication, participant: Participant) => {
93 if (
94 publication.source !== Track.Source.ScreenShare &&
95 publication.source !== Track.Source.ScreenShareAudio
96 ) {
97 return;
98 }
99 let trackMap = screenShareTracks.find((tr) => tr.participant.identity === participant.identity);
100 const getScreenShareTracks = (participant: Participant) => {
101 return participant
102 .getTrackPublications()
103 .filter(
104 (track) =>
105 (track.source === Track.Source.ScreenShare ||
106 track.source === Track.Source.ScreenShareAudio) &&
107 track.track,
108 );
109 };
110 if (!trackMap) {
111 trackMap = {
112 participant,
113 tracks: getScreenShareTracks(participant),
114 };
115 } else {
116 const index = screenShareTracks.indexOf(trackMap);
117 screenShareTracks.splice(index, 1);
118 trackMap.tracks = getScreenShareTracks(participant);
119 }
120 if (trackMap.tracks.length > 0) {
121 screenShareTracks.push(trackMap);
122 }
123
124 screenShareSubscriber.next(screenShareTracks);
125 };
126 observers.push(
127 roomEventSelector(room, RoomEvent.TrackSubscribed).subscribe(([, ...args]) =>
128 handleSub(...args),
129 ),
130 );
131 observers.push(
132 roomEventSelector(room, RoomEvent.TrackUnsubscribed).subscribe(([, ...args]) =>
133 handleSub(...args),
134 ),
135 );
136 observers.push(
137 roomEventSelector(room, RoomEvent.LocalTrackPublished).subscribe((args) => handleSub(...args)),
138 );
139 observers.push(
140 roomEventSelector(room, RoomEvent.LocalTrackUnpublished).subscribe((args) => {
141 handleSub(...args);
142 }),
143 );
144 observers.push(
145 roomEventSelector(room, RoomEvent.TrackMuted).subscribe((args) => {
146 handleSub(...args);
147 }),
148 );
149 observers.push(
150 roomEventSelector(room, RoomEvent.TrackUnmuted).subscribe((args) => {
151 handleSub(...args);
152 }),
153 );
154 setTimeout(() => {
155 // TODO find way to avoid this timeout
156 for (const p of room.remoteParticipants.values()) {
157 p.getTrackPublications().forEach((track) => {
158 handleSub(track, p);
159 });
160 }
161 }, 1);
162
163 return observable;
164}
165
166export function roomInfoObserver(room: Room) {
167 const observer = observeRoomEvents(
168 room,
169 RoomEvent.RoomMetadataChanged,
170 RoomEvent.ConnectionStateChanged,
171 ).pipe(
172 map((r) => {
173 return { name: r.name, metadata: r.metadata };
174 }),
175 );
176 return observer;
177}
178
179export function activeSpeakerObserver(room: Room) {
180 return roomEventSelector(room, RoomEvent.ActiveSpeakersChanged).pipe(
181 map(([speakers]) => speakers),
182 );
183}
184
185export function createMediaDeviceObserver(
186 kind?: MediaDeviceKind,
187 onError?: (e: Error) => void,
188 requestPermissions = true,
189) {
190 // Initial devices fetch observable
191 const initialDevices$ = new Observable<MediaDeviceInfo[]>((subscriber) => {
192 Room.getLocalDevices(kind, requestPermissions)
193 .then((devices) => {
194 subscriber.next(devices);
195 subscriber.complete();
196 })
197 .catch((e) => {
198 onError?.(e);
199 subscriber.next([] as MediaDeviceInfo[]);
200 subscriber.complete();
201 });
202 });
203
204 // Device change observable
205 const deviceChanges$ = new Observable<MediaDeviceInfo[]>((subscriber) => {
206 const onDeviceChange = async () => {
207 try {
208 const newDevices = await Room.getLocalDevices(kind, requestPermissions);
209 subscriber.next(newDevices);
210 } catch (e: any) {
211 onError?.(e);
212 }
213 };
214
215 if (typeof window !== 'undefined') {
216 if (!window.isSecureContext) {
217 throw new Error(
218 `Accessing media devices is available only in secure contexts (HTTPS and localhost), in some or all supporting browsers. See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mediaDevices`,
219 );
220 }
221 navigator?.mediaDevices?.addEventListener('devicechange', onDeviceChange);
222 }
223
224 // Return unsubscribe function that cleans up when the observable is unsubscribed
225 return () => {
226 navigator?.mediaDevices?.removeEventListener('devicechange', onDeviceChange);
227 };
228 });
229
230 // Combine both observables
231 return concat(initialDevices$, deviceChanges$);
232}
233
234export function createDataObserver(room: Room) {
235 return roomEventSelector(room, RoomEvent.DataReceived);
236}
237
238export function createChatObserver(room: Room) {
239 return roomEventSelector(room, RoomEvent.ChatMessage);
240}
241
242export function roomAudioPlaybackAllowedObservable(room: Room) {
243 const observable = observeRoomEvents(room, RoomEvent.AudioPlaybackStatusChanged).pipe(
244 map((room) => {
245 return { canPlayAudio: room.canPlaybackAudio };
246 }),
247 );
248 return observable;
249}
250
251export function roomVideoPlaybackAllowedObservable(room: Room) {
252 const observable = observeRoomEvents(room, RoomEvent.VideoPlaybackStatusChanged).pipe(
253 map((room) => {
254 return { canPlayVideo: room.canPlaybackVideo };
255 }),
256 );
257 return observable;
258}
259
260export function createActiveDeviceObservable(room: Room, kind: MediaDeviceKind) {
261 return roomEventSelector(room, RoomEvent.ActiveDeviceChanged).pipe(
262 filter(([kindOfDevice]) => kindOfDevice === kind),
263 map(([kind, deviceId]) => {
264 log.debug('activeDeviceObservable | RoomEvent.ActiveDeviceChanged', { kind, deviceId });
265 return deviceId;
266 }),
267 );
268}
269
270export function encryptionStatusObservable(room: Room, participant: Participant | undefined) {
271 return roomEventSelector(room, RoomEvent.ParticipantEncryptionStatusChanged).pipe(
272 filter(
273 ([, p]) =>
274 participant?.identity === p?.identity ||
275 (!p && participant?.identity === room.localParticipant.identity),
276 ),
277 map(([encrypted]) => encrypted),
278 startWith(
279 participant?.isLocal
280 ? (participant as LocalParticipant).isE2EEEnabled
281 : !!participant?.isEncrypted,
282 ),
283 );
284}
285
286export function recordingStatusObservable(room: Room) {
287 return roomEventSelector(room, RoomEvent.RecordingStatusChanged).pipe(
288 map(([recording]) => recording),
289 startWith(room.isRecording),
290 );
291}
Added web/src/shared/livekit/core/observables/track.ts web/src/shared/livekit/core/observables/track.ts +208 −0 View web/src/shared/livekit/core/observables/track.ts
@@ -0,0 +1,208 @@
1import type {
2 LocalTrackPublication,
3 Participant,
4 RemoteTrackPublication,
5 Room,
6 Track,
7 TrackPublication,
8 PublicationEventCallbacks,
9} from 'livekit-client';
10import { RoomEvent, TrackEvent } from 'livekit-client';
11import { map, Observable, startWith } from 'rxjs';
12import { allParticipantRoomEvents, participantTrackEvents } from '../helper';
13import { log } from '../logger';
14import type { TrackReference } from '../track-reference';
15import { observeRoomEvents } from './room';
16import type { ParticipantTrackIdentifier } from '../types';
17import { observeParticipantEvents } from './participant';
18
19export function trackObservable(track: TrackPublication) {
20 const trackObserver = observeTrackEvents(
21 track,
22 TrackEvent.Muted,
23 TrackEvent.Unmuted,
24 TrackEvent.Subscribed,
25 TrackEvent.Unsubscribed,
26 );
27
28 return trackObserver;
29}
30
31export function observeTrackEvents(track: TrackPublication, ...events: TrackEvent[]) {
32 const observable = new Observable<TrackPublication>((subscribe) => {
33 const onTrackUpdate = () => {
34 subscribe.next(track);
35 };
36
37 events.forEach((evt) => {
38 // @ts-expect-error type of `TrackEvent` and `PublicationCallbacks` are congruent
39 track.on(evt, onTrackUpdate);
40 });
41
42 const unsubscribe = () => {
43 events.forEach((evt) => {
44 // @ts-expect-error type of `TrackEvent` and `PublicationCallbacks` are congruent
45 track.off(evt, onTrackUpdate);
46 });
47 };
48 return unsubscribe;
49 }).pipe(startWith(track));
50
51 return observable;
52}
53
54/**
55 * Create `TrackReferences` for all tracks that are included in the sources property.
56 * */
57function getTrackReferences(
58 room: Room,
59 sources: Track.Source[],
60 onlySubscribedTracks = true,
61): { trackReferences: TrackReference[]; participants: Participant[] } {
62 const localParticipant = room.localParticipant;
63 const allParticipants = [localParticipant, ...Array.from(room.remoteParticipants.values())];
64 const trackReferences: TrackReference[] = [];
65
66 allParticipants.forEach((participant) => {
67 sources.forEach((source) => {
68 const sourceReferences = Array.from<RemoteTrackPublication | LocalTrackPublication>(
69 participant.trackPublications.values(),
70 )
71 .filter(
72 (track) =>
73 track.source === source &&
74 // either return all or only the ones that are subscribed
75 (!onlySubscribedTracks || track.track),
76 )
77 .map((track): TrackReference => {
78 return {
79 participant: participant,
80 publication: track,
81 source: track.source,
82 };
83 });
84
85 trackReferences.push(...sourceReferences);
86 });
87 });
88
89 return { trackReferences, participants: allParticipants };
90}
91
92/**
93 * Create `TrackReferences` for all tracks that are included in the sources property.
94 * */
95function getParticipantTrackRefs(
96 participant: Participant,
97 identifier: ParticipantTrackIdentifier,
98 onlySubscribedTracks = false,
99): TrackReference[] {
100 const { sources, kind, name } = identifier;
101 const sourceReferences = Array.from(participant.trackPublications.values())
102 .filter(
103 (pub) =>
104 (!sources || sources.includes(pub.source)) &&
105 (!kind || pub.kind === kind) &&
106 (!name || pub.trackName === name) &&
107 // either return all or only the ones that are subscribed
108 (!onlySubscribedTracks || pub.track),
109 )
110 .map((track): TrackReference => {
111 return {
112 participant: participant,
113 publication: track,
114 source: track.source,
115 };
116 });
117
118 return sourceReferences;
119}
120
121type TrackReferencesObservableOptions = {
122 additionalRoomEvents?: RoomEvent[];
123 onlySubscribed?: boolean;
124};
125
126export function trackReferencesObservable(
127 room: Room,
128 sources: Track.Source[],
129 options: TrackReferencesObservableOptions,
130): Observable<{ trackReferences: TrackReference[]; participants: Participant[] }> {
131 const additionalRoomEvents = options.additionalRoomEvents ?? allParticipantRoomEvents;
132 const onlySubscribedTracks: boolean = options.onlySubscribed ?? true;
133 const roomEvents = Array.from(
134 new Set([
135 RoomEvent.ParticipantConnected,
136 RoomEvent.ParticipantDisconnected,
137 RoomEvent.ConnectionStateChanged,
138 RoomEvent.LocalTrackPublished,
139 RoomEvent.LocalTrackUnpublished,
140 RoomEvent.TrackPublished,
141 RoomEvent.TrackUnpublished,
142 RoomEvent.TrackSubscriptionStatusChanged,
143 ...additionalRoomEvents,
144 ]).values(),
145 );
146
147 const observable = observeRoomEvents(room, ...roomEvents).pipe(
148 map((room) => {
149 const data = getTrackReferences(room, sources, onlySubscribedTracks);
150 log.debug(`TrackReference[] was updated. (length ${data.trackReferences.length})`, data);
151 return data;
152 }),
153 startWith(getTrackReferences(room, sources, onlySubscribedTracks)),
154 );
155
156 return observable;
157}
158
159export function participantTracksObservable(
160 participant: Participant,
161 trackIdentifier: ParticipantTrackIdentifier,
162): Observable<TrackReference[]> {
163 const observable = observeParticipantEvents(participant, ...participantTrackEvents).pipe(
164 map((participant) => {
165 const data = getParticipantTrackRefs(participant, trackIdentifier);
166 log.debug(`TrackReference[] was updated. (length ${data.length})`, data);
167 return data;
168 }),
169 startWith(getParticipantTrackRefs(participant, trackIdentifier)),
170 );
171
172 return observable;
173}
174
175export function trackEventSelector<T extends TrackEvent>(
176 publication: TrackPublication | Track,
177 event: T,
178) {
179 const observable = new Observable<
180 Parameters<PublicationEventCallbacks[Extract<T, keyof PublicationEventCallbacks>]>
181 >((subscribe) => {
182 const update = (
183 ...params: Parameters<PublicationEventCallbacks[Extract<T, keyof PublicationEventCallbacks>]>
184 ) => {
185 subscribe.next(params);
186 };
187 // @ts-expect-error not a perfect overlap between TrackEvent and keyof TrackEventCallbacks
188 publication.on(event, update);
189
190 const unsubscribe = () => {
191 // @ts-expect-error not a perfect overlap between TrackEvent and keyof TrackEventCallbacks
192 publication.off(event, update);
193 };
194 return unsubscribe;
195 });
196
197 return observable;
198}
199
200export function trackTranscriptionObserver(publication: TrackPublication) {
201 return trackEventSelector(publication, TrackEvent.TranscriptionReceived);
202}
203
204export function trackSyncTimeObserver(track: Track) {
205 return trackEventSelector(track, TrackEvent.TimeSyncUpdate).pipe(
206 map(([timeUpdate]) => timeUpdate),
207 ) as unknown as Observable<{ timestamp: number; rtpTimestamp?: number }>;
208}
Added web/src/shared/livekit/core/persistent-storage/index.ts web/src/shared/livekit/core/persistent-storage/index.ts +6 −0 View web/src/shared/livekit/core/persistent-storage/index.ts
@@ -0,0 +1,6 @@
1export {
2 saveUserChoices,
3 loadUserChoices,
4 type LocalUserChoices,
5 defaultUserChoices,
6} from './user-choices';
Added web/src/shared/livekit/core/persistent-storage/local-storage-helpers.ts web/src/shared/livekit/core/persistent-storage/local-storage-helpers.ts +64 −0 View web/src/shared/livekit/core/persistent-storage/local-storage-helpers.ts
@@ -0,0 +1,64 @@
1import { log } from '../logger';
2
3type JsonPrimitive = string | number | boolean | null;
4type JsonArray = JsonValue[];
5type JsonObject = { [key: string]: JsonValue };
6type JsonValue = JsonPrimitive | JsonArray | JsonObject;
7
8/**
9 * Persists a serializable object to local storage associated with the specified key.
10 * @internal
11 */
12function saveToLocalStorage<T extends JsonValue>(key: string, value: T): void {
13 if (typeof localStorage === 'undefined') {
14 log.error('Local storage is not available.');
15 return;
16 }
17
18 try {
19 if (value) {
20 const nonEmptySettings = Object.fromEntries(
21 Object.entries(value).filter(([, value]) => value !== ''),
22 );
23 localStorage.setItem(key, JSON.stringify(nonEmptySettings));
24 }
25 } catch (error) {
26 log.error(`Error setting item to local storage: ${error}`);
27 }
28}
29
30/**
31 * Retrieves a serializable object from local storage by its key.
32 * @internal
33 */
34function loadFromLocalStorage<T extends JsonValue>(key: string): T | undefined {
35 if (typeof localStorage === 'undefined') {
36 log.error('Local storage is not available.');
37 return undefined;
38 }
39
40 try {
41 const item = localStorage.getItem(key);
42 if (!item) {
43 log.warn(`Item with key ${key} does not exist in local storage.`);
44 return undefined;
45 }
46 return JSON.parse(item);
47 } catch (error) {
48 log.error(`Error getting item from local storage: ${error}`);
49 return undefined;
50 }
51}
52
53/**
54 * Generate a pair of functions to load and save a value of type T to local storage.
55 * @internal
56 */
57export function createLocalStorageInterface<T extends JsonValue>(
58 key: string,
59): { load: () => T | undefined; save: (value: T) => void } {
60 return {
61 load: () => loadFromLocalStorage<T>(key),
62 save: (value: T) => saveToLocalStorage<T>(key, value),
63 };
64}
Added web/src/shared/livekit/core/persistent-storage/user-choices.ts web/src/shared/livekit/core/persistent-storage/user-choices.ts +103 −0 View web/src/shared/livekit/core/persistent-storage/user-choices.ts
@@ -0,0 +1,103 @@
1import { cssPrefix } from '../constants';
2import { createLocalStorageInterface } from './local-storage-helpers';
3
4const USER_CHOICES_KEY = `${cssPrefix}-user-choices` as const;
5
6/**
7 * @public
8 * Represents the user's choices for video and audio input devices,
9 * as well as their username.
10 */
11export type LocalUserChoices = {
12 /**
13 * Whether video input is enabled.
14 * @defaultValue `true`
15 */
16 videoEnabled: boolean;
17 /**
18 * Whether audio input is enabled.
19 * @defaultValue `true`
20 */
21 audioEnabled: boolean;
22 /**
23 * The device ID of the video input device to use.
24 * @defaultValue `''`
25 */
26 videoDeviceId: string;
27 /**
28 * The device ID of the audio input device to use.
29 * @defaultValue `''`
30 */
31 audioDeviceId: string;
32 /**
33 * The username to use.
34 * @defaultValue `''`
35 */
36 username: string;
37};
38
39export const defaultUserChoices: LocalUserChoices = {
40 videoEnabled: true,
41 audioEnabled: true,
42 videoDeviceId: 'default',
43 audioDeviceId: 'default',
44 username: '',
45} as const;
46
47/**
48 * The type of the object stored in local storage.
49 * @remarks
50 * TODO: Replace this type with `LocalUserChoices` after removing the deprecated properties from `LocalUserChoices`.
51 * @internal
52 */
53type TempStorageType = Omit<LocalUserChoices, 'e2ee' | 'sharedPassphrase'>;
54const { load, save } = createLocalStorageInterface<TempStorageType>(USER_CHOICES_KEY);
55
56/**
57 * Saves user choices to local storage.
58 * @alpha
59 */
60export function saveUserChoices(
61 userChoices: LocalUserChoices,
62 /**
63 * Whether to prevent saving user choices to local storage.
64 */
65 preventSave: boolean = false,
66): void {
67 if (preventSave === true) {
68 return;
69 }
70 save(userChoices);
71}
72
73/**
74 * Reads the user choices from local storage, or returns the default settings if none are found.
75 * @remarks
76 * The deprecated parameters `e2ee` and `sharedPassphrase` are not read from local storage
77 * and always return the value from the passed `defaults` or internal defaults.
78 * @alpha
79 */
80export function loadUserChoices(
81 defaults?: Partial<LocalUserChoices>,
82 /**
83 * Whether to prevent loading from local storage and return default values instead.
84 * @defaultValue false
85 */
86 preventLoad: boolean = false,
87): LocalUserChoices {
88 const fallback: LocalUserChoices = {
89 videoEnabled: defaults?.videoEnabled ?? defaultUserChoices.videoEnabled,
90 audioEnabled: defaults?.audioEnabled ?? defaultUserChoices.audioEnabled,
91 videoDeviceId: defaults?.videoDeviceId ?? defaultUserChoices.videoDeviceId,
92 audioDeviceId: defaults?.audioDeviceId ?? defaultUserChoices.audioDeviceId,
93 username: defaults?.username ?? defaultUserChoices.username,
94 };
95
96 if (preventLoad) {
97 return fallback;
98 } else {
99 const maybeLoadedObject = load();
100 const result = { ...fallback, ...(maybeLoadedObject ?? {}) };
101 return result;
102 }
103}
Added web/src/shared/livekit/core/sorting/base-sort-functions.ts web/src/shared/livekit/core/sorting/base-sort-functions.ts +101 −0 View web/src/shared/livekit/core/sorting/base-sort-functions.ts
@@ -0,0 +1,101 @@
1import type { Participant } from 'livekit-client';
2import { Track } from 'livekit-client';
3import type { TrackReferenceOrPlaceholder } from '../track-reference';
4import { getTrackReferenceSource, isTrackReference } from '../track-reference';
5
6export function sortParticipantsByAudioLevel(
7 a: Pick<Participant, 'audioLevel'>,
8 b: Pick<Participant, 'audioLevel'>,
9): number {
10 return b.audioLevel - a.audioLevel;
11}
12
13export function sortParticipantsByIsSpeaking(
14 a: Pick<Participant, 'isSpeaking'>,
15 b: Pick<Participant, 'isSpeaking'>,
16): number {
17 if (a.isSpeaking === b.isSpeaking) {
18 return 0;
19 } else {
20 return a.isSpeaking ? -1 : 1;
21 }
22}
23
24export function sortParticipantsByLastSpokenAT(
25 a: Pick<Participant, 'lastSpokeAt'>,
26 b: Pick<Participant, 'lastSpokeAt'>,
27): number {
28 if (a.lastSpokeAt !== undefined || b.lastSpokeAt !== undefined) {
29 return (b.lastSpokeAt?.getTime() ?? 0) - (a.lastSpokeAt?.getTime() ?? 0);
30 } else {
31 return 0;
32 }
33}
34
35export function sortParticipantsByJoinedAt(
36 a: Pick<Participant, 'joinedAt'>,
37 b: Pick<Participant, 'joinedAt'>,
38) {
39 return (a.joinedAt?.getTime() ?? 0) - (b.joinedAt?.getTime() ?? 0);
40}
41
42export function sortTrackReferencesByType(
43 a: TrackReferenceOrPlaceholder,
44 b: TrackReferenceOrPlaceholder,
45) {
46 if (isTrackReference(a)) {
47 if (isTrackReference(b)) {
48 return 0;
49 } else {
50 return -1;
51 }
52 } else if (isTrackReference(b)) {
53 return 1;
54 } else {
55 return 0;
56 }
57}
58
59/** TrackReference with screen share source goes first. */
60export function sortTrackReferencesByScreenShare(
61 a: TrackReferenceOrPlaceholder,
62 b: TrackReferenceOrPlaceholder,
63): number {
64 const sourceA = getTrackReferenceSource(a);
65 const sourceB = getTrackReferenceSource(b);
66
67 if (sourceA === sourceB) {
68 if (sourceA === Track.Source.ScreenShare) {
69 if (a.participant.isLocal === b.participant.isLocal) {
70 return 0;
71 } else {
72 return a.participant.isLocal ? 1 : -1;
73 }
74 }
75 return 0;
76 } else if (sourceA === Track.Source.ScreenShare) {
77 return -1;
78 } else if (sourceB === Track.Source.ScreenShare) {
79 return 1;
80 } else {
81 return 0;
82 }
83}
84
85export function sortTrackRefsByIsCameraEnabled(
86 a: { participant: { isCameraEnabled: boolean } },
87 b: { participant: { isCameraEnabled: boolean } },
88) {
89 const aVideo = a.participant.isCameraEnabled;
90 const bVideo = b.participant.isCameraEnabled;
91
92 if (aVideo !== bVideo) {
93 if (aVideo) {
94 return -1;
95 } else {
96 return 1;
97 }
98 } else {
99 return 0;
100 }
101}
Added web/src/shared/livekit/core/sorting/index.ts web/src/shared/livekit/core/sorting/index.ts +3 −0 View web/src/shared/livekit/core/sorting/index.ts
@@ -0,0 +1,3 @@
1export { sortTrackReferences } from './sort-track-bundles';
2export { sortParticipants } from './sort-participants';
3export { updatePages } from './tile-array-update';
Added web/src/shared/livekit/core/sorting/sort-participants.ts web/src/shared/livekit/core/sorting/sort-participants.ts +63 −0 View web/src/shared/livekit/core/sorting/sort-participants.ts
@@ -0,0 +1,63 @@
1import type { Participant } from 'livekit-client';
2import { LocalParticipant } from 'livekit-client';
3import {
4 sortParticipantsByAudioLevel,
5 sortParticipantsByIsSpeaking,
6 sortParticipantsByJoinedAt,
7 sortParticipantsByLastSpokenAT,
8} from './base-sort-functions';
9
10/**
11 * Default sort for participants, it'll order participants by:
12 * 1. local participant
13 * 2. dominant speaker (speaker with the loudest audio level)
14 * 3. other speakers that are recently active
15 * 4. participants with video on
16 * 5. by joinedAt
17 */
18export function sortParticipants(participants: Participant[]): Participant[] {
19 const sortedParticipants = [...participants];
20 sortedParticipants.sort((a, b) => {
21 // loudest speaker first
22 if (a.isSpeaking && b.isSpeaking) {
23 return sortParticipantsByAudioLevel(a, b);
24 }
25
26 // speaker goes first
27 if (a.isSpeaking !== b.isSpeaking) {
28 return sortParticipantsByIsSpeaking(a, b);
29 }
30
31 // last active speaker first
32 if (a.lastSpokeAt !== b.lastSpokeAt) {
33 return sortParticipantsByLastSpokenAT(a, b);
34 }
35
36 // video on
37 const aVideo = a.videoTrackPublications.size > 0;
38 const bVideo = b.videoTrackPublications.size > 0;
39 if (aVideo !== bVideo) {
40 if (aVideo) {
41 return -1;
42 } else {
43 return 1;
44 }
45 }
46
47 // joinedAt
48 return sortParticipantsByJoinedAt(a, b);
49 });
50 const localParticipant = sortedParticipants.find((p) => p.isLocal) as LocalParticipant;
51 if (localParticipant) {
52 const localIdx = sortedParticipants.indexOf(localParticipant);
53 if (localIdx >= 0) {
54 sortedParticipants.splice(localIdx, 1);
55 if (sortedParticipants.length > 0) {
56 sortedParticipants.splice(0, 0, localParticipant);
57 } else {
58 sortedParticipants.push(localParticipant);
59 }
60 }
61 }
62 return sortedParticipants;
63}
Added web/src/shared/livekit/core/sorting/sort-track-bundles.ts web/src/shared/livekit/core/sorting/sort-track-bundles.ts +122 −0 View web/src/shared/livekit/core/sorting/sort-track-bundles.ts
@@ -0,0 +1,122 @@
1import { Track } from 'livekit-client';
2import type { TrackReferenceOrPlaceholder } from '../track-reference';
3import { isTrackReference } from '../track-reference';
4import {
5 sortParticipantsByAudioLevel,
6 sortParticipantsByIsSpeaking,
7 sortParticipantsByJoinedAt,
8 sortParticipantsByLastSpokenAT,
9 sortTrackReferencesByType,
10 sortTrackRefsByIsCameraEnabled,
11} from './base-sort-functions';
12
13/**
14 * Default sort for `TrackReferenceOrPlaceholder`, it'll order participants by:
15 * 1. local camera track (publication.isLocal)
16 * 2. remote screen_share track
17 * 3. local screen_share track
18 * 4. remote dominant speaker camera track (sorted by speaker with the loudest audio level)
19 * 5. other remote speakers that are recently active
20 * 6. remote unmuted camera tracks
21 * 7. remote tracks sorted by joinedAt
22 */
23export function sortTrackReferences(
24 tracks: TrackReferenceOrPlaceholder[],
25): TrackReferenceOrPlaceholder[] {
26 const localTracks: TrackReferenceOrPlaceholder[] = [];
27 const screenShareTracks: TrackReferenceOrPlaceholder[] = [];
28 const cameraTracks: TrackReferenceOrPlaceholder[] = [];
29 const undefinedTracks: TrackReferenceOrPlaceholder[] = [];
30
31 tracks.forEach((trackRef) => {
32 if (trackRef.participant.isLocal && trackRef.source === Track.Source.Camera) {
33 localTracks.push(trackRef);
34 } else if (trackRef.source === Track.Source.ScreenShare) {
35 screenShareTracks.push(trackRef);
36 } else if (trackRef.source === Track.Source.Camera) {
37 cameraTracks.push(trackRef);
38 } else {
39 undefinedTracks.push(trackRef);
40 }
41 });
42
43 const sortedScreenShareTracks = sortScreenShareTracks(screenShareTracks);
44 const sortedCameraTracks = sortCameraTracks(cameraTracks);
45
46 return [...localTracks, ...sortedScreenShareTracks, ...sortedCameraTracks, ...undefinedTracks];
47}
48
49/**
50 * Sort an array of `TrackReference` screen shares.
51 * Main sorting order:
52 * 1. remote screen shares
53 * 2. local screen shares
54 * Secondary sorting by participant's joining time.
55 */
56function sortScreenShareTracks(
57 screenShareTracks: TrackReferenceOrPlaceholder[],
58): TrackReferenceOrPlaceholder[] {
59 const localScreenShares: TrackReferenceOrPlaceholder[] = [];
60 const remoteScreenShares: TrackReferenceOrPlaceholder[] = [];
61
62 screenShareTracks.forEach((trackRef) => {
63 if (trackRef.participant.isLocal) {
64 localScreenShares.push(trackRef);
65 } else {
66 remoteScreenShares.push(trackRef);
67 }
68 });
69
70 localScreenShares.sort((a, b) => sortParticipantsByJoinedAt(a.participant, b.participant));
71 remoteScreenShares.sort((a, b) => sortParticipantsByJoinedAt(a.participant, b.participant));
72
73 const sortedScreenShareTrackRefs = [...remoteScreenShares, ...localScreenShares];
74 return sortedScreenShareTrackRefs;
75}
76
77function sortCameraTracks(
78 cameraTrackReferences: TrackReferenceOrPlaceholder[],
79): TrackReferenceOrPlaceholder[] {
80 const localCameraTracks: TrackReferenceOrPlaceholder[] = [];
81 const remoteCameraTracks: TrackReferenceOrPlaceholder[] = [];
82
83 cameraTrackReferences.forEach((trackRef) => {
84 if (trackRef.participant.isLocal) {
85 localCameraTracks.push(trackRef);
86 } else {
87 remoteCameraTracks.push(trackRef);
88 }
89 });
90
91 remoteCameraTracks.sort((a, b) => {
92 // Participant with higher audio level goes first.
93 if (a.participant.isSpeaking && b.participant.isSpeaking) {
94 return sortParticipantsByAudioLevel(a.participant, b.participant);
95 }
96
97 // A speaking participant goes before one that is not speaking.
98 if (a.participant.isSpeaking !== b.participant.isSpeaking) {
99 return sortParticipantsByIsSpeaking(a.participant, b.participant);
100 }
101
102 // A participant that spoke recently goes before a participant that spoke a while back.
103 if (a.participant.lastSpokeAt !== b.participant.lastSpokeAt) {
104 return sortParticipantsByLastSpokenAT(a.participant, b.participant);
105 }
106
107 // TrackReference before TrackReferencePlaceholder
108 if (isTrackReference(a) !== isTrackReference(b)) {
109 return sortTrackReferencesByType(a, b);
110 }
111
112 // Tiles with video on before tiles with muted video track.
113 if (a.participant.isCameraEnabled !== b.participant.isCameraEnabled) {
114 return sortTrackRefsByIsCameraEnabled(a, b);
115 }
116
117 // A participant that joined a long time ago goes before one that joined recently.
118 return sortParticipantsByJoinedAt(a.participant, b.participant);
119 });
120
121 return [...localCameraTracks, ...remoteCameraTracks];
122}
Added web/src/shared/livekit/core/sorting/tile-array-update.ts web/src/shared/livekit/core/sorting/tile-array-update.ts +169 −0 View web/src/shared/livekit/core/sorting/tile-array-update.ts
@@ -0,0 +1,169 @@
1import { differenceBy, chunk, zip } from '../helper/array-helper';
2import { log } from '../logger';
3import type { TrackReferenceOrPlaceholder } from '../track-reference';
4import {
5 getTrackReferenceId,
6 isPlaceholderReplacement,
7 isTrackReference,
8 isTrackReferencePlaceholder,
9} from '../track-reference';
10import { flatTrackReferenceArray } from '../track-reference/test-utils';
11
12type VisualChanges<T> = {
13 dropped: T[];
14 added: T[];
15};
16
17export type UpdatableItem = TrackReferenceOrPlaceholder | number;
18
19/** Check to see if anything visually changes on the page. */
20export function visualPageChange<T extends UpdatableItem>(state: T[], next: T[]): VisualChanges<T> {
21 return {
22 dropped: differenceBy(state, next, getTrackReferenceId),
23 added: differenceBy(next, state, getTrackReferenceId),
24 };
25}
26
27function listNeedsUpdating<T>(changes: VisualChanges<T>): boolean {
28 return changes.added.length !== 0 || changes.dropped.length !== 0;
29}
30
31export function findIndex<T extends UpdatableItem>(
32 trackReference: T,
33 trackReferences: T[],
34): number {
35 const indexToReplace = trackReferences.findIndex(
36 (trackReference_) =>
37 getTrackReferenceId(trackReference_) === getTrackReferenceId(trackReference),
38 );
39 if (indexToReplace === -1) {
40 throw new Error(
41 `Element not part of the array: ${getTrackReferenceId(
42 trackReference,
43 )} not in ${flatTrackReferenceArray(trackReferences)}`,
44 );
45 }
46 return indexToReplace;
47}
48
49/** Swap items in the complete list of all elements */
50export function swapItems<T extends UpdatableItem>(
51 moveForward: T,
52 moveBack: T,
53 trackReferences: T[],
54): T[] {
55 const indexToReplace = findIndex(moveForward, trackReferences);
56 const indexReplaceWith = findIndex(moveBack, trackReferences);
57
58 trackReferences.splice(indexToReplace, 1, moveBack);
59 trackReferences.splice(indexReplaceWith, 1, moveForward);
60
61 return trackReferences;
62}
63
64export function dropItem<T extends UpdatableItem>(itemToDrop: T, list: T[]): T[] {
65 const indexOfElementToDrop = findIndex(itemToDrop, list);
66 // const indexOfElementToDrop = list.findIndex((item) => item === itemToDrop, list);
67 list.splice(indexOfElementToDrop, 1);
68 return list;
69}
70
71function addItem<T extends UpdatableItem>(itemToAdd: T, list: T[]): T[] {
72 return [...list, itemToAdd];
73}
74
75export function divideIntoPages<T>(list: T[], maxElementsOnPage: number): Array<T[]> {
76 const pages = chunk(list, maxElementsOnPage);
77 return pages;
78}
79
80/** Divide the list of elements into pages and and check if pages need updating. */
81export function updatePages<T extends UpdatableItem>(
82 currentList: T[],
83 nextList: T[],
84 maxItemsOnPage: number,
85): T[] {
86 let updatedList: T[] = refreshList(currentList, nextList);
87
88 if (updatedList.length < nextList.length) {
89 // Items got added: Find newly added items and add them to the end of the list.
90 const addedItems = differenceBy(nextList, updatedList, getTrackReferenceId);
91 updatedList = [...updatedList, ...addedItems];
92 }
93 const currentPages = divideIntoPages(updatedList, maxItemsOnPage);
94 const nextPages = divideIntoPages(nextList, maxItemsOnPage);
95
96 zip(currentPages, nextPages).forEach(([currentPage, nextPage], pageIndex) => {
97 if (currentPage && nextPage) {
98 // 1) Identify missing tile.
99 const updatedPage = divideIntoPages(updatedList, maxItemsOnPage)[pageIndex];
100 const changes = visualPageChange(updatedPage, nextPage);
101
102 if (listNeedsUpdating(changes)) {
103 log.debug(
104 `Detected visual changes on page: ${pageIndex}, current: ${flatTrackReferenceArray(
105 currentPage,
106 )}, next: ${flatTrackReferenceArray(nextPage)}`,
107 { changes },
108 );
109 // ## Swap Items
110 if (changes.added.length === changes.dropped.length) {
111 zip(changes.added, changes.dropped).forEach(([added, dropped]) => {
112 if (added && dropped) {
113 updatedList = swapItems<T>(added, dropped, updatedList);
114 } else {
115 throw new Error(
116 `For a swap action we need a addition and a removal one is missing: ${added}, ${dropped}`,
117 );
118 }
119 });
120 }
121 // ## Handle Drop Items
122 if (changes.added.length === 0 && changes.dropped.length > 0) {
123 changes.dropped.forEach((item) => {
124 updatedList = dropItem<T>(item, updatedList);
125 });
126 }
127 // ## Handle Item added
128 if (changes.added.length > 0 && changes.dropped.length === 0) {
129 changes.added.forEach((item) => {
130 updatedList = addItem<T>(item, updatedList);
131 });
132 }
133 }
134 }
135 });
136
137 if (updatedList.length > nextList.length) {
138 // Items got removed: Find items that got completely removed from the list.
139 const missingItems = differenceBy(updatedList, nextList, getTrackReferenceId);
140 updatedList = updatedList.filter(
141 (item) => !missingItems.map(getTrackReferenceId).includes(getTrackReferenceId(item)),
142 );
143 }
144
145 return updatedList;
146}
147
148/**
149 * Update the current list with the items from the next list whenever the item ids are the same
150 * or the current item is a placeholder and we find a track reference in the next list
151 * to replace the placeholder with.
152 * @remarks
153 * This is needed because `TrackReference`s can change their internal state while keeping the same id.
154 */
155function refreshList<T extends UpdatableItem>(currentList: T[], nextList: T[]): T[] {
156 return currentList.map((currentItem) => {
157 const updateForCurrentItem = nextList.find(
158 (newItem_) =>
159 // If the IDs match or ..
160 getTrackReferenceId(currentItem) === getTrackReferenceId(newItem_) ||
161 // ... if the current item is a placeholder and the new item is the track reference can replace it.
162 (typeof currentItem !== 'number' &&
163 isTrackReferencePlaceholder(currentItem) &&
164 isTrackReference(newItem_) &&
165 isPlaceholderReplacement(currentItem, newItem_)),
166 );
167 return updateForCurrentItem ?? currentItem;
168 });
169}
Added web/src/shared/livekit/core/styles-interface/class-prefixer.ts web/src/shared/livekit/core/styles-interface/class-prefixer.ts +14 −0 View web/src/shared/livekit/core/styles-interface/class-prefixer.ts
@@ -0,0 +1,14 @@
1import type { UnprefixedClassNames as ComponentNoPrefixClasses } from '@livekit/components-styles/dist/types_unprefixed/index.scss';
2import type { UnprefixedClassNames as PrefabNoPrefixClasses } from '@livekit/components-styles/dist/types_unprefixed/prefabs/index.scss';
3import { cssPrefix } from './../constants';
4
5type UnprefixedClassNames = ComponentNoPrefixClasses | PrefabNoPrefixClasses;
6
7/**
8 * This function is a type safe way to add a prefix to a HTML class attribute.
9 * Only classes defined in the styles module are valid, any other class produces a ts error.
10 * @internal
11 */
12export function prefixClass<T extends UnprefixedClassNames>(unprefixedClassName: T) {
13 return `${cssPrefix}-${unprefixedClassName}` as const;
14}
Added web/src/shared/livekit/core/styles-interface/index.ts web/src/shared/livekit/core/styles-interface/index.ts +1 −0 View web/src/shared/livekit/core/styles-interface/index.ts
@@ -0,0 +1 @@
1export { prefixClass } from './class-prefixer';
Added web/src/shared/livekit/core/track-reference/index.ts web/src/shared/livekit/core/track-reference/index.ts +2 −0 View web/src/shared/livekit/core/track-reference/index.ts
@@ -0,0 +1,2 @@
1export * from './track-reference.types';
2export * from './track-reference.utils';
Added web/src/shared/livekit/core/track-reference/test-utils.ts web/src/shared/livekit/core/track-reference/test-utils.ts +102 −0 View web/src/shared/livekit/core/track-reference/test-utils.ts
@@ -0,0 +1,102 @@
1/**
2 * Internal test function.
3 *
4 * @internal
5 */
6
7import { Participant, RemoteTrackPublication, Track, TrackPublication } from 'livekit-client';
8import type { UpdatableItem } from '../sorting/tile-array-update';
9import type { TrackReference, TrackReferencePlaceholder } from './track-reference.types';
10import { getTrackReferenceId } from './track-reference.utils';
11import { TrackInfo } from '@livekit/protocol';
12
13// Test function:
14export const mockTrackReferencePlaceholder = (
15 id: string,
16 source: Track.Source,
17): TrackReferencePlaceholder => {
18 return { participant: new Participant(`${id}`, `${id}`), source };
19};
20
21export const mockTrackReferencePublished = (id: string, source: Track.Source): TrackReference => {
22 const kind = [Track.Source.Camera, Track.Source.ScreenShare].includes(source)
23 ? Track.Kind.Video
24 : Track.Kind.Audio;
25 const trackInfo = new TrackInfo({
26 sid: `${id}`,
27 name: `${id}`,
28 muted: false,
29 });
30 return {
31 participant: new Participant(`${id}`, `${id}`),
32 publication: new RemoteTrackPublication(kind, trackInfo, true),
33 source: source,
34 };
35};
36
37type mockTrackReferenceSubscribedOptions = {
38 mockPublication?: boolean;
39 mockParticipant?: boolean;
40 mockIsLocal?: boolean;
41};
42
43export const mockTrackReferenceSubscribed = (
44 id: string,
45 source: Track.Source,
46 options: mockTrackReferenceSubscribedOptions = {},
47): TrackReference => {
48 const kind = [Track.Source.Camera, Track.Source.ScreenShare].includes(source)
49 ? Track.Kind.Video
50 : Track.Kind.Audio;
51 const trackInfo = new TrackInfo({
52 sid: `${id}`,
53 name: `${id}`,
54 muted: false,
55 });
56 const publication = new RemoteTrackPublication(kind, trackInfo, true);
57 // @ts-expect-error
58 publication.track = {};
59 return {
60 participant: options.mockParticipant
61 ? (mockParticipant(id, options.mockIsLocal ?? false) as Participant)
62 : new Participant(`${id}`, `${id}`),
63 publication: options.mockPublication
64 ? (mockTrackPublication(`publicationId(${id})`, kind, source) as TrackPublication)
65 : publication,
66 source,
67 };
68};
69
70const mockTrackPublication = (
71 id: string,
72 kind: Track.Kind,
73 source: Track.Source,
74): Pick<TrackPublication, 'kind' | 'trackSid' | 'trackName' | 'source'> => {
75 return {
76 kind,
77 trackSid: id,
78 trackName: `name_${id}`,
79 source: source,
80 };
81};
82
83function mockParticipant(
84 id: string,
85 isLocal: boolean,
86): Pick<Participant, 'sid' | 'identity' | 'isLocal'> {
87 return {
88 sid: `${id}_sid`,
89 identity: `${id}`,
90 isLocal: isLocal,
91 };
92}
93
94export function flatTrackReferenceArray<T extends UpdatableItem>(list: T[]): string[] {
95 return list.map((item) => {
96 if (typeof item === 'string' || typeof item === 'number') {
97 return `${item}`;
98 } else {
99 return getTrackReferenceId(item);
100 }
101 });
102}
Added web/src/shared/livekit/core/track-reference/track-reference.types.ts web/src/shared/livekit/core/track-reference/track-reference.types.ts +73 −0 View web/src/shared/livekit/core/track-reference/track-reference.types.ts
@@ -0,0 +1,73 @@
1/**
2 * The TrackReference type is a logical grouping of participant publication and/or subscribed track.
3 *
4 */
5
6import type { Participant, Track, TrackPublication } from 'livekit-client';
7// ## TrackReference Types
8
9/** @public */
10export type TrackReferencePlaceholder = {
11 participant: Participant;
12 publication?: never;
13 source: Track.Source;
14};
15
16/** @public */
17export type TrackReference = {
18 participant: Participant;
19 publication: TrackPublication;
20 source: Track.Source;
21};
22
23/** @public */
24export type TrackReferenceOrPlaceholder = TrackReference | TrackReferencePlaceholder;
25
26// ### TrackReference Type Predicates
27/** @internal */
28export function isTrackReference(trackReference: unknown): trackReference is TrackReference {
29 if (typeof trackReference === 'undefined') {
30 return false;
31 }
32 return (
33 isTrackReferenceSubscribed(trackReference as TrackReference) ||
34 isTrackReferencePublished(trackReference as TrackReference)
35 );
36}
37
38function isTrackReferenceSubscribed(trackReference?: TrackReferenceOrPlaceholder): boolean {
39 if (!trackReference) {
40 return false;
41 }
42 return (
43 trackReference.hasOwnProperty('participant') &&
44 trackReference.hasOwnProperty('source') &&
45 trackReference.hasOwnProperty('track') &&
46 typeof trackReference.publication?.track !== 'undefined'
47 );
48}
49
50function isTrackReferencePublished(trackReference?: TrackReferenceOrPlaceholder): boolean {
51 if (!trackReference) {
52 return false;
53 }
54 return (
55 trackReference.hasOwnProperty('participant') &&
56 trackReference.hasOwnProperty('source') &&
57 trackReference.hasOwnProperty('publication') &&
58 typeof trackReference.publication !== 'undefined'
59 );
60}
61
62export function isTrackReferencePlaceholder(
63 trackReference?: TrackReferenceOrPlaceholder,
64): trackReference is TrackReferencePlaceholder {
65 if (!trackReference) {
66 return false;
67 }
68 return (
69 trackReference.hasOwnProperty('participant') &&
70 trackReference.hasOwnProperty('source') &&
71 typeof trackReference.publication === 'undefined'
72 );
73}
Added web/src/shared/livekit/core/track-reference/track-reference.utils.ts web/src/shared/livekit/core/track-reference/track-reference.utils.ts +97 −0 View web/src/shared/livekit/core/track-reference/track-reference.utils.ts
@@ -0,0 +1,97 @@
1import type { Track } from 'livekit-client';
2import type { PinState } from '../types';
3import type { TrackReferenceOrPlaceholder } from './track-reference.types';
4import { isTrackReference, isTrackReferencePlaceholder } from './track-reference.types';
5
6/**
7 * Returns a id to identify the `TrackReference` or `TrackReferencePlaceholder` based on
8 * participant, track source and trackSid.
9 * @remarks
10 * The id pattern is: `${participantIdentity}_${trackSource}_${trackSid}` for `TrackReference`
11 * and `${participantIdentity}_${trackSource}_placeholder` for `TrackReferencePlaceholder`.
12 */
13export function getTrackReferenceId(trackReference: TrackReferenceOrPlaceholder | number) {
14 if (typeof trackReference === 'string' || typeof trackReference === 'number') {
15 return `${trackReference}`;
16 } else if (isTrackReferencePlaceholder(trackReference)) {
17 return `${trackReference.participant.identity}_${trackReference.source}_placeholder`;
18 } else if (isTrackReference(trackReference)) {
19 return `${trackReference.participant.identity}_${trackReference.publication.source}_${trackReference.publication.trackSid}`;
20 } else {
21 throw new Error(`Can't generate a id for the given track reference: ${trackReference}`);
22 }
23}
24
25export type TrackReferenceId = ReturnType<typeof getTrackReferenceId>;
26
27/** Returns the Source of the TrackReference. */
28export function getTrackReferenceSource(trackReference: TrackReferenceOrPlaceholder): Track.Source {
29 if (isTrackReference(trackReference)) {
30 return trackReference.publication.source;
31 } else {
32 return trackReference.source;
33 }
34}
35
36export function isEqualTrackRef(
37 a?: TrackReferenceOrPlaceholder,
38 b?: TrackReferenceOrPlaceholder,
39): boolean {
40 if (a === undefined || b === undefined) {
41 return false;
42 }
43 if (isTrackReference(a) && isTrackReference(b)) {
44 return a.publication.trackSid === b.publication.trackSid;
45 } else {
46 return getTrackReferenceId(a) === getTrackReferenceId(b);
47 }
48}
49
50/**
51 * Check if the `TrackReference` is pinned.
52 */
53export function isTrackReferencePinned(
54 trackReference: TrackReferenceOrPlaceholder,
55 pinState: PinState | undefined,
56): boolean {
57 if (typeof pinState === 'undefined') {
58 return false;
59 }
60 if (isTrackReference(trackReference)) {
61 return pinState.some(
62 (pinnedTrackReference) =>
63 pinnedTrackReference.participant.identity === trackReference.participant.identity &&
64 isTrackReference(pinnedTrackReference) &&
65 pinnedTrackReference.publication.trackSid === trackReference.publication.trackSid,
66 );
67 } else if (isTrackReferencePlaceholder(trackReference)) {
68 return pinState.some(
69 (pinnedTrackReference) =>
70 pinnedTrackReference.participant.identity === trackReference.participant.identity &&
71 isTrackReferencePlaceholder(pinnedTrackReference) &&
72 pinnedTrackReference.source === trackReference.source,
73 );
74 } else {
75 return false;
76 }
77}
78
79/**
80 * Check if the current `currentTrackRef` is the placeholder for next `nextTrackRef`.
81 * Based on the participant identity and the source.
82 * @internal
83 */
84export function isPlaceholderReplacement(
85 currentTrackRef: TrackReferenceOrPlaceholder,
86 nextTrackRef: TrackReferenceOrPlaceholder,
87) {
88 // if (typeof nextTrackRef === 'number' || typeof currentTrackRef === 'number') {
89 // return false;
90 // }
91 return (
92 isTrackReferencePlaceholder(currentTrackRef) &&
93 isTrackReference(nextTrackRef) &&
94 nextTrackRef.participant.identity === currentTrackRef.participant.identity &&
95 nextTrackRef.source === currentTrackRef.source
96 );
97}
Added web/src/shared/livekit/core/constants.ts web/src/shared/livekit/core/constants.ts +1 −0 View web/src/shared/livekit/core/constants.ts
@@ -0,0 +1 @@
1export const cssPrefix = 'lk';
Added web/src/shared/livekit/core/index.ts web/src/shared/livekit/core/index.ts +36 −0 View web/src/shared/livekit/core/index.ts
@@ -0,0 +1,36 @@
1export * from './constants';
2export * from './utils';
3export * from './helper';
4export * from './types';
5export * from './sorting';
6export * from './track-reference';
7export * from './messages';
8
9export * from './components/mediaToggle';
10export * from './components/mediaDeviceSelect';
11export * from './components/disconnectButton';
12export * from './components/mediaTrack';
13export * from './components/connectionQualityIndicator';
14export * from './components/trackMutedIndicator';
15export * from './components/participantName';
16export * from './components/mediaTrack';
17export * from './components/participantTile';
18export * from './components/chat';
19export * from './components/startAudio';
20export * from './components/startVideo';
21export * from './components/chatToggle';
22export * from './components/focusToggle';
23export * from './components/clearPinButton';
24export * from './components/room';
25
26export * from './observables/room';
27export * from './observables/participant';
28export * from './observables/track';
29export * from './observables/dataChannel';
30export * from './observables/dom-event';
31
32export * from './persistent-storage';
33
34export * from './components/textStream';
35
36export { log, setLogLevel, setLogExtension } from './logger';
Added web/src/shared/livekit/core/logger.ts web/src/shared/livekit/core/logger.ts +55 −0 View web/src/shared/livekit/core/logger.ts
@@ -0,0 +1,55 @@
1import {
2 setLogLevel as setClientSdkLogLevel,
3 setLogExtension as setClientSdkLogExtension,
4 LogLevel as LogLevelEnum,
5} from 'livekit-client';
6import loglevel from 'loglevel';
7
8export const log = loglevel.getLogger('lk-components-js');
9log.setDefaultLevel('WARN');
10
11type LogLevel = Parameters<typeof setClientSdkLogLevel>[0];
12type SetLogLevelOptions = {
13 liveKitClientLogLevel?: LogLevel;
14};
15
16/**
17 * Set the log level for both the `@livekit/components-react` package and the `@livekit-client` package.
18 * To set the `@livekit-client` log independently, use the `liveKitClientLogLevel` prop on the `options` object.
19 * @public
20 */
21export function setLogLevel(level: LogLevel, options: SetLogLevelOptions = {}): void {
22 log.setLevel(level);
23 setClientSdkLogLevel(options.liveKitClientLogLevel ?? level);
24}
25
26type LogExtension = (level: LogLevel, msg: string, context?: object) => void;
27type SetLogExtensionOptions = {
28 liveKitClientLogExtension?: LogExtension;
29};
30
31/**
32 * Set the log extension for both the `@livekit/components-react` package and the `@livekit-client` package.
33 * To set the `@livekit-client` log extension, use the `liveKitClientLogExtension` prop on the `options` object.
34 * @public
35 */
36export function setLogExtension(extension: LogExtension, options: SetLogExtensionOptions = {}) {
37 const originalFactory = log.methodFactory;
38
39 log.methodFactory = (methodName, configLevel, loggerName) => {
40 const rawMethod = originalFactory(methodName, configLevel, loggerName);
41
42 const logLevel = LogLevelEnum[methodName];
43 const needLog = logLevel >= configLevel && logLevel < LogLevelEnum.silent;
44
45 return (msg, context?: [msg: string, context: object]) => {
46 if (context) rawMethod(msg, context);
47 else rawMethod(msg);
48 if (needLog) {
49 extension(logLevel, msg, context);
50 }
51 };
52 };
53 log.setLevel(log.getLevel()); // Be sure to call setLevel method in order to apply plugin
54 setClientSdkLogExtension(options.liveKitClientLogExtension ?? extension);
55}
Added web/src/shared/livekit/core/types.ts web/src/shared/livekit/core/types.ts +89 −0 View web/src/shared/livekit/core/types.ts
@@ -0,0 +1,89 @@
1import type { Participant, ParticipantKind, Track, TrackPublication } from 'livekit-client';
2import type { TrackReference, TrackReferenceOrPlaceholder } from './track-reference';
3
4// ## PinState Type
5/** @public */
6export type PinState = TrackReferenceOrPlaceholder[];
7export const PIN_DEFAULT_STATE: PinState = [];
8
9// ## WidgetState Types
10/** @public */
11export type WidgetState = {
12 showChat: boolean;
13 unreadMessages: number;
14 showSettings?: boolean;
15};
16export const WIDGET_DEFAULT_STATE: WidgetState = {
17 showChat: false,
18 unreadMessages: 0,
19 showSettings: false,
20};
21
22// ## Track Source Types
23export type TrackSourceWithOptions = { source: Track.Source; withPlaceholder: boolean };
24
25export type SourcesArray = Track.Source[] | TrackSourceWithOptions[];
26
27// ### Track Source Type Predicates
28export function isSourceWitOptions(source: SourcesArray[number]): source is TrackSourceWithOptions {
29 return typeof source === 'object';
30}
31
32export function isSourcesWithOptions(sources: SourcesArray): sources is TrackSourceWithOptions[] {
33 return (
34 Array.isArray(sources) &&
35 (sources as TrackSourceWithOptions[]).filter(isSourceWitOptions).length > 0
36 );
37}
38
39// ## Loop Filter Types
40export type TrackReferenceFilter = Parameters<TrackReferenceOrPlaceholder[]['filter']>['0'];
41export type ParticipantFilter = Parameters<Participant[]['filter']>['0'];
42
43// ## Other Types
44/** @internal */
45export interface ParticipantClickEvent {
46 participant: Participant;
47 track?: TrackPublication;
48}
49
50export type TrackSource<T extends Track.Source> = RequireAtLeastOne<
51 { source: T; name: string; participant: Participant },
52 'name' | 'source'
53>;
54
55export type ParticipantTrackIdentifier = RequireAtLeastOne<
56 { sources: Track.Source[]; name: string; kind: Track.Kind },
57 'sources' | 'name' | 'kind'
58>;
59
60/**
61 * @beta
62 */
63export type ParticipantIdentifier = RequireAtLeastOne<
64 { kind: ParticipantKind; identity: string },
65 'identity' | 'kind'
66>;
67
68/**
69 * The TrackIdentifier type is used to select Tracks either based on
70 * - Track.Source and/or name of the track, e.g. `{source: Track.Source.Camera}` or `{name: "my-track"}`
71 * - TrackReference (participant and publication)
72 * @internal
73 */
74export type TrackIdentifier<T extends Track.Source = Track.Source> =
75 TrackSource<T> | TrackReference;
76
77// ## Util Types
78type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
79 {
80 [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
81 }[Keys];
82
83export type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
84 {
85 [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
86 }[Keys];
87
88export type AudioSource = Track.Source.Microphone | Track.Source.ScreenShareAudio;
89export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;
Added web/src/shared/livekit/core/utils.ts web/src/shared/livekit/core/utils.ts +74 −0 View web/src/shared/livekit/core/utils.ts
@@ -0,0 +1,74 @@
1import type { Participant, TrackPublication } from 'livekit-client';
2
3import type { TrackReference } from './track-reference';
4import { isEqualTrackRef } from './track-reference';
5import type { PinState } from './types';
6
7export function isLocal(p: Participant) {
8 return p.isLocal;
9}
10
11export function isRemote(p: Participant) {
12 return !p.isLocal;
13}
14
15export const attachIfSubscribed = (
16 publication: TrackPublication | undefined,
17 element: HTMLMediaElement | null | undefined,
18) => {
19 if (!publication) return;
20 const { isSubscribed, track } = publication;
21 if (element && track) {
22 if (isSubscribed) {
23 track.attach(element);
24 } else {
25 track.detach(element);
26 }
27 }
28};
29
30/**
31 * Check if the participant track reference is pinned.
32 */
33export function isParticipantTrackReferencePinned(
34 trackRef: TrackReference,
35 pinState: PinState | undefined,
36): boolean {
37 if (pinState === undefined) {
38 return false;
39 }
40
41 return pinState.some((pinnedTrackRef) => isEqualTrackRef(pinnedTrackRef, trackRef));
42}
43
44/**
45 * Calculates the scrollbar width by creating two HTML elements
46 * and messaging the difference.
47 * @internal
48 */
49export function getScrollBarWidth() {
50 const inner = document.createElement('p');
51 inner.style.width = '100%';
52 inner.style.height = '200px';
53
54 const outer = document.createElement('div');
55 outer.style.position = 'absolute';
56 outer.style.top = '0px';
57 outer.style.left = '0px';
58 outer.style.visibility = 'hidden';
59 outer.style.width = '200px';
60 outer.style.height = '150px';
61 outer.style.overflow = 'hidden';
62 outer.appendChild(inner);
63
64 document.body.appendChild(outer);
65 const w1 = inner.offsetWidth;
66 outer.style.overflow = 'scroll';
67 let w2 = inner.offsetWidth;
68 if (w1 === w2) {
69 w2 = outer.clientWidth;
70 }
71 document.body.removeChild(outer);
72 const scrollBarWidth = w1 - w2;
73 return scrollBarWidth;
74}
Added web/src/shared/livekit/react/assets/icons/CameraDisabledIcon.tsx web/src/shared/livekit/react/assets/icons/CameraDisabledIcon.tsx +14 −0 View web/src/shared/livekit/react/assets/icons/CameraDisabledIcon.tsx
@@ -0,0 +1,14 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgCameraDisabledIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentColor" {...props}>
10 <path d="M1.354.646a.5.5 0 1 0-.708.708l14 14a.5.5 0 0 0 .708-.708L11 10.293V4.5A1.5 1.5 0 0 0 9.5 3H3.707zM0 4.5a1.5 1.5 0 0 1 .943-1.393l9.532 9.533c-.262.224-.603.36-.975.36h-8A1.5 1.5 0 0 1 0 11.5z" />
11 <path d="m15.2 3.6-2.8 2.1a1 1 0 0 0-.4.8v3a1 1 0 0 0 .4.8l2.8 2.1a.5.5 0 0 0 .8-.4V4a.5.5 0 0 0-.8-.4" />
12 </svg>
13);
14export default SvgCameraDisabledIcon;
Added web/src/shared/livekit/react/assets/icons/CameraIcon.tsx web/src/shared/livekit/react/assets/icons/CameraIcon.tsx +13 −0 View web/src/shared/livekit/react/assets/icons/CameraIcon.tsx
@@ -0,0 +1,13 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgCameraIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentColor" {...props}>
10 <path d="M0 4.5A1.5 1.5 0 0 1 1.5 3h8A1.5 1.5 0 0 1 11 4.5v7A1.5 1.5 0 0 1 9.5 13h-8A1.5 1.5 0 0 1 0 11.5zM15.2 3.6l-2.8 2.1a1 1 0 0 0-.4.8v3a1 1 0 0 0 .4.8l2.8 2.1a.5.5 0 0 0 .8-.4V4a.5.5 0 0 0-.8-.4" />
11 </svg>
12);
13export default SvgCameraIcon;
Added web/src/shared/livekit/react/assets/icons/ChatCloseIcon.tsx web/src/shared/livekit/react/assets/icons/ChatCloseIcon.tsx +16 −0 View web/src/shared/livekit/react/assets/icons/ChatCloseIcon.tsx
@@ -0,0 +1,16 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgChatCloseIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} viewBox="0 0 24 24" {...props}>
10 <path
11 fill="#FFF"
12 d="M4.99 3.99a1 1 0 0 0-.697 1.717L10.586 12l-6.293 6.293a1 1 0 1 0 1.414 1.414L12 13.414l6.293 6.293a1 1 0 1 0 1.414-1.414L13.414 12l6.293-6.293a1 1 0 0 0-.727-1.717 1 1 0 0 0-.687.303L12 10.586 5.707 4.293a1 1 0 0 0-.717-.303"
13 />
14 </svg>
15);
16export default SvgChatCloseIcon;
Added web/src/shared/livekit/react/assets/icons/ChatIcon.tsx web/src/shared/livekit/react/assets/icons/ChatIcon.tsx +24 −0 View web/src/shared/livekit/react/assets/icons/ChatIcon.tsx
@@ -0,0 +1,24 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgChatIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={18} fill="none" {...props}>
10 <path
11 fill="currentColor"
12 fillRule="evenodd"
13 d="M0 2.75A2.75 2.75 0 0 1 2.75 0h10.5A2.75 2.75 0 0 1 16 2.75v13.594a.75.75 0 0 1-1.234.572l-3.691-3.12a1.25 1.25 0 0 0-.807-.296H2.75A2.75 2.75 0 0 1 0 10.75zM2.75 1.5c-.69 0-1.25.56-1.25 1.25v8c0 .69.56 1.25 1.25 1.25h7.518c.65 0 1.279.23 1.775.65l2.457 2.077V2.75c0-.69-.56-1.25-1.25-1.25z"
14 clipRule="evenodd"
15 />
16 <path
17 fill="currentColor"
18 fillRule="evenodd"
19 d="M3 4.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5"
20 clipRule="evenodd"
21 />
22 </svg>
23);
24export default SvgChatIcon;
Added web/src/shared/livekit/react/assets/icons/Chevron.tsx web/src/shared/livekit/react/assets/icons/Chevron.tsx +18 −0 View web/src/shared/livekit/react/assets/icons/Chevron.tsx
@@ -0,0 +1,18 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgChevron = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <path
11 fill="currentcolor"
12 fillRule="evenodd"
13 d="M5.293 2.293a1 1 0 0 1 1.414 0l4.823 4.823a1.25 1.25 0 0 1 0 1.768l-4.823 4.823a1 1 0 0 1-1.414-1.414L9.586 8 5.293 3.707a1 1 0 0 1 0-1.414"
14 clipRule="evenodd"
15 />
16 </svg>
17);
18export default SvgChevron;
Added web/src/shared/livekit/react/assets/icons/FocusToggleIcon.tsx web/src/shared/livekit/react/assets/icons/FocusToggleIcon.tsx +15 −0 View web/src/shared/livekit/react/assets/icons/FocusToggleIcon.tsx
@@ -0,0 +1,15 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgFocusToggleIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}>
11 <path d="M10 1.75h4.25m0 0V6m0-4.25L9 7M6 14.25H1.75m0 0V10m0 4.25L7 9" />
12 </g>
13 </svg>
14);
15export default SvgFocusToggleIcon;
Added web/src/shared/livekit/react/assets/icons/GearIcon.tsx web/src/shared/livekit/react/assets/icons/GearIcon.tsx +18 −0 View web/src/shared/livekit/react/assets/icons/GearIcon.tsx
@@ -0,0 +1,18 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgGearIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <path
11 fill="currentcolor"
12 fillRule="evenodd"
13 d="M8.961.894C8.875-.298 7.125-.298 7.04.894c-.066.912-1.246 1.228-1.76.472-.67-.99-2.186-.115-1.664.96.399.824-.465 1.688-1.288 1.289-1.076-.522-1.95.994-.961 1.665.756.513.44 1.693-.472 1.759-1.192.086-1.192 1.836 0 1.922.912.066 1.228 1.246.472 1.76-.99.67-.115 2.186.96 1.664.824-.399 1.688.465 1.289 1.288-.522 1.076.994 1.95 1.665.961.513-.756 1.693-.44 1.759.472.086 1.192 1.836 1.192 1.922 0 .066-.912 1.246-1.228 1.76-.472.67.99 2.186.115 1.664-.96-.399-.824.465-1.687 1.288-1.289 1.076.522 1.95-.994.961-1.665-.756-.513-.44-1.693.472-1.759 1.192-.086 1.192-1.836 0-1.922-.912-.066-1.228-1.246-.472-1.76.99-.67.115-2.186-.96-1.664-.824.399-1.687-.465-1.289-1.288.522-1.076-.994-1.95-1.665-.961-.513.756-1.693.44-1.759-.472M8 13A5 5 0 1 0 8 3a5 5 0 0 0 0 10"
14 clipRule="evenodd"
15 />
16 </svg>
17);
18export default SvgGearIcon;
Added web/src/shared/livekit/react/assets/icons/index.ts web/src/shared/livekit/react/assets/icons/index.ts +19 −0 View web/src/shared/livekit/react/assets/icons/index.ts
@@ -0,0 +1,19 @@
1export { default as CameraDisabledIcon } from './CameraDisabledIcon';
2export { default as CameraIcon } from './CameraIcon';
3export { default as ChatCloseIcon } from './ChatCloseIcon';
4export { default as ChatIcon } from './ChatIcon';
5export { default as Chevron } from './Chevron';
6export { default as FocusToggleIcon } from './FocusToggleIcon';
7export { default as GearIcon } from './GearIcon';
8export { default as LeaveIcon } from './LeaveIcon';
9export { default as LockLockedIcon } from './LockLockedIcon';
10export { default as MicDisabledIcon } from './MicDisabledIcon';
11export { default as MicIcon } from './MicIcon';
12export { default as QualityExcellentIcon } from './QualityExcellentIcon';
13export { default as QualityGoodIcon } from './QualityGoodIcon';
14export { default as QualityPoorIcon } from './QualityPoorIcon';
15export { default as QualityUnknownIcon } from './QualityUnknownIcon';
16export { default as ScreenShareIcon } from './ScreenShareIcon';
17export { default as ScreenShareStopIcon } from './ScreenShareStopIcon';
18export { default as SpinnerIcon } from './SpinnerIcon';
19export { default as UnfocusToggleIcon } from './UnfocusToggleIcon';
Added web/src/shared/livekit/react/assets/icons/LeaveIcon.tsx web/src/shared/livekit/react/assets/icons/LeaveIcon.tsx +24 −0 View web/src/shared/livekit/react/assets/icons/LeaveIcon.tsx
@@ -0,0 +1,24 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgLeaveIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <path
11 fill="currentColor"
12 fillRule="evenodd"
13 d="M2 2.75A2.75 2.75 0 0 1 4.75 0h6.5A2.75 2.75 0 0 1 14 2.75v10.5A2.75 2.75 0 0 1 11.25 16h-6.5A2.75 2.75 0 0 1 2 13.25v-.5a.75.75 0 0 1 1.5 0v.5c0 .69.56 1.25 1.25 1.25h6.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25h-6.5c-.69 0-1.25.56-1.25 1.25v.5a.75.75 0 0 1-1.5 0z"
14 clipRule="evenodd"
15 />
16 <path
17 fill="currentColor"
18 fillRule="evenodd"
19 d="M8.78 7.47a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 1 1-1.06-1.06l.97-.97H1.75a.75.75 0 0 1 0-1.5h4.69l-.97-.97a.75.75 0 0 1 1.06-1.06z"
20 clipRule="evenodd"
21 />
22 </svg>
23);
24export default SvgLeaveIcon;
Added web/src/shared/livekit/react/assets/icons/LockLockedIcon.tsx web/src/shared/livekit/react/assets/icons/LockLockedIcon.tsx +18 −0 View web/src/shared/livekit/react/assets/icons/LockLockedIcon.tsx
@@ -0,0 +1,18 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgLockLockedIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <path
11 fill="currentcolor"
12 fillRule="evenodd"
13 d="M4 6.104V4a4 4 0 1 1 8 0v2.104c1.154.326 2 1.387 2 2.646v4.5A2.75 2.75 0 0 1 11.25 16h-6.5A2.75 2.75 0 0 1 2 13.25v-4.5c0-1.259.846-2.32 2-2.646M5.5 4a2.5 2.5 0 0 1 5 0v2h-5z"
14 clipRule="evenodd"
15 />
16 </svg>
17);
18export default SvgLockLockedIcon;
Added web/src/shared/livekit/react/assets/icons/MicDisabledIcon.tsx web/src/shared/livekit/react/assets/icons/MicDisabledIcon.tsx +14 −0 View web/src/shared/livekit/react/assets/icons/MicDisabledIcon.tsx
@@ -0,0 +1,14 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgMicDisabledIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentColor" {...props}>
10 <path d="M12.227 11.52a5.48 5.48 0 0 0 1.246-2.97.5.5 0 0 0-.995-.1 4.5 4.5 0 0 1-.962 2.359l-1.07-1.07C10.794 9.247 11 8.647 11 8V3a3 3 0 0 0-6 0v1.293L1.354.646a.5.5 0 1 0-.708.708l14 14a.5.5 0 0 0 .708-.708zM8 12.5c.683 0 1.33-.152 1.911-.425l.743.743c-.649.359-1.378.59-2.154.66V15h2a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1h2v-1.522a5.5 5.5 0 0 1-4.973-4.929.5.5 0 0 1 .995-.098A4.5 4.5 0 0 0 8 12.5" />
11 <path d="M8.743 10.907 5 7.164V8a3 3 0 0 0 3.743 2.907" />
12 </svg>
13);
14export default SvgMicDisabledIcon;
Added web/src/shared/livekit/react/assets/icons/MicIcon.tsx web/src/shared/livekit/react/assets/icons/MicIcon.tsx +18 −0 View web/src/shared/livekit/react/assets/icons/MicIcon.tsx
@@ -0,0 +1,18 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgMicIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentColor" {...props}>
10 <path
11 fillRule="evenodd"
12 d="M2.975 8.002a.5.5 0 0 1 .547.449 4.5 4.5 0 0 0 8.956 0 .5.5 0 1 1 .995.098A5.5 5.5 0 0 1 8.5 13.478V15h2a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1h2v-1.522a5.5 5.5 0 0 1-4.973-4.929.5.5 0 0 1 .448-.547"
13 clipRule="evenodd"
14 />
15 <path d="M5 3a3 3 0 1 1 6 0v5a3 3 0 0 1-6 0z" />
16 </svg>
17);
18export default SvgMicIcon;
Added web/src/shared/livekit/react/assets/icons/QualityExcellentIcon.tsx web/src/shared/livekit/react/assets/icons/QualityExcellentIcon.tsx +14 −0 View web/src/shared/livekit/react/assets/icons/QualityExcellentIcon.tsx
@@ -0,0 +1,14 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgQualityExcellentIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentcolor" {...props}>
10 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-6a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
11 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-6a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
12 </svg>
13);
14export default SvgQualityExcellentIcon;
Added web/src/shared/livekit/react/assets/icons/QualityGoodIcon.tsx web/src/shared/livekit/react/assets/icons/QualityGoodIcon.tsx +18 −0 View web/src/shared/livekit/react/assets/icons/QualityGoodIcon.tsx
@@ -0,0 +1,18 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgQualityGoodIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentcolor" {...props}>
10 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
11 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
12 <g opacity={0.25}>
13 <path d="M12 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
14 <path d="M12 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
15 </g>
16 </svg>
17);
18export default SvgQualityGoodIcon;
Added web/src/shared/livekit/react/assets/icons/QualityPoorIcon.tsx web/src/shared/livekit/react/assets/icons/QualityPoorIcon.tsx +19 −0 View web/src/shared/livekit/react/assets/icons/QualityPoorIcon.tsx
@@ -0,0 +1,19 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgQualityPoorIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentcolor" {...props}>
10 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
11 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
12 <g opacity={0.25}>
13 <path d="M6 6.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
14 <path d="M6 6.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-6a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
15 <path d="M12 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
16 </g>
17 </svg>
18);
19export default SvgQualityPoorIcon;
Added web/src/shared/livekit/react/assets/icons/QualityUnknownIcon.tsx web/src/shared/livekit/react/assets/icons/QualityUnknownIcon.tsx +16 −0 View web/src/shared/livekit/react/assets/icons/QualityUnknownIcon.tsx
@@ -0,0 +1,16 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgQualityUnknownIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="currentColor" {...props}>
10 <g opacity={0.25}>
11 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-6a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
12 <path d="M0 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5zm6-6a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5z" />
13 </g>
14 </svg>
15);
16export default SvgQualityUnknownIcon;
Added web/src/shared/livekit/react/assets/icons/ScreenShareIcon.tsx web/src/shared/livekit/react/assets/icons/ScreenShareIcon.tsx +24 −0 View web/src/shared/livekit/react/assets/icons/ScreenShareIcon.tsx
@@ -0,0 +1,24 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgScreenShareIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={20} height={16} fill="none" {...props}>
10 <path
11 fill="currentColor"
12 fillRule="evenodd"
13 d="M0 2.75A2.75 2.75 0 0 1 2.75 0h14.5A2.75 2.75 0 0 1 20 2.75v10.5A2.75 2.75 0 0 1 17.25 16H2.75A2.75 2.75 0 0 1 0 13.25zM2.75 1.5c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25z"
14 clipRule="evenodd"
15 />
16 <path
17 fill="currentColor"
18 fillRule="evenodd"
19 d="M9.47 4.22a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1-1.06 1.06l-.97-.97v4.69a.75.75 0 0 1-1.5 0V6.56l-.97.97a.75.75 0 0 1-1.06-1.06z"
20 clipRule="evenodd"
21 />
22 </svg>
23);
24export default SvgScreenShareIcon;
Added web/src/shared/livekit/react/assets/icons/ScreenShareStopIcon.tsx web/src/shared/livekit/react/assets/icons/ScreenShareStopIcon.tsx +20 −0 View web/src/shared/livekit/react/assets/icons/ScreenShareStopIcon.tsx
@@ -0,0 +1,20 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgScreenShareStopIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={20} height={16} fill="none" {...props}>
10 <g fill="currentColor">
11 <path d="M7.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L10 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L11.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L10 6.94z" />
12 <path
13 fillRule="evenodd"
14 d="M2.75 0A2.75 2.75 0 0 0 0 2.75v10.5A2.75 2.75 0 0 0 2.75 16h14.5A2.75 2.75 0 0 0 20 13.25V2.75A2.75 2.75 0 0 0 17.25 0zM1.5 2.75c0-.69.56-1.25 1.25-1.25h14.5c.69 0 1.25.56 1.25 1.25v10.5c0 .69-.56 1.25-1.25 1.25H2.75c-.69 0-1.25-.56-1.25-1.25z"
15 clipRule="evenodd"
16 />
17 </g>
18 </svg>
19);
20export default SvgScreenShareStopIcon;
Added web/src/shared/livekit/react/assets/icons/SpinnerIcon.tsx web/src/shared/livekit/react/assets/icons/SpinnerIcon.tsx +92 −0 View web/src/shared/livekit/react/assets/icons/SpinnerIcon.tsx
@@ -0,0 +1,92 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgSpinnerIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <path
11 fill="currentColor"
12 fillRule="evenodd"
13 d="M8 0a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0"
14 clipRule="evenodd"
15 />
16 <path
17 fill="currentColor"
18 fillRule="evenodd"
19 d="M8 12a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 8 12"
20 clipRule="evenodd"
21 opacity={0.7}
22 />
23 <path
24 fill="currentColor"
25 fillRule="evenodd"
26 d="M12 1.072a.75.75 0 0 1 .274 1.024l-1.25 2.165a.75.75 0 0 1-1.299-.75l1.25-2.165A.75.75 0 0 1 12 1.072"
27 clipRule="evenodd"
28 />
29 <path
30 fill="currentColor"
31 fillRule="evenodd"
32 d="M6 11.464a.75.75 0 0 1 .274 1.025l-1.25 2.165a.75.75 0 0 1-1.299-.75l1.25-2.165A.75.75 0 0 1 6 11.464"
33 clipRule="evenodd"
34 opacity={0.6}
35 />
36 <path
37 fill="currentColor"
38 fillRule="evenodd"
39 d="M14.928 4a.75.75 0 0 1-.274 1.025l-2.165 1.25a.75.75 0 1 1-.75-1.3l2.165-1.25A.75.75 0 0 1 14.928 4"
40 clipRule="evenodd"
41 />
42 <path
43 fill="currentColor"
44 fillRule="evenodd"
45 d="M4.536 10a.75.75 0 0 1-.275 1.024l-2.165 1.25a.75.75 0 0 1-.75-1.298l2.165-1.25A.75.75 0 0 1 4.536 10"
46 clipRule="evenodd"
47 opacity={0.5}
48 />
49 <path
50 fill="currentColor"
51 fillRule="evenodd"
52 d="M16 8a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h2.5A.75.75 0 0 1 16 8"
53 clipRule="evenodd"
54 />
55 <path
56 fill="currentColor"
57 fillRule="evenodd"
58 d="M4 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h2.5A.75.75 0 0 1 4 8"
59 clipRule="evenodd"
60 opacity={0.4}
61 />
62 <path
63 fill="currentColor"
64 fillRule="evenodd"
65 d="M14.928 12a.75.75 0 0 1-1.024.274l-2.165-1.25a.75.75 0 0 1 .75-1.299l2.165 1.25A.75.75 0 0 1 14.928 12"
66 clipRule="evenodd"
67 opacity={0.9}
68 />
69 <path
70 fill="currentColor"
71 fillRule="evenodd"
72 d="M4.536 6a.75.75 0 0 1-1.025.275l-2.165-1.25a.75.75 0 1 1 .75-1.3l2.165 1.25A.75.75 0 0 1 4.536 6"
73 clipRule="evenodd"
74 opacity={0.3}
75 />
76 <path
77 fill="currentColor"
78 fillRule="evenodd"
79 d="M12 14.928a.75.75 0 0 1-1.024-.274l-1.25-2.165a.75.75 0 0 1 1.298-.75l1.25 2.165A.75.75 0 0 1 12 14.928"
80 clipRule="evenodd"
81 opacity={0.8}
82 />
83 <path
84 fill="currentColor"
85 fillRule="evenodd"
86 d="M6 4.536a.75.75 0 0 1-1.024-.275l-1.25-2.165a.75.75 0 1 1 1.299-.75l1.25 2.165A.75.75 0 0 1 6 4.536"
87 clipRule="evenodd"
88 opacity={0.2}
89 />
90 </svg>
91);
92export default SvgSpinnerIcon;
Added web/src/shared/livekit/react/assets/icons/UnfocusToggleIcon.tsx web/src/shared/livekit/react/assets/icons/UnfocusToggleIcon.tsx +15 −0 View web/src/shared/livekit/react/assets/icons/UnfocusToggleIcon.tsx
@@ -0,0 +1,15 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgUnfocusToggleIcon = (props: SVGProps<SVGSVGElement>) => (
9 <svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} fill="none" {...props}>
10 <g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}>
11 <path d="M13.25 7H9m0 0V2.75M9 7l5.25-5.25M2.75 9H7m0 0v4.25M7 9l-5.25 5.25" />
12 </g>
13 </svg>
14);
15export default SvgUnfocusToggleIcon;
Added web/src/shared/livekit/react/assets/icons/util.tsx web/src/shared/livekit/react/assets/icons/util.tsx +46 −0 View web/src/shared/livekit/react/assets/icons/util.tsx
@@ -0,0 +1,46 @@
1import { ConnectionQuality, Track } from 'livekit-client';
2
3import {
4 MicIcon,
5 MicDisabledIcon,
6 CameraIcon,
7 CameraDisabledIcon,
8 QualityUnknownIcon,
9 QualityExcellentIcon,
10 QualityGoodIcon,
11 QualityPoorIcon,
12 ScreenShareIcon,
13 ScreenShareStopIcon,
14} from './index';
15
16/**
17 * @internal
18 */
19export function getSourceIcon(source: Track.Source, enabled: boolean) {
20 switch (source) {
21 case Track.Source.Microphone:
22 return enabled ? <MicIcon /> : <MicDisabledIcon />;
23 case Track.Source.Camera:
24 return enabled ? <CameraIcon /> : <CameraDisabledIcon />;
25 case Track.Source.ScreenShare:
26 return enabled ? <ScreenShareStopIcon /> : <ScreenShareIcon />;
27 default:
28 return undefined;
29 }
30}
31
32/**
33 * @internal
34 */
35export function getConnectionQualityIcon(quality: ConnectionQuality) {
36 switch (quality) {
37 case ConnectionQuality.Excellent:
38 return <QualityExcellentIcon />;
39 case ConnectionQuality.Good:
40 return <QualityGoodIcon />;
41 case ConnectionQuality.Poor:
42 return <QualityPoorIcon />;
43 default:
44 return <QualityUnknownIcon />;
45 }
46}
Added web/src/shared/livekit/react/assets/images/index.ts web/src/shared/livekit/react/assets/images/index.ts +1 −0 View web/src/shared/livekit/react/assets/images/index.ts
@@ -0,0 +1 @@
1export { default as ParticipantPlaceholder } from './ParticipantPlaceholder';
Added web/src/shared/livekit/react/assets/images/ParticipantPlaceholder.tsx web/src/shared/livekit/react/assets/images/ParticipantPlaceholder.tsx +30 −0 View web/src/shared/livekit/react/assets/images/ParticipantPlaceholder.tsx
@@ -0,0 +1,30 @@
1/**
2 * WARNING: This file was auto-generated by svgr. Do not edit.
3 */
4import type { SVGProps } from 'react';
5/**
6 * @internal
7 */
8const SvgParticipantPlaceholder = (props: SVGProps<SVGSVGElement>) => (
9 <svg
10 width={320}
11 height={320}
12 viewBox="0 0 320 320"
13 preserveAspectRatio="xMidYMid meet"
14 fill="none"
15 xmlns="http://www.w3.org/2000/svg"
16 {...props}
17 >
18 <path
19 d="M160 180C204.182 180 240 144.183 240 100C240 55.8172 204.182 20 160 20C115.817 20 79.9997 55.8172 79.9997 100C79.9997 144.183 115.817 180 160 180Z"
20 fill="white"
21 fillOpacity={0.25}
22 />
23 <path
24 d="M97.6542 194.614C103.267 191.818 109.841 192.481 115.519 195.141C129.025 201.466 144.1 205 159.999 205C175.899 205 190.973 201.466 204.48 195.141C210.158 192.481 216.732 191.818 222.345 194.614C262.703 214.719 291.985 253.736 298.591 300.062C300.15 310.997 291.045 320 280 320H39.9997C28.954 320 19.8495 310.997 21.4087 300.062C28.014 253.736 57.2966 214.72 97.6542 194.614Z"
25 fill="white"
26 fillOpacity={0.25}
27 />
28 </svg>
29);
30export default SvgParticipantPlaceholder;
Added web/src/shared/livekit/react/components/controls/ChatToggle.tsx web/src/shared/livekit/react/components/controls/ChatToggle.tsx +32 −0 View web/src/shared/livekit/react/components/controls/ChatToggle.tsx
@@ -0,0 +1,32 @@
1import * as React from 'react';
2import { useChatToggle } from '../../hooks';
3
4/** @public */
5export interface ChatToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
6
7/**
8 * The `ChatToggle` component is a button that toggles the visibility of the `Chat` component.
9 * @remarks
10 * For the component to have any effect it has to live inside a `LayoutContext` context.
11 *
12 * @example
13 * ```tsx
14 * <LiveKitRoom>
15 * <ChatToggle />
16 * </LiveKitRoom>
17 * ```
18 * @public
19 */
20export const ChatToggle: (
21 props: ChatToggleProps & React.RefAttributes<HTMLButtonElement>,
22) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, ChatToggleProps>(
23 function ChatToggle(props: ChatToggleProps, ref) {
24 const { mergedProps } = useChatToggle({ props });
25
26 return (
27 <button ref={ref} {...mergedProps}>
28 {props.children}
29 </button>
30 );
31 },
32);
Added web/src/shared/livekit/react/components/controls/ClearPinButton.tsx web/src/shared/livekit/react/components/controls/ClearPinButton.tsx +32 −0 View web/src/shared/livekit/react/components/controls/ClearPinButton.tsx
@@ -0,0 +1,32 @@
1import * as React from 'react';
2import { useClearPinButton } from '../../hooks';
3
4/** @public */
5export interface ClearPinButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
6
7/**
8 * The `ClearPinButton` is a basic html button with the added ability to signal
9 * the `LayoutContext` that it should display the grid view again.
10 * @remarks
11 * This component works only inside a `LayoutContext`.
12 *
13 * @example
14 * ```tsx
15 * <LiveKitRoom>
16 * <ClearPinButton>Back to grid view</ClearPinButton>
17 * </LiveKitRoom>
18 * ```
19 * @public
20 */
21export const ClearPinButton: (
22 props: ClearPinButtonProps & React.RefAttributes<HTMLButtonElement>,
23) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, ClearPinButtonProps>(
24 function ClearPinButton(props: ClearPinButtonProps, ref) {
25 const { buttonProps } = useClearPinButton(props);
26 return (
27 <button ref={ref} {...buttonProps}>
28 {props.children}
29 </button>
30 );
31 },
32);
Added web/src/shared/livekit/react/components/controls/DisconnectButton.tsx web/src/shared/livekit/react/components/controls/DisconnectButton.tsx +32 −0 View web/src/shared/livekit/react/components/controls/DisconnectButton.tsx
@@ -0,0 +1,32 @@
1import * as React from 'react';
2import { useDisconnectButton } from '../../hooks';
3
4/** @public */
5export interface DisconnectButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
6 stopTracks?: boolean;
7}
8
9/**
10 * The `DisconnectButton` is a basic html button with the added ability to disconnect from a LiveKit room.
11 * Normally this is the big red button that allows end users to leave the video or audio call.
12 *
13 * @example
14 * ```tsx
15 * <LiveKitRoom>
16 * <DisconnectButton>Leave room</DisconnectButton>
17 * </LiveKitRoom>
18 * ```
19 * @public
20 */
21export const DisconnectButton: (
22 props: DisconnectButtonProps & React.RefAttributes<HTMLButtonElement>,
23) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, DisconnectButtonProps>(
24 function DisconnectButton(props: DisconnectButtonProps, ref) {
25 const { buttonProps } = useDisconnectButton(props);
26 return (
27 <button ref={ref} {...buttonProps}>
28 {props.children}
29 </button>
30 );
31 },
32);
Added web/src/shared/livekit/react/components/controls/FocusToggle.tsx web/src/shared/livekit/react/components/controls/FocusToggle.tsx +54 −0 View web/src/shared/livekit/react/components/controls/FocusToggle.tsx
@@ -0,0 +1,54 @@
1import * as React from 'react';
2import { LayoutContext, useMaybeTrackRefContext } from '../../context';
3import { FocusToggleIcon, UnfocusToggleIcon } from '../../assets/icons';
4import { useFocusToggle } from '../../hooks';
5import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
6
7/** @public */
8export interface FocusToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9 trackRef?: TrackReferenceOrPlaceholder;
10}
11
12/**
13 * The `FocusToggle` puts the `ParticipantTile` in focus or removes it from focus.
14 * @remarks
15 * This component needs to live inside `LayoutContext` to work properly.
16 *
17 * @example
18 * ```tsx
19 * <ParticipantTile>
20 * <FocusToggle />
21 * </ParticipantTile>
22 * ```
23 * @public
24 */
25export const FocusToggle: (
26 props: FocusToggleProps & React.RefAttributes<HTMLButtonElement>,
27) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, FocusToggleProps>(
28 function FocusToggle({ trackRef, ...props }: FocusToggleProps, ref) {
29 const trackRefFromContext = useMaybeTrackRefContext();
30
31 const { mergedProps, inFocus } = useFocusToggle({
32 trackRef: trackRef ?? trackRefFromContext,
33 props,
34 });
35
36 return (
37 <LayoutContext.Consumer>
38 {(layoutContext) =>
39 layoutContext !== undefined && (
40 <button ref={ref} {...mergedProps}>
41 {props.children ? (
42 props.children
43 ) : inFocus ? (
44 <UnfocusToggleIcon />
45 ) : (
46 <FocusToggleIcon />
47 )}
48 </button>
49 )
50 }
51 </LayoutContext.Consumer>
52 );
53 },
54);
Added web/src/shared/livekit/react/components/controls/MediaDeviceSelect.tsx web/src/shared/livekit/react/components/controls/MediaDeviceSelect.tsx +146 −0 View web/src/shared/livekit/react/components/controls/MediaDeviceSelect.tsx
@@ -0,0 +1,146 @@
1import * as React from 'react';
2import { useMaybeRoomContext } from '../../context';
3import { mergeProps } from '../../utils';
4import { RoomEvent, type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client';
5import { useMediaDeviceSelect } from '../../hooks';
6
7/** @public */
8export interface MediaDeviceSelectProps extends Omit<
9 React.HTMLAttributes<HTMLUListElement>,
10 'onError'
11> {
12 kind: MediaDeviceKind;
13 onActiveDeviceChange?: (deviceId: string) => void;
14 onDeviceListChange?: (devices: MediaDeviceInfo[]) => void;
15 onDeviceSelectError?: (e: Error) => void;
16 initialSelection?: string;
17 /** will force the browser to only return the specified device
18 * will call `onDeviceSelectError` with the error in case this fails
19 */
20 exactMatch?: boolean;
21 track?: LocalAudioTrack | LocalVideoTrack;
22 /**
23 * this will call getUserMedia if the permissions are not yet given to enumerate the devices with device labels.
24 * in some browsers multiple calls to getUserMedia result in multiple permission prompts.
25 * It's generally advised only flip this to true, once a (preview) track has been acquired successfully with the
26 * appropriate permissions.
27 *
28 * @see {@link MediaDeviceMenu}
29 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices | MDN enumerateDevices}
30 */
31 requestPermissions?: boolean;
32 onError?: (e: Error) => void;
33}
34
35/**
36 * The `MediaDeviceSelect` list all media devices of one kind.
37 * Clicking on one of the listed devices make it the active media device.
38 *
39 * @example
40 * ```tsx
41 * <LiveKitRoom>
42 * <MediaDeviceSelect kind='audioinput' />
43 * </LiveKitRoom>
44 * ```
45 * @public
46 */
47export const MediaDeviceSelect: (
48 props: MediaDeviceSelectProps & React.RefAttributes<HTMLUListElement>,
49) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLUListElement, MediaDeviceSelectProps>(
50 function MediaDeviceSelect(
51 {
52 kind,
53 initialSelection,
54 onActiveDeviceChange,
55 onDeviceListChange,
56 onDeviceSelectError,
57 exactMatch,
58 track,
59 requestPermissions,
60 onError,
61 ...props
62 }: MediaDeviceSelectProps,
63 ref,
64 ) {
65 const room = useMaybeRoomContext();
66 const previousActiveDeviceId = React.useRef<string>('default');
67 const handleError = React.useCallback(
68 (e: Error) => {
69 if (room) {
70 // awkwardly emit the event from outside of the room, as we don't have other means to raise a MediaDeviceError
71 room.emit(RoomEvent.MediaDevicesError, e);
72 }
73 onError?.(e);
74 },
75 [room, onError],
76 );
77 const { devices, activeDeviceId, setActiveMediaDevice, className } = useMediaDeviceSelect({
78 kind,
79 room,
80 track,
81 requestPermissions,
82 onError: handleError,
83 });
84 React.useEffect(() => {
85 if (initialSelection !== undefined) {
86 setActiveMediaDevice(initialSelection);
87 }
88 }, [setActiveMediaDevice]);
89
90 React.useEffect(() => {
91 if (typeof onDeviceListChange === 'function') {
92 onDeviceListChange(devices);
93 }
94 }, [onDeviceListChange, devices]);
95
96 React.useEffect(() => {
97 if (activeDeviceId !== previousActiveDeviceId.current) {
98 onActiveDeviceChange?.(activeDeviceId);
99 }
100 previousActiveDeviceId.current = activeDeviceId;
101 }, [activeDeviceId]);
102
103 const handleActiveDeviceChange = async (deviceId: string) => {
104 try {
105 await setActiveMediaDevice(deviceId, { exact: exactMatch ?? true });
106 } catch (e) {
107 if (e instanceof Error) {
108 onDeviceSelectError?.(e);
109 } else {
110 throw e;
111 }
112 }
113 };
114 // Merge Props
115 const mergedProps = React.useMemo(
116 () => mergeProps(props, { className }, { className: 'lk-list' }),
117 [className, props],
118 );
119
120 const hasDefault = !!devices.find((info) => info.label.toLowerCase().startsWith('default'));
121
122 function isActive(deviceId: string, activeDeviceId: string, index: number) {
123 return (
124 deviceId === activeDeviceId || (!hasDefault && index === 0 && activeDeviceId === 'default')
125 );
126 }
127
128 return (
129 <ul ref={ref} {...mergedProps}>
130 {devices.map((device, index) => (
131 <li
132 key={device.deviceId}
133 id={device.deviceId}
134 data-lk-active={isActive(device.deviceId, activeDeviceId, index)}
135 aria-selected={isActive(device.deviceId, activeDeviceId, index)}
136 role="option"
137 >
138 <button className="lk-button" onClick={() => handleActiveDeviceChange(device.deviceId)}>
139 {device.label}
140 </button>
141 </li>
142 ))}
143 </ul>
144 );
145 },
146);
Added web/src/shared/livekit/react/components/controls/PaginationControl.tsx web/src/shared/livekit/react/components/controls/PaginationControl.tsx +49 −0 View web/src/shared/livekit/react/components/controls/PaginationControl.tsx
@@ -0,0 +1,49 @@
1import * as React from 'react';
2import SvgChevron from '../../assets/icons/Chevron';
3import type { usePagination } from '../../hooks';
4import { createInteractingObservable } from '@livekit/components-core';
5
6export interface PaginationControlProps extends Pick<
7 ReturnType<typeof usePagination>,
8 'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage'
9> {
10 /** Reference to an HTML element that holds the pages, while interacting (`mouseover`)
11 * with it, the pagination controls will appear for a while. */
12 pagesContainer?: React.RefObject<HTMLElement | null>;
13}
14
15export function PaginationControl({
16 totalPageCount,
17 nextPage,
18 prevPage,
19 currentPage,
20 pagesContainer: connectedElement,
21}: PaginationControlProps) {
22 const [interactive, setInteractive] = React.useState(false);
23 React.useEffect(() => {
24 let subscription:
25 ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']> | undefined;
26 if (connectedElement) {
27 subscription = createInteractingObservable(connectedElement.current, 2000).subscribe(
28 setInteractive,
29 );
30 }
31 return () => {
32 if (subscription) {
33 subscription.unsubscribe();
34 }
35 };
36 }, [connectedElement]);
37
38 return (
39 <div className="lk-pagination-control" data-lk-user-interaction={interactive}>
40 <button className="lk-button" onClick={prevPage}>
41 <SvgChevron />
42 </button>
43 <span className="lk-pagination-count">{`${currentPage} of ${totalPageCount}`}</span>
44 <button className="lk-button" onClick={nextPage}>
45 <SvgChevron />
46 </button>
47 </div>
48 );
49}
Added web/src/shared/livekit/react/components/controls/PaginationIndicator.tsx web/src/shared/livekit/react/components/controls/PaginationIndicator.tsx +26 −0 View web/src/shared/livekit/react/components/controls/PaginationIndicator.tsx
@@ -0,0 +1,26 @@
1import * as React from 'react';
2
3export interface PaginationIndicatorProps {
4 totalPageCount: number;
5 currentPage: number;
6}
7
8export const PaginationIndicator: (
9 props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>,
10) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLDivElement, PaginationIndicatorProps>(
11 function PaginationIndicator({ totalPageCount, currentPage }: PaginationIndicatorProps, ref) {
12 const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
13 if (index + 1 === currentPage) {
14 return <span data-lk-active key={index} />;
15 } else {
16 return <span key={index} />;
17 }
18 });
19
20 return (
21 <div ref={ref} className="lk-pagination-indicator">
22 {bubbles}
23 </div>
24 );
25 },
26);
Added web/src/shared/livekit/react/components/controls/SettingsMenuToggle.tsx web/src/shared/livekit/react/components/controls/SettingsMenuToggle.tsx +26 −0 View web/src/shared/livekit/react/components/controls/SettingsMenuToggle.tsx
@@ -0,0 +1,26 @@
1import * as React from 'react';
2import { useSettingsToggle } from '../../hooks/useSettingsToggle';
3
4/** @alpha */
5export interface SettingsMenuToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
6
7/**
8 * The `SettingsMenuToggle` component is a button that toggles the visibility of the `SettingsMenu` component.
9 * @remarks
10 * For the component to have any effect it has to live inside a `LayoutContext` context.
11 *
12 * @alpha
13 */
14export const SettingsMenuToggle: (
15 props: SettingsMenuToggleProps & React.RefAttributes<HTMLButtonElement>,
16) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, SettingsMenuToggleProps>(
17 function SettingsMenuToggle(props: SettingsMenuToggleProps, ref) {
18 const { mergedProps } = useSettingsToggle({ props });
19
20 return (
21 <button ref={ref} {...mergedProps}>
22 {props.children}
23 </button>
24 );
25 },
26);
Added web/src/shared/livekit/react/components/controls/StartAudio.tsx web/src/shared/livekit/react/components/controls/StartAudio.tsx +41 −0 View web/src/shared/livekit/react/components/controls/StartAudio.tsx
@@ -0,0 +1,41 @@
1import * as React from 'react';
2import { useEnsureRoom } from '../../context';
3import { useStartAudio } from '../../hooks';
4import { Room } from 'livekit-client';
5import { lkT } from '@/shared/livekit/lk-i18n';
6
7/** @public */
8export interface AllowAudioPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9 room?: Room;
10 label: string;
11}
12
13/**
14 * The `StartAudio` component is only visible when the browser blocks audio playback. This is due to some browser implemented autoplay policies.
15 * To start audio playback, the user must perform a user-initiated event such as clicking this button.
16 * As soon as audio playback starts, the button hides itself again.
17 *
18 * @example
19 * ```tsx
20 * <LiveKitRoom>
21 * <StartAudio label="Click to allow audio playback" />
22 * </LiveKitRoom>
23 * ```
24 *
25 * @see Autoplay policy on MDN web docs: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
26 * @public
27 */
28export const StartAudio: (
29 props: AllowAudioPlaybackProps & React.RefAttributes<HTMLButtonElement>,
30) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, AllowAudioPlaybackProps>(
31 function StartAudio({ label = lkT("livekit.allowAudio"), ...props }: AllowAudioPlaybackProps, ref) {
32 const room = useEnsureRoom(props.room);
33 const { mergedProps } = useStartAudio({ room, props });
34
35 return (
36 <button ref={ref} {...mergedProps}>
37 {label}
38 </button>
39 );
40 },
41);
Added web/src/shared/livekit/react/components/controls/StartMediaButton.tsx web/src/shared/livekit/react/components/controls/StartMediaButton.tsx +42 −0 View web/src/shared/livekit/react/components/controls/StartMediaButton.tsx
@@ -0,0 +1,42 @@
1import * as React from 'react';
2import { useRoomContext } from '../../context';
3import { useStartAudio, useStartVideo } from '../../hooks';
4import { lkT } from '@/shared/livekit/lk-i18n';
5
6/** @public */
7export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
8 label?: string;
9}
10
11/**
12 * The `StartMediaButton` component is only visible when the browser blocks media playback. This is due to some browser implemented autoplay policies.
13 * To start media playback, the user must perform a user-initiated event such as clicking this button.
14 * As soon as media playback starts, the button hides itself again.
15 *
16 * @example
17 * ```tsx
18 * <LiveKitRoom>
19 * <StartMediaButton label="Click to allow media playback" />
20 * </LiveKitRoom>
21 * ```
22 *
23 * @see Autoplay policy on MDN web docs: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
24 * @public
25 */
26export const StartMediaButton: (
27 props: AllowMediaPlaybackProps & React.RefAttributes<HTMLButtonElement>,
28) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, AllowMediaPlaybackProps>(
29 function StartMediaButton({ label, ...props }: AllowMediaPlaybackProps, ref) {
30 const room = useRoomContext();
31 const { mergedProps: audioProps, canPlayAudio } = useStartAudio({ room, props });
32 const { mergedProps, canPlayVideo } = useStartVideo({ room, props: audioProps });
33 const { style, ...restProps } = mergedProps;
34 style.display = canPlayAudio && canPlayVideo ? 'none' : 'block';
35
36 return (
37 <button ref={ref} style={style} {...restProps}>
38 {label ?? lkT(canPlayAudio ? "livekit.startVideo" : "livekit.startAudio")}
39 </button>
40 );
41 },
42);
Added web/src/shared/livekit/react/components/controls/TrackToggle.tsx web/src/shared/livekit/react/components/controls/TrackToggle.tsx +56 −0 View web/src/shared/livekit/react/components/controls/TrackToggle.tsx
@@ -0,0 +1,56 @@
1import type { CaptureOptionsBySource, ToggleSource } from '@livekit/components-core';
2import * as React from 'react';
3import { getSourceIcon } from '../../assets/icons/util';
4import { useTrackToggle } from '../../hooks';
5import { type TrackPublishOptions } from 'livekit-client';
6
7/** @public */
8export interface TrackToggleProps<T extends ToggleSource> extends Omit<
9 React.ButtonHTMLAttributes<HTMLButtonElement>,
10 'onChange'
11> {
12 source: T;
13 showIcon?: boolean;
14 initialState?: boolean;
15 /**
16 * Function that is called when the enabled state of the toggle changes.
17 * The second function argument `isUserInitiated` is `true` if the change was initiated by a user interaction, such as a click.
18 */
19 onChange?: (enabled: boolean, isUserInitiated: boolean) => void;
20 captureOptions?: CaptureOptionsBySource<T>;
21 publishOptions?: TrackPublishOptions;
22 onDeviceError?: (error: Error) => void;
23}
24
25/**
26 * With the `TrackToggle` component it is possible to mute and unmute your camera and microphone.
27 * The component uses an html button element under the hood so you can treat it like a button.
28 *
29 * @example
30 * ```tsx
31 * <LiveKitRoom>
32 * <TrackToggle source={Track.Source.Microphone} />
33 * <TrackToggle source={Track.Source.Camera} />
34 * </LiveKitRoom>
35 * ```
36 * @public
37 */
38export const TrackToggle: <T extends ToggleSource>(
39 props: TrackToggleProps<T> & React.RefAttributes<HTMLButtonElement>,
40) => React.ReactNode = /* @__PURE__ */ React.forwardRef(function TrackToggle<
41 T extends ToggleSource,
42>({ showIcon, ...props }: TrackToggleProps<T>, ref: React.ForwardedRef<HTMLButtonElement>) {
43 const { buttonProps, enabled } = useTrackToggle(props);
44 const [isClient, setIsClient] = React.useState(false);
45 React.useEffect(() => {
46 setIsClient(true);
47 }, []);
48 return (
49 isClient && (
50 <button ref={ref} {...buttonProps}>
51 {(showIcon ?? true) && getSourceIcon(props.source, enabled)}
52 {props.children}
53 </button>
54 )
55 );
56});
Added web/src/shared/livekit/react/components/layout/CarouselLayout.tsx web/src/shared/livekit/react/components/layout/CarouselLayout.tsx +80 −0 View web/src/shared/livekit/react/components/layout/CarouselLayout.tsx
@@ -0,0 +1,80 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import { getScrollBarWidth } from '@livekit/components-core';
3import * as React from 'react';
4import { useSize } from '../../hooks/internal';
5import { useVisualStableUpdate } from '../../hooks';
6import { TrackLoop } from '../TrackLoop';
7
8const MIN_HEIGHT = 130;
9const MIN_WIDTH = 140;
10const MIN_VISIBLE_TILES = 1;
11const ASPECT_RATIO = 16 / 10;
12const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1;
13
14/** @public */
15export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
16 tracks: TrackReferenceOrPlaceholder[];
17 children: React.ReactNode;
18 /** Place the tiles vertically or horizontally next to each other.
19 * If undefined orientation is guessed by the dimensions of the container. */
20 orientation?: 'vertical' | 'horizontal';
21}
22
23/**
24 * The `CarouselLayout` component displays a list of tracks in a scroll container.
25 * It will display as many tiles as possible and overflow the rest.
26 * @remarks
27 * To ensure visual stability when tiles are reordered due to track updates,
28 * the component uses the `useVisualStableUpdate` hook.
29 * @example
30 * ```tsx
31 * const tracks = useTracks([Track.Source.Camera]);
32 * <CarouselLayout tracks={tracks}>
33 * <ParticipantTile />
34 * </CarouselLayout>
35 * ```
36 * @public
37 */
38export function CarouselLayout({ tracks, orientation, ...props }: CarouselLayoutProps) {
39 const asideEl = React.useRef<HTMLDivElement>(null);
40 const [prevTiles, setPrevTiles] = React.useState(0);
41 const { width, height } = useSize(asideEl);
42 const carouselOrientation = orientation
43 ? orientation
44 : height >= width
45 ? 'vertical'
46 : 'horizontal';
47
48 const tileSpan =
49 carouselOrientation === 'vertical'
50 ? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
51 : Math.max(height * ASPECT_RATIO, MIN_WIDTH);
52 const scrollBarWidth = getScrollBarWidth();
53
54 const tilesThatFit =
55 carouselOrientation === 'vertical'
56 ? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
57 : Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES);
58
59 let maxVisibleTiles = Math.round(tilesThatFit);
60 if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
61 maxVisibleTiles = Math.round(prevTiles);
62 } else if (prevTiles !== tilesThatFit) {
63 setPrevTiles(tilesThatFit);
64 }
65
66 const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles);
67
68 React.useLayoutEffect(() => {
69 if (asideEl.current) {
70 asideEl.current.dataset.lkOrientation = carouselOrientation;
71 asideEl.current.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString());
72 }
73 }, [maxVisibleTiles, carouselOrientation]);
74
75 return (
76 <aside key={carouselOrientation} className="lk-carousel" ref={asideEl} {...props}>
77 <TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
78 </aside>
79 );
80}
Added web/src/shared/livekit/react/components/layout/FocusLayout.tsx web/src/shared/livekit/react/components/layout/FocusLayout.tsx +37 −0 View web/src/shared/livekit/react/components/layout/FocusLayout.tsx
@@ -0,0 +1,37 @@
1import * as React from 'react';
2import { mergeProps } from '../../utils';
3import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
4import { ParticipantTile } from '../participant/ParticipantTile';
5import type { ParticipantClickEvent } from '@livekit/components-core';
6
7/** @public */
8export interface FocusLayoutContainerProps extends React.HTMLAttributes<HTMLDivElement> {}
9
10/**
11 * The `FocusLayoutContainer` is a layout component that expects two children:
12 * A small side component: In a video conference, this is usually a carousel of participants
13 * who are not in focus. And a larger main component to display the focused participant.
14 * For example, with the `FocusLayout` component.
15 * @public
16 */
17export function FocusLayoutContainer(props: FocusLayoutContainerProps) {
18 const elementProps = mergeProps(props, { className: 'lk-focus-layout' });
19
20 return <div {...elementProps}>{props.children}</div>;
21}
22
23/** @public */
24export interface FocusLayoutProps extends React.HTMLAttributes<HTMLElement> {
25 /** The track to display in the focus layout. */
26 trackRef?: TrackReferenceOrPlaceholder;
27
28 onParticipantClick?: (evt: ParticipantClickEvent) => void;
29}
30
31/**
32 * The `FocusLayout` component is just a light wrapper around the `ParticipantTile` to display a single participant.
33 * @public
34 */
35export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
36 return <ParticipantTile trackRef={trackRef} {...htmlProps} />;
37}
Added web/src/shared/livekit/react/components/layout/GridLayout.tsx web/src/shared/livekit/react/components/layout/GridLayout.tsx +62 −0 View web/src/shared/livekit/react/components/layout/GridLayout.tsx
@@ -0,0 +1,62 @@
1import * as React from 'react';
2import type { UseParticipantsOptions } from '../../hooks';
3import { useGridLayout, usePagination, useSwipe } from '../../hooks';
4import { mergeProps } from '../../utils';
5import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
6import { TrackLoop } from '../TrackLoop';
7import { PaginationControl } from '../controls/PaginationControl';
8import { PaginationIndicator } from '../controls/PaginationIndicator';
9
10/** @public */
11export interface GridLayoutProps
12 extends React.HTMLAttributes<HTMLDivElement>, Pick<UseParticipantsOptions, 'updateOnlyOn'> {
13 children: React.ReactNode;
14 tracks: TrackReferenceOrPlaceholder[];
15}
16
17/**
18 * The `GridLayout` component displays the nested participants in a grid where every participants has the same size.
19 * It also supports pagination if there are more participants than the grid can display.
20 * @remarks
21 * To ensure visual stability when tiles are reordered due to track updates,
22 * the component uses the `useVisualStableUpdate` hook.
23 * @example
24 * ```tsx
25 * <LiveKitRoom>
26 * <GridLayout tracks={tracks}>
27 * <ParticipantTile />
28 * </GridLayout>
29 * <LiveKitRoom>
30 * ```
31 * @public
32 */
33export function GridLayout({ tracks, ...props }: GridLayoutProps) {
34 const gridEl = React.createRef<HTMLDivElement>();
35
36 const elementProps = React.useMemo(
37 () => mergeProps(props, { className: 'lk-grid-layout' }),
38 [props],
39 );
40 const { layout } = useGridLayout(gridEl, tracks.length);
41 const pagination = usePagination(layout.maxTiles, tracks);
42
43 useSwipe(gridEl, {
44 onLeftSwipe: pagination.nextPage,
45 onRightSwipe: pagination.prevPage,
46 });
47
48 return (
49 <div ref={gridEl} data-lk-pagination={pagination.totalPageCount > 1} {...elementProps}>
50 <TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
51 {tracks.length > layout.maxTiles && (
52 <>
53 <PaginationIndicator
54 totalPageCount={pagination.totalPageCount}
55 currentPage={pagination.currentPage}
56 />
57 <PaginationControl pagesContainer={gridEl} {...pagination} />
58 </>
59 )}
60 </div>
61 );
62}
Added web/src/shared/livekit/react/components/layout/index.ts web/src/shared/livekit/react/components/layout/index.ts +3 −0 View web/src/shared/livekit/react/components/layout/index.ts
@@ -0,0 +1,3 @@
1export * from './FocusLayout';
2export * from './GridLayout';
3export { CarouselLayout, type CarouselLayoutProps } from './CarouselLayout';
Added web/src/shared/livekit/react/components/layout/LayoutContextProvider.tsx web/src/shared/livekit/react/components/layout/LayoutContextProvider.tsx +36 −0 View web/src/shared/livekit/react/components/layout/LayoutContextProvider.tsx
@@ -0,0 +1,36 @@
1import type { PinState, WidgetState } from '@livekit/components-core';
2import { log } from '@livekit/components-core';
3import * as React from 'react';
4import type { LayoutContextType } from '../../context';
5import { LayoutContext, useEnsureCreateLayoutContext } from '../../context';
6
7/** @alpha */
8export interface LayoutContextProviderProps {
9 value?: LayoutContextType;
10 onPinChange?: (state: PinState) => void;
11 onWidgetChange?: (state: WidgetState) => void;
12}
13
14/** @alpha */
15export function LayoutContextProvider({
16 value,
17 onPinChange,
18 onWidgetChange,
19 children,
20}: React.PropsWithChildren<LayoutContextProviderProps>) {
21 const layoutContextValue = useEnsureCreateLayoutContext(value);
22
23 React.useEffect(() => {
24 log.debug('PinState Updated', { state: layoutContextValue.pin.state });
25 if (onPinChange && layoutContextValue.pin.state) onPinChange(layoutContextValue.pin.state);
26 }, [layoutContextValue.pin.state, onPinChange]);
27
28 React.useEffect(() => {
29 log.debug('Widget Updated', { widgetState: layoutContextValue.widget.state });
30 if (onWidgetChange && layoutContextValue.widget.state) {
31 onWidgetChange(layoutContextValue.widget.state);
32 }
33 }, [onWidgetChange, layoutContextValue.widget.state]);
34
35 return <LayoutContext.Provider value={layoutContextValue}>{children}</LayoutContext.Provider>;
36}
Added web/src/shared/livekit/react/components/participant/animationSequences/connectingSequence.ts web/src/shared/livekit/react/components/participant/animationSequences/connectingSequence.ts +9 −0 View web/src/shared/livekit/react/components/participant/animationSequences/connectingSequence.ts
@@ -0,0 +1,9 @@
1export const generateConnectingSequenceBar = (columns: number): number[][] => {
2 const seq = [];
3
4 for (let x = 0; x < columns; x++) {
5 seq.push([x, columns - 1 - x]);
6 }
7
8 return seq;
9};
Added web/src/shared/livekit/react/components/participant/animationSequences/listeningSequence.ts web/src/shared/livekit/react/components/participant/animationSequences/listeningSequence.ts +6 −0 View web/src/shared/livekit/react/components/participant/animationSequences/listeningSequence.ts
@@ -0,0 +1,6 @@
1export const generateListeningSequenceBar = (columns: number): number[][] => {
2 const center = Math.floor(columns / 2);
3 const noIndex = -1;
4
5 return [[center], [noIndex]];
6};
Added web/src/shared/livekit/react/components/participant/animationSequences/thinkingSequence.ts web/src/shared/livekit/react/components/participant/animationSequences/thinkingSequence.ts +12 −0 View web/src/shared/livekit/react/components/participant/animationSequences/thinkingSequence.ts
@@ -0,0 +1,12 @@
1export const generateThinkingSequenceBar = (columns: number): number[][] => {
2 const seq = [];
3 for (let x = 0; x < columns; x++) {
4 seq.push([x]);
5 }
6
7 for (let x = columns - 1; x >= 0; x--) {
8 seq.push([x]);
9 }
10
11 return seq;
12};
Added web/src/shared/livekit/react/components/participant/animators/useBarAnimator.ts web/src/shared/livekit/react/components/participant/animators/useBarAnimator.ts +55 −0 View web/src/shared/livekit/react/components/participant/animators/useBarAnimator.ts
@@ -0,0 +1,55 @@
1import { useEffect, useRef, useState } from 'react';
2import { generateConnectingSequenceBar } from '../animationSequences/connectingSequence';
3import { generateListeningSequenceBar } from '../animationSequences/listeningSequence';
4import type { AgentState } from '../../../hooks';
5
6export const useBarAnimator = (
7 state: AgentState | undefined,
8 columns: number,
9 interval: number,
10): number[] => {
11 const [index, setIndex] = useState(0);
12 const [sequence, setSequence] = useState<number[][]>([[]]);
13
14 useEffect(() => {
15 if (state === 'thinking') {
16 setSequence(generateListeningSequenceBar(columns));
17 } else if (state === 'connecting' || state === 'initializing') {
18 const sequence = [...generateConnectingSequenceBar(columns)];
19 setSequence(sequence);
20 } else if (state === 'listening') {
21 setSequence(generateListeningSequenceBar(columns));
22 } else if (state === undefined || state === 'speaking') {
23 setSequence([new Array(columns).fill(0).map((_, idx) => idx)]);
24 } else {
25 setSequence([[]]);
26 }
27 setIndex(0);
28 }, [state, columns]);
29
30 const animationFrameId = useRef<number | null>(null);
31 useEffect(() => {
32 let startTime = performance.now();
33
34 const animate = (time: DOMHighResTimeStamp) => {
35 const timeElapsed = time - startTime;
36
37 if (timeElapsed >= interval) {
38 setIndex((prev) => prev + 1);
39 startTime = time;
40 }
41
42 animationFrameId.current = requestAnimationFrame(animate);
43 };
44
45 animationFrameId.current = requestAnimationFrame(animate);
46
47 return () => {
48 if (animationFrameId.current !== null) {
49 cancelAnimationFrame(animationFrameId.current);
50 }
51 };
52 }, [interval, columns, state, sequence.length]);
53
54 return sequence[index % sequence.length];
55};
Added web/src/shared/livekit/react/components/participant/AudioTrack.tsx web/src/shared/livekit/react/components/participant/AudioTrack.tsx +89 −0 View web/src/shared/livekit/react/components/participant/AudioTrack.tsx
@@ -0,0 +1,89 @@
1import * as React from 'react';
2import { useMediaTrackBySourceOrName } from '../../hooks/useMediaTrackBySourceOrName';
3import type { TrackReference } from '@livekit/components-core';
4import { log } from '@livekit/components-core';
5import { RemoteAudioTrack, RemoteTrackPublication } from 'livekit-client';
6import { useEnsureTrackRef } from '../../context';
7
8/** @public */
9export interface AudioTrackProps extends React.AudioHTMLAttributes<HTMLAudioElement> {
10 /** The track reference of the track from which the audio is to be rendered. */
11 trackRef?: TrackReference;
12
13 onSubscriptionStatusChanged?: (subscribed: boolean) => void;
14 /** Sets the volume of the audio track. By default, the range is between `0.0` and `1.0`. */
15 volume?: number;
16 /**
17 * Mutes the audio track if set to `true`.
18 * @remarks
19 * If set to `true`, the server will stop sending audio track data to the client.
20 * @alpha
21 */
22 muted?: boolean;
23}
24
25/**
26 * The AudioTrack component is responsible for rendering participant audio tracks.
27 * This component must have access to the participant's context, or alternatively pass it a `Participant` as a property.
28 *
29 * @example
30 * ```tsx
31 * <ParticipantTile>
32 * <AudioTrack trackRef={trackRef} />
33 * </ParticipantTile>
34 * ```
35 *
36 * @see `ParticipantTile` component
37 * @public
38 */
39export const AudioTrack: (
40 props: AudioTrackProps & React.RefAttributes<HTMLAudioElement>,
41) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLAudioElement, AudioTrackProps>(
42 function AudioTrack(
43 { trackRef, onSubscriptionStatusChanged, volume, ...props }: AudioTrackProps,
44 ref,
45 ) {
46 const trackReference = useEnsureTrackRef(trackRef);
47
48 const mediaEl = React.useRef<HTMLAudioElement>(null);
49 React.useImperativeHandle(ref, () => mediaEl.current as HTMLAudioElement);
50
51 const {
52 elementProps,
53 isSubscribed,
54 track,
55 publication: pub,
56 } = useMediaTrackBySourceOrName(trackReference, {
57 element: mediaEl,
58 props,
59 });
60
61 React.useEffect(() => {
62 onSubscriptionStatusChanged?.(!!isSubscribed);
63 }, [isSubscribed, onSubscriptionStatusChanged]);
64
65 React.useEffect(() => {
66 if (track === undefined || volume === undefined) {
67 return;
68 }
69 if (track instanceof RemoteAudioTrack) {
70 track.setVolume(volume);
71 } else {
72 log.warn('Volume can only be set on remote audio tracks.');
73 }
74 }, [volume, track]);
75
76 React.useEffect(() => {
77 if (pub === undefined || props.muted === undefined) {
78 return;
79 }
80 if (pub instanceof RemoteTrackPublication) {
81 pub.setEnabled(!props.muted);
82 } else {
83 log.warn('Can only call setEnabled on remote track publications.');
84 }
85 }, [props.muted, pub, track]);
86
87 return <audio ref={mediaEl} {...elementProps} />;
88 },
89);
Added web/src/shared/livekit/react/components/participant/AudioVisualizer.tsx web/src/shared/livekit/react/components/participant/AudioVisualizer.tsx +67 −0 View web/src/shared/livekit/react/components/participant/AudioVisualizer.tsx
@@ -0,0 +1,67 @@
1import * as React from 'react';
2import { type TrackReference } from '@livekit/components-core';
3import { useEnsureTrackRef } from '../../context';
4import { useMultibandTrackVolume } from '../../hooks';
5
6/**
7 * @public
8 * @deprecated Use BarVisualizer instead
9 */
10export interface AudioVisualizerProps extends React.HTMLAttributes<SVGElement> {
11 trackRef?: TrackReference;
12}
13
14/**
15 * The AudioVisualizer component is used to visualize the audio volume of a given audio track.
16 * @remarks
17 * Requires a `TrackReferenceOrPlaceholder` to be provided either as a property or via the `TrackRefContext`.
18 * @example
19 * ```tsx
20 * <AudioVisualizer />
21 * ```
22 * @public
23 * @deprecated Use BarVisualizer instead
24 */
25export const AudioVisualizer: (
26 props: AudioVisualizerProps & React.RefAttributes<SVGSVGElement>,
27) => React.ReactNode = /* @__PURE__ */ React.forwardRef<SVGSVGElement, AudioVisualizerProps>(
28 function AudioVisualizer({ trackRef, ...props }: AudioVisualizerProps, ref) {
29 const svgWidth = 200;
30 const svgHeight = 90;
31 const barWidth = 6;
32 const barSpacing = 4;
33 const volMultiplier = 50;
34 const barCount = 7;
35 const trackReference = useEnsureTrackRef(trackRef);
36
37 const volumes = useMultibandTrackVolume(trackReference, { bands: 7, loPass: 300 });
38
39 return (
40 <svg
41 ref={ref}
42 width="100%"
43 height="100%"
44 viewBox={`0 0 ${svgWidth} ${svgHeight}`}
45 {...props}
46 className="lk-audio-visualizer"
47 >
48 <rect x="0" y="0" width="100%" height="100%" />
49 <g
50 style={{
51 transform: `translate(${(svgWidth - barCount * (barWidth + barSpacing)) / 2}px, 0)`,
52 }}
53 >
54 {volumes.map((vol, idx) => (
55 <rect
56 key={idx}
57 x={idx * (barWidth + barSpacing)}
58 y={svgHeight / 2 - (vol * volMultiplier) / 2}
59 width={barWidth}
60 height={vol * volMultiplier}
61 ></rect>
62 ))}
63 </g>
64 </svg>
65 );
66 },
67);
Added web/src/shared/livekit/react/components/participant/BarVisualizer.tsx web/src/shared/livekit/react/components/participant/BarVisualizer.tsx +164 −0 View web/src/shared/livekit/react/components/participant/BarVisualizer.tsx
@@ -0,0 +1,164 @@
1import * as React from 'react';
2import { useBarAnimator } from './animators/useBarAnimator';
3import { useMultibandTrackVolume, type AgentState } from '../../hooks';
4import { type TrackReferenceOrPlaceholder } from '@livekit/components-core';
5import { useMaybeTrackRefContext } from '../../context';
6import { cloneSingleChild, mergeProps } from '../../utils';
7import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
8
9/**
10 * @beta
11 */
12export type BarVisualizerOptions = {
13 /** in percentage */
14 maxHeight?: number;
15 /** in percentage */
16 minHeight?: number;
17};
18
19/**
20 * @beta
21 */
22export interface BarVisualizerProps extends React.HTMLProps<HTMLDivElement> {
23 /** If set, the visualizer will transition between different voice assistant states */
24 state?: AgentState;
25 /** Number of bars that show up in the visualizer */
26 barCount?: number;
27 /** @deprecated use `track` field instead */
28 trackRef?: TrackReferenceOrPlaceholder;
29 track?: TrackReferenceOrPlaceholder | LocalAudioTrack | RemoteAudioTrack;
30 options?: BarVisualizerOptions;
31 /** The template component to be used in the visualizer. */
32 children?: React.ReactNode;
33}
34
35const sequencerIntervals = new Map<AgentState, number>([
36 ['connecting', 2000],
37 ['initializing', 2000],
38 ['listening', 500],
39 ['thinking', 150],
40]);
41
42const getSequencerInterval = (
43 state: AgentState | undefined,
44 barCount: number,
45): number | undefined => {
46 if (state === undefined) {
47 return 1000;
48 }
49 let interval = sequencerIntervals.get(state);
50 if (interval) {
51 switch (state) {
52 case 'connecting':
53 // case 'thinking':
54 interval /= barCount;
55 break;
56
57 default:
58 break;
59 }
60 }
61 return interval;
62};
63/**
64 * Visualizes audio signals from a TrackReference as bars.
65 * If the `state` prop is set, it automatically transitions between VoiceAssistant states.
66 * @beta
67 *
68 * @remarks For VoiceAssistant state transitions this component requires a voice assistant agent running with livekit-agents \>= 0.9.0
69 *
70 * @example
71 * ```tsx
72 * function SimpleVoiceAssistant() {
73 * const { state, audioTrack } = useVoiceAssistant();
74 * return (
75 * <BarVisualizer
76 * state={state}
77 * trackRef={audioTrack}
78 * />
79 * );
80 * }
81 * ```
82 *
83 * @example
84 * Styling the BarVisualizer using CSS classes
85 * ```css
86 * .lk-audio-bar {
87 * // Styles for "idle" bars
88 * }
89 * .lk-audio-bar.lk-highlighted {
90 * // Styles for "active" bars
91 * }
92 * ```
93 *
94 * @example
95 * Styling the BarVisualizer using CSS custom properties
96 * ```css
97 * --lk-fg // for the "active" colour, note that this defines the main foreground colour for the whole "theme"
98 * --lk-va-bg // for "idle" colour
99 * ```
100 *
101 * @example
102 * Using a custom bar template for the BarVisualizer
103 * ```tsx
104 * <BarVisualizer>
105 * <div className="all the classes" />
106 * </BarVisualizer>
107 * ```
108 * the highlighted children will get a data prop of data-lk-highlighted for them to switch between active and idle bars in their own template bar
109 */
110export const BarVisualizer = /* @__PURE__ */ React.forwardRef<HTMLDivElement, BarVisualizerProps>(
111 function BarVisualizer(
112 { state, options, barCount = 15, trackRef, track, children, ...props }: BarVisualizerProps,
113 ref,
114 ) {
115 const elementProps = mergeProps(props, { className: 'lk-audio-bar-visualizer' });
116 let targetTrack: TrackReferenceOrPlaceholder | LocalAudioTrack | RemoteAudioTrack | undefined =
117 useMaybeTrackRefContext();
118
119 if (trackRef || track) {
120 targetTrack = trackRef || track;
121 }
122
123 const volumeBands = useMultibandTrackVolume(targetTrack, {
124 bands: barCount,
125 loPass: 100,
126 hiPass: 200,
127 });
128 const minHeight = options?.minHeight ?? 20;
129 const maxHeight = options?.maxHeight ?? 100;
130
131 const highlightedIndices = useBarAnimator(
132 state,
133 barCount,
134 getSequencerInterval(state, barCount) ?? 100,
135 );
136
137 return (
138 <div ref={ref} {...elementProps} data-lk-va-state={state}>
139 {volumeBands.map((volume, idx) =>
140 children ? (
141 cloneSingleChild(children, {
142 'data-lk-highlighted': highlightedIndices.includes(idx),
143 'data-lk-bar-index': idx,
144 className: 'lk-audio-bar',
145 style: { height: `${Math.min(maxHeight, Math.max(minHeight, volume * 100 + 5))}%` },
146 })
147 ) : (
148 <span
149 key={idx}
150 data-lk-highlighted={highlightedIndices.includes(idx)}
151 data-lk-bar-index={idx}
152 className={`lk-audio-bar ${highlightedIndices.includes(idx) && 'lk-highlighted'}`}
153 style={{
154 // TODO transform animations would be more performant, however the border-radius gets distorted when using scale transforms. a 9-slice approach (or 3 in this case) could work
155 // transform: `scale(1, ${Math.min(maxHeight, Math.max(minHeight, volume))}`,
156 height: `${Math.min(maxHeight, Math.max(minHeight, volume * 100 + 5))}%`,
157 }}
158 ></span>
159 ),
160 )}
161 </div>
162 );
163 },
164);
Added web/src/shared/livekit/react/components/participant/ConnectionQualityIndicator.tsx web/src/shared/livekit/react/components/participant/ConnectionQualityIndicator.tsx +55 −0 View web/src/shared/livekit/react/components/participant/ConnectionQualityIndicator.tsx
@@ -0,0 +1,55 @@
1import * as React from 'react';
2import { mergeProps } from '../../utils';
3import { getConnectionQualityIcon } from '../../assets/icons/util';
4import type { ConnectionQualityIndicatorOptions } from '../../hooks';
5import { useConnectionQualityIndicator } from '../../hooks';
6import { lkT } from '@/shared/livekit/lk-i18n';
7import type { MessageKey } from '@/shared/i18n';
8
9/**
10 * Local modification: the official indicator is icon-only, so the connection
11 * state was invisible to a screen reader and had no tooltip. The label follows
12 * the observed value, not a one-off read, so it stays correct while it changes.
13 */
14const QUALITY_KEYS: Record<string, MessageKey> = {
15 excellent: 'chat.voice.qualityHigh',
16 good: 'chat.voice.qualityMedium',
17 poor: 'chat.voice.qualityLow',
18 unknown: 'chat.voice.qualityUnknown',
19};
20
21/** @public */
22export interface ConnectionQualityIndicatorProps
23 extends React.HTMLAttributes<HTMLDivElement>, ConnectionQualityIndicatorOptions {}
24
25/**
26 * The `ConnectionQualityIndicator` shows the individual connection quality of a participant.
27 *
28 * @example
29 * ```tsx
30 * <ConnectionQualityIndicator />
31 * ```
32 * @public
33 */
34export const ConnectionQualityIndicator: (
35 props: ConnectionQualityIndicatorProps & React.RefAttributes<HTMLDivElement>,
36) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
37 HTMLDivElement,
38 ConnectionQualityIndicatorProps
39>(function ConnectionQualityIndicator(props: ConnectionQualityIndicatorProps, ref) {
40 const { className, quality } = useConnectionQualityIndicator(props);
41 const elementProps = React.useMemo(() => {
42 const label = lkT(QUALITY_KEYS[quality] ?? 'chat.voice.qualityUnknown');
43 return {
44 ...mergeProps(props, { className: className as string }),
45 'data-lk-quality': quality,
46 title: label,
47 'aria-label': label,
48 };
49 }, [quality, props, className]);
50 return (
51 <div ref={ref} {...elementProps}>
52 {props.children ?? getConnectionQualityIcon(quality)}
53 </div>
54 );
55});
Added web/src/shared/livekit/react/components/participant/ParticipantAudioTile.tsx web/src/shared/livekit/react/components/participant/ParticipantAudioTile.tsx +67 −0 View web/src/shared/livekit/react/components/participant/ParticipantAudioTile.tsx
@@ -0,0 +1,67 @@
1import * as React from 'react';
2
3import { ConnectionQualityIndicator } from './ConnectionQualityIndicator';
4import { ParticipantName } from './ParticipantName';
5import { TrackMutedIndicator } from './TrackMutedIndicator';
6import { TrackRefContext, useEnsureTrackRef } from '../../context';
7
8import type { ParticipantTileProps } from './ParticipantTile';
9import { AudioTrack } from './AudioTrack';
10import { useParticipantTile } from '../../hooks';
11import { isTrackReference } from '@livekit/components-core';
12import { BarVisualizer } from './BarVisualizer';
13
14/**
15 * The `ParticipantAudioTile` component is the base utility wrapper for displaying a visual representation of a participant.
16 * This component can be used as a child of the `TileLoop` or independently if a participant is passed as a property.
17 *
18 * @example
19 * ```tsx
20 * <ParticipantAudioTile />
21 * ```
22 * @public
23 */
24export const ParticipantAudioTile: (
25 props: ParticipantTileProps & React.RefAttributes<HTMLDivElement>,
26) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLDivElement, ParticipantTileProps>(
27 function ParticipantAudioTile(
28 {
29 children,
30 disableSpeakingIndicator,
31 onParticipantClick,
32 trackRef,
33 ...htmlProps
34 }: ParticipantTileProps,
35 ref,
36 ) {
37 const trackReference = useEnsureTrackRef(trackRef);
38 const { elementProps } = useParticipantTile({
39 trackRef: trackReference,
40 htmlProps,
41 disableSpeakingIndicator,
42 onParticipantClick,
43 });
44
45 return (
46 <div ref={ref} style={{ position: 'relative', minHeight: '160px' }} {...elementProps}>
47 <TrackRefContext.Provider value={trackReference}>
48 {children ?? (
49 <>
50 {isTrackReference(trackReference) && (
51 <AudioTrack trackRef={trackReference}></AudioTrack>
52 )}
53 <BarVisualizer barCount={7} options={{ minHeight: 8 }} />
54 <div className="lk-participant-metadata">
55 <div className="lk-participant-metadata-item">
56 <TrackMutedIndicator trackRef={trackReference}></TrackMutedIndicator>
57 <ParticipantName />
58 </div>
59 <ConnectionQualityIndicator className="lk-participant-metadata-item" />
60 </div>
61 </>
62 )}
63 </TrackRefContext.Provider>
64 </div>
65 );
66 },
67);
Added web/src/shared/livekit/react/components/participant/ParticipantName.tsx web/src/shared/livekit/react/components/participant/ParticipantName.tsx +49 −0 View web/src/shared/livekit/react/components/participant/ParticipantName.tsx
@@ -0,0 +1,49 @@
1import { setupParticipantName } from '@livekit/components-core';
2import * as React from 'react';
3import { useEnsureParticipant } from '../../context';
4import { useObservableState } from '../../hooks/internal/useObservableState';
5import { mergeProps } from '../../utils';
6import type { UseParticipantInfoOptions } from '../../hooks';
7
8/** @public */
9export interface ParticipantNameProps
10 extends React.HTMLAttributes<HTMLSpanElement>, UseParticipantInfoOptions {}
11
12/**
13 * The `ParticipantName` component displays the name of the participant as a string within an HTML span element.
14 * If no participant name is undefined the participant identity string is displayed.
15 *
16 * @example
17 * ```tsx
18 * <ParticipantName />
19 * ```
20 * @public
21 */
22export const ParticipantName: (
23 props: ParticipantNameProps & React.RefAttributes<HTMLSpanElement>,
24) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLSpanElement, ParticipantNameProps>(
25 function ParticipantName({ participant, ...props }: ParticipantNameProps, ref) {
26 const p = useEnsureParticipant(participant);
27
28 const { className, infoObserver } = React.useMemo(() => {
29 return setupParticipantName(p);
30 }, [p]);
31
32 const { identity, name } = useObservableState(infoObserver, {
33 name: p.name,
34 identity: p.identity,
35 metadata: p.metadata,
36 });
37
38 const mergedProps = React.useMemo(() => {
39 return mergeProps(props, { className, 'data-lk-participant-name': name });
40 }, [props, className, name]);
41
42 return (
43 <span ref={ref} {...mergedProps}>
44 {name !== '' ? name : identity}
45 {props.children}
46 </span>
47 );
48 },
49);
Added web/src/shared/livekit/react/components/participant/ParticipantTile.tsx web/src/shared/livekit/react/components/participant/ParticipantTile.tsx +193 −0 View web/src/shared/livekit/react/components/participant/ParticipantTile.tsx
@@ -0,0 +1,193 @@
1import * as React from 'react';
2import type { Participant } from 'livekit-client';
3import { Track } from 'livekit-client';
4import type { ParticipantClickEvent, TrackReferenceOrPlaceholder } from '@livekit/components-core';
5import { isTrackReference, isTrackReferencePinned } from '@livekit/components-core';
6import { ConnectionQualityIndicator } from './ConnectionQualityIndicator';
7import { ParticipantName } from './ParticipantName';
8import { TrackMutedIndicator } from './TrackMutedIndicator';
9import {
10 ParticipantContext,
11 TrackRefContext,
12 useEnsureTrackRef,
13 useFeatureContext,
14 useMaybeLayoutContext,
15 useMaybeParticipantContext,
16 useMaybeTrackRefContext,
17} from '../../context';
18import { FocusToggle } from '../controls/FocusToggle';
19import { ParticipantPlaceholder } from '../../assets/images';
20import { LockLockedIcon, ScreenShareIcon } from '../../assets/icons';
21import { VideoTrack } from './VideoTrack';
22import { AudioTrack } from './AudioTrack';
23import { useParticipantTile } from '../../hooks';
24import { useIsEncrypted } from '../../hooks/useIsEncrypted';
25import { lkT } from '@/shared/livekit/lk-i18n';
26
27/**
28 * The `ParticipantContextIfNeeded` component only creates a `ParticipantContext`
29 * if there is no `ParticipantContext` already.
30 * @example
31 * ```tsx
32 * <ParticipantContextIfNeeded participant={trackReference.participant}>
33 * ...
34 * </ParticipantContextIfNeeded>
35 * ```
36 * @public
37 */
38export function ParticipantContextIfNeeded(
39 props: React.PropsWithChildren<{
40 participant?: Participant;
41 }>,
42) {
43 const hasContext = !!useMaybeParticipantContext();
44 return props.participant && !hasContext ? (
45 <ParticipantContext.Provider value={props.participant}>
46 {props.children}
47 </ParticipantContext.Provider>
48 ) : (
49 <>{props.children}</>
50 );
51}
52
53/**
54 * Only create a `TrackRefContext` if there is no `TrackRefContext` already.
55 * @internal
56 */
57export function TrackRefContextIfNeeded(
58 props: React.PropsWithChildren<{
59 trackRef?: TrackReferenceOrPlaceholder;
60 }>,
61) {
62 const hasContext = !!useMaybeTrackRefContext();
63 return props.trackRef && !hasContext ? (
64 <TrackRefContext.Provider value={props.trackRef}>{props.children}</TrackRefContext.Provider>
65 ) : (
66 <>{props.children}</>
67 );
68}
69
70/** @public */
71export interface ParticipantTileProps extends React.HTMLAttributes<HTMLDivElement> {
72 /** The track reference to display. */
73 trackRef?: TrackReferenceOrPlaceholder;
74 disableSpeakingIndicator?: boolean;
75
76 onParticipantClick?: (event: ParticipantClickEvent) => void;
77}
78
79/**
80 * The `ParticipantTile` component is the base utility wrapper for displaying a visual representation of a participant.
81 * This component can be used as a child of the `TrackLoop` component or by passing a track reference as property.
82 *
83 * @example Using the `ParticipantTile` component with a track reference:
84 * ```tsx
85 * <ParticipantTile trackRef={trackRef} />
86 * ```
87 * @example Using the `ParticipantTile` component as a child of the `TrackLoop` component:
88 * ```tsx
89 * <TrackLoop>
90 * <ParticipantTile />
91 * </TrackLoop>
92 * ```
93 * @public
94 */
95export const ParticipantTile: (
96 props: ParticipantTileProps & React.RefAttributes<HTMLDivElement>,
97) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLDivElement, ParticipantTileProps>(
98 function ParticipantTile(
99 {
100 trackRef,
101 children,
102 onParticipantClick,
103 disableSpeakingIndicator,
104 ...htmlProps
105 }: ParticipantTileProps,
106 ref,
107 ) {
108 const trackReference = useEnsureTrackRef(trackRef);
109
110 const { elementProps } = useParticipantTile<HTMLDivElement>({
111 htmlProps,
112 disableSpeakingIndicator,
113 onParticipantClick,
114 trackRef: trackReference,
115 });
116 const isEncrypted = useIsEncrypted(trackReference.participant);
117 const layoutContext = useMaybeLayoutContext();
118
119 const autoManageSubscription = useFeatureContext()?.autoSubscription;
120
121 const handleSubscribe = React.useCallback(
122 (subscribed: boolean) => {
123 if (
124 trackReference.source &&
125 !subscribed &&
126 layoutContext &&
127 layoutContext.pin.dispatch &&
128 isTrackReferencePinned(trackReference, layoutContext.pin.state)
129 ) {
130 layoutContext.pin.dispatch({ msg: 'clear_pin' });
131 }
132 },
133 [trackReference, layoutContext],
134 );
135
136 return (
137 <div ref={ref} style={{ position: 'relative' }} {...elementProps}>
138 <TrackRefContextIfNeeded trackRef={trackReference}>
139 <ParticipantContextIfNeeded participant={trackReference.participant}>
140 {children ?? (
141 <>
142 {isTrackReference(trackReference) &&
143 (trackReference.publication?.kind === 'video' ||
144 trackReference.source === Track.Source.Camera ||
145 trackReference.source === Track.Source.ScreenShare) ? (
146 <VideoTrack
147 trackRef={trackReference}
148 onSubscriptionStatusChanged={handleSubscribe}
149 manageSubscription={autoManageSubscription}
150 />
151 ) : (
152 isTrackReference(trackReference) && (
153 <AudioTrack
154 trackRef={trackReference}
155 onSubscriptionStatusChanged={handleSubscribe}
156 />
157 )
158 )}
159 <div className="lk-participant-placeholder">
160 <ParticipantPlaceholder />
161 </div>
162 <div className="lk-participant-metadata">
163 <div className="lk-participant-metadata-item">
164 {trackReference.source === Track.Source.Camera ? (
165 <>
166 {isEncrypted && <LockLockedIcon style={{ marginRight: '0.25rem' }} />}
167 <TrackMutedIndicator
168 trackRef={{
169 participant: trackReference.participant,
170 source: Track.Source.Microphone,
171 }}
172 show={'muted'}
173 ></TrackMutedIndicator>
174 <ParticipantName />
175 </>
176 ) : (
177 <>
178 <ScreenShareIcon style={{ marginRight: '0.25rem' }} />
179 <ParticipantName>{lkT('livekit.screenSuffix')}</ParticipantName>
180 </>
181 )}
182 </div>
183 <ConnectionQualityIndicator className="lk-participant-metadata-item" />
184 </div>
185 </>
186 )}
187 <FocusToggle trackRef={trackReference} />
188 </ParticipantContextIfNeeded>
189 </TrackRefContextIfNeeded>
190 </div>
191 );
192 },
193);
Added web/src/shared/livekit/react/components/participant/TrackMutedIndicator.tsx web/src/shared/livekit/react/components/participant/TrackMutedIndicator.tsx +53 −0 View web/src/shared/livekit/react/components/participant/TrackMutedIndicator.tsx
@@ -0,0 +1,53 @@
1import * as React from 'react';
2import { mergeProps } from '../../utils';
3import { getSourceIcon } from '../../assets/icons/util';
4import { useTrackMutedIndicator } from '../../hooks';
5import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
6
7/** @public */
8export interface TrackMutedIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
9 trackRef: TrackReferenceOrPlaceholder;
10 show?: 'always' | 'muted' | 'unmuted';
11}
12
13/**
14 * The `TrackMutedIndicator` shows whether the participant's camera or microphone is muted or not.
15 * By default, a muted/unmuted icon is displayed for a camera, microphone, and screen sharing track.
16 *
17 * @example
18 * ```tsx
19 * <TrackMutedIndicator trackRef={trackRef} />
20 * ```
21 * @public
22 */
23export const TrackMutedIndicator: (
24 props: TrackMutedIndicatorProps & React.RefAttributes<HTMLDivElement>,
25) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLDivElement, TrackMutedIndicatorProps>(
26 function TrackMutedIndicator(
27 { trackRef, show = 'always', ...props }: TrackMutedIndicatorProps,
28 ref,
29 ) {
30 const { className, isMuted } = useTrackMutedIndicator(trackRef);
31
32 const showIndicator =
33 show === 'always' || (show === 'muted' && isMuted) || (show === 'unmuted' && !isMuted);
34
35 const htmlProps = React.useMemo(
36 () =>
37 mergeProps(props, {
38 className,
39 }),
40 [className, props],
41 );
42
43 if (!showIndicator) {
44 return null;
45 }
46
47 return (
48 <div ref={ref} {...htmlProps} data-lk-muted={isMuted}>
49 {props.children ?? getSourceIcon(trackRef.source, !isMuted)}
50 </div>
51 );
52 },
53);
Added web/src/shared/livekit/react/components/participant/VideoTrack.tsx web/src/shared/livekit/react/components/participant/VideoTrack.tsx +121 −0 View web/src/shared/livekit/react/components/participant/VideoTrack.tsx
@@ -0,0 +1,121 @@
1import { RemoteTrackPublication } from 'livekit-client';
2import * as React from 'react';
3import { useMediaTrackBySourceOrName } from '../../hooks/useMediaTrackBySourceOrName';
4import type { ParticipantClickEvent, TrackReference } from '@livekit/components-core';
5import { useEnsureTrackRef } from '../../context';
6import * as useHooks from 'usehooks-ts';
7
8/** @public */
9export interface VideoTrackProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
10 /** The track reference of the track to render. */
11 trackRef?: TrackReference;
12 onTrackClick?: (evt: ParticipantClickEvent) => void;
13 onSubscriptionStatusChanged?: (subscribed: boolean) => void;
14 manageSubscription?: boolean;
15}
16
17/**
18 * The `VideoTrack` component is responsible for rendering participant video tracks like `camera` and `screen_share`.
19 * This component must have access to the participant's context, or alternatively pass it a `Participant` as a property.
20 *
21 * @example
22 * ```tsx
23 * <VideoTrack trackRef={trackRef} />
24 * ```
25 * @see {@link @livekit/components-react#ParticipantTile | ParticipantTile}
26 * @public
27 */
28export const VideoTrack: (
29 props: VideoTrackProps & React.RefAttributes<HTMLVideoElement>,
30) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLVideoElement, VideoTrackProps>(
31 function VideoTrack(
32 {
33 onTrackClick,
34 onClick,
35 onSubscriptionStatusChanged,
36 trackRef,
37 manageSubscription,
38 ...props
39 }: VideoTrackProps,
40 ref,
41 ) {
42 const trackReference = useEnsureTrackRef(trackRef);
43
44 const mediaEl = React.useRef<HTMLVideoElement | null>(null);
45 React.useImperativeHandle(ref, () => mediaEl.current as HTMLVideoElement);
46
47 const { ref: intersectionRef, entry: intersectionEntry } = useHooks.useIntersectionObserver({
48 root: null,
49 });
50
51 const [debouncedIntersectionEntry] = useHooks.useDebounceValue(intersectionEntry, 3000);
52
53 const managedPublicationRef = React.useRef<RemoteTrackPublication | null>(null);
54 const manageSubscriptionRef = React.useRef(false);
55
56 React.useEffect(() => {
57 manageSubscriptionRef.current = Boolean(manageSubscription);
58 managedPublicationRef.current =
59 trackReference.publication instanceof RemoteTrackPublication
60 ? trackReference.publication
61 : null;
62 }, [trackReference.publication, manageSubscription]);
63
64 React.useEffect(() => {
65 if (
66 manageSubscription &&
67 trackReference.publication instanceof RemoteTrackPublication &&
68 debouncedIntersectionEntry?.isIntersecting === false &&
69 intersectionEntry?.isIntersecting === false
70 ) {
71 trackReference.publication.setSubscribed(false);
72 }
73 }, [debouncedIntersectionEntry, intersectionEntry, trackReference, manageSubscription]);
74
75 React.useEffect(() => {
76 if (
77 manageSubscription &&
78 trackReference.publication instanceof RemoteTrackPublication &&
79 intersectionEntry?.isIntersecting === true
80 ) {
81 trackReference.publication.setSubscribed(true);
82 }
83 }, [intersectionEntry, trackReference, manageSubscription]);
84
85 React.useEffect(() => {
86 return () => {
87 if (manageSubscriptionRef.current) {
88 managedPublicationRef.current?.setSubscribed(false);
89 }
90 };
91 }, []);
92
93 const {
94 elementProps,
95 publication: pub,
96 isSubscribed,
97 } = useMediaTrackBySourceOrName(trackReference, {
98 element: mediaEl,
99 props,
100 });
101
102 React.useEffect(() => {
103 onSubscriptionStatusChanged?.(!!isSubscribed);
104 }, [isSubscribed, onSubscriptionStatusChanged]);
105
106 const clickHandler = (evt: React.MouseEvent<HTMLVideoElement, MouseEvent>) => {
107 onClick?.(evt);
108 onTrackClick?.({ participant: trackReference?.participant, track: pub });
109 };
110
111 const setVideoRef = React.useCallback(
112 (el: HTMLVideoElement | null) => {
113 mediaEl.current = el;
114 intersectionRef(el);
115 },
116 [intersectionRef],
117 );
118
119 return <video ref={setVideoRef} {...elementProps} muted={true} onClick={clickHandler}></video>;
120 },
121);
Added web/src/shared/livekit/react/components/ChatEntry.tsx web/src/shared/livekit/react/components/ChatEntry.tsx +112 −0 View web/src/shared/livekit/react/components/ChatEntry.tsx
@@ -0,0 +1,112 @@
1import type { ReceivedChatMessage } from '@livekit/components-core';
2import { tokenize, createDefaultGrammar } from '@livekit/components-core';
3import * as React from 'react';
4
5/** @public */
6export type MessageFormatter = (message: string) => React.ReactNode;
7
8/**
9 * ChatEntry composes the HTML div element under the hood, so you can pass all its props.
10 * These are the props specific to the ChatEntry component:
11 * @public
12 */
13export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
14 /** The chat massage object to display. */
15 entry: ReceivedChatMessage;
16 /** Hide sender name. Useful when displaying multiple consecutive chat messages from the same person. */
17 hideName?: boolean;
18 /** Hide message timestamp. */
19 hideTimestamp?: boolean;
20 /** An optional formatter for the message body. */
21 messageFormatter?: MessageFormatter;
22}
23
24/**
25 * The `ChatEntry` component holds and displays one chat message.
26 *
27 * @example
28 * ```tsx
29 * <Chat>
30 * <ChatEntry />
31 * </Chat>
32 * ```
33 * @see `Chat`
34 * @public
35 */
36export const ChatEntry: (
37 props: ChatEntryProps & React.RefAttributes<HTMLLIElement>,
38) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLLIElement, ChatEntryProps>(
39 function ChatEntry(
40 { entry, hideName = false, hideTimestamp = false, messageFormatter, ...props }: ChatEntryProps,
41 ref,
42 ) {
43 const formattedMessage = React.useMemo(() => {
44 return messageFormatter ? messageFormatter(entry.message) : entry.message;
45 }, [entry.message, messageFormatter]);
46 const hasBeenEdited = !!entry.editTimestamp;
47 const time = new Date(entry.timestamp);
48 const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
49
50 const name = entry.from?.name ?? entry.from?.identity;
51
52 return (
53 <li
54 ref={ref}
55 className="lk-chat-entry"
56 title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
57 data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
58 {...props}
59 >
60 {(!hideTimestamp || !hideName || hasBeenEdited) && (
61 <span className="lk-meta-data">
62 {!hideName && <strong className="lk-participant-name">{name}</strong>}
63
64 {(!hideTimestamp || hasBeenEdited) && (
65 <span className="lk-timestamp">
66 {hasBeenEdited && 'edited '}
67 {time.toLocaleTimeString(locale, { timeStyle: 'short' })}
68 </span>
69 )}
70 </span>
71 )}
72
73 <span className="lk-message-body">{formattedMessage}</span>
74 <span className="lk-message-attachements">
75 {entry.attachedFiles?.map(
76 (file) =>
77 file.type.startsWith('image/') && (
78 <img
79 style={{ maxWidth: '300px', maxHeight: '300px' }}
80 key={file.name}
81 src={URL.createObjectURL(file)}
82 alt={file.name}
83 />
84 ),
85 )}
86 </span>
87 </li>
88 );
89 },
90);
91
92/** @public */
93export function formatChatMessageLinks(message: string): React.ReactNode {
94 return tokenize(message, createDefaultGrammar()).map((tok, i) => {
95 if (typeof tok === `string`) {
96 return tok;
97 } else {
98 const content = tok.content.toString();
99 const href =
100 tok.type === `url`
101 ? /^http(s?):\/\//.test(content)
102 ? content
103 : `https://${content}`
104 : `mailto:${content}`;
105 return (
106 <a className="lk-chat-link" key={i} href={href} target="_blank" rel="noreferrer">
107 {content}
108 </a>
109 );
110 }
111 });
112}
Added web/src/shared/livekit/react/components/ConnectionState.tsx web/src/shared/livekit/react/components/ConnectionState.tsx +36 −0 View web/src/shared/livekit/react/components/ConnectionState.tsx
@@ -0,0 +1,36 @@
1import type { Room } from 'livekit-client';
2import * as React from 'react';
3import { useConnectionState } from '../hooks';
4
5/** @public */
6export interface ConnectionStatusProps extends React.HTMLAttributes<HTMLDivElement> {
7 /**
8 * The room from which the connection status should be displayed.
9 */
10 room?: Room;
11}
12
13/**
14 * The `ConnectionState` component displays the connection status of the room as strings
15 * (`"connected" | "connecting" | "disconnected" | "reconnecting"`).
16 *
17 * @example
18 * ```tsx
19 * <LiveKitRoom>
20 * <ConnectionState />
21 * </LiveKitRoom>
22 * ```
23 * @public
24 */
25export const ConnectionState: (
26 props: ConnectionStatusProps & React.RefAttributes<HTMLDivElement>,
27) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLDivElement, ConnectionStatusProps>(
28 function ConnectionState({ room, ...props }: ConnectionStatusProps, ref) {
29 const connectionState = useConnectionState(room);
30 return (
31 <div ref={ref} {...props}>
32 {connectionState}
33 </div>
34 );
35 },
36);
Added web/src/shared/livekit/react/components/ConnectionStateToast.tsx web/src/shared/livekit/react/components/ConnectionStateToast.tsx +48 −0 View web/src/shared/livekit/react/components/ConnectionStateToast.tsx
@@ -0,0 +1,48 @@
1import type { Room } from 'livekit-client';
2import { ConnectionState } from 'livekit-client';
3import * as React from 'react';
4import { SpinnerIcon } from '../assets/icons';
5import { useConnectionState } from '../hooks';
6import { Toast } from './Toast';
7import { lkT } from '@/shared/livekit/lk-i18n';
8
9/** @public */
10export interface ConnectionStateToastProps extends React.HTMLAttributes<HTMLDivElement> {
11 room?: Room;
12}
13
14/**
15 * The `ConnectionStateToast` component displays a toast
16 * notification indicating the current connection state of the room.
17 * @public
18 */
19export function ConnectionStateToast(props: ConnectionStateToastProps) {
20 const [notification, setNotification] = React.useState<React.ReactElement | undefined>(undefined);
21 const state = useConnectionState(props.room);
22
23 React.useEffect(() => {
24 switch (state) {
25 case ConnectionState.Reconnecting:
26 setNotification(
27 <>
28 <SpinnerIcon className="lk-spinner" /> {lkT("livekit.reconnecting")}
29 </>,
30 );
31 break;
32 case ConnectionState.Connecting:
33 setNotification(
34 <>
35 <SpinnerIcon className="lk-spinner" /> {lkT("livekit.connecting")}
36 </>,
37 );
38 break;
39 case ConnectionState.Disconnected:
40 setNotification(<>{lkT("livekit.disconnected")}</>);
41 break;
42 default:
43 setNotification(undefined);
44 break;
45 }
46 }, [state]);
47 return notification ? <Toast className="lk-toast-connection-state">{notification}</Toast> : <></>;
48}
Added web/src/shared/livekit/react/components/index.ts web/src/shared/livekit/react/components/index.ts +34 −0 View web/src/shared/livekit/react/components/index.ts
@@ -0,0 +1,34 @@
1export * from './controls/ClearPinButton';
2export * from './ConnectionState';
3export * from './controls/ChatToggle';
4export * from './controls/DisconnectButton';
5export * from './controls/FocusToggle';
6export * from './controls/MediaDeviceSelect';
7export * from './controls/StartAudio';
8export * from './controls/StartMediaButton';
9export * from './controls/TrackToggle';
10export * from './layout';
11export * from './layout/LayoutContextProvider';
12export * from './LiveKitRoom';
13export * from './participant/AudioVisualizer';
14export * from './participant/ConnectionQualityIndicator';
15export * from './participant/AudioTrack';
16export * from './participant/VideoTrack';
17export * from './participant/ParticipantName';
18export * from './participant/TrackMutedIndicator';
19export * from './ParticipantLoop';
20export { RoomAudioRenderer, type RoomAudioRendererProps } from './RoomAudioRenderer';
21export * from './RoomName';
22export { Toast } from './Toast';
23export * from './TrackLoop';
24export * from './participant/ParticipantTile';
25export * from './participant/ParticipantAudioTile';
26export * from './participant/BarVisualizer';
27export { ConnectionStateToast, type ConnectionStateToastProps } from './ConnectionStateToast';
28export {
29 type MessageFormatter,
30 type ChatEntryProps,
31 ChatEntry,
32 formatChatMessageLinks,
33} from './ChatEntry';
34export * from './SessionProvider';
Added web/src/shared/livekit/react/components/LiveKitRoom.tsx web/src/shared/livekit/react/components/LiveKitRoom.tsx +122 −0 View web/src/shared/livekit/react/components/LiveKitRoom.tsx
@@ -0,0 +1,122 @@
1import type {
2 AudioCaptureOptions,
3 DisconnectReason,
4 RoomConnectOptions,
5 RoomOptions,
6 ScreenShareCaptureOptions,
7 VideoCaptureOptions,
8} from 'livekit-client';
9import type { MediaDeviceFailure, Room } from 'livekit-client';
10import * as React from 'react';
11import { type FeatureFlags, LKFeatureContext, RoomContext } from '../context';
12import { useLiveKitRoom } from '../hooks';
13
14/** @public */
15export interface LiveKitRoomProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onError'> {
16 /**
17 * URL to the LiveKit server.
18 * For example: `wss://<domain>.livekit.cloud`
19 * To simplify the implementation, `undefined` is also accepted as an intermediate value, but only with a valid string url can the connection be established.
20 */
21 serverUrl: string | undefined;
22 /**
23 * A user specific access token for a client to authenticate to the room.
24 * This token is necessary to establish a connection to the room.
25 * To simplify the implementation, `undefined` is also accepted as an intermediate value, but only with a valid string token can the connection be established.
26 *
27 * @see https://docs.livekit.io/cloud/project-management/keys-and-tokens/#generating-access-tokens
28 */
29 token: string | undefined;
30 /**
31 * Publish audio immediately after connecting to your LiveKit room.
32 * @defaultValue `false`
33 * @see https://docs.livekit.io/client-sdk-js/interfaces/AudioCaptureOptions.html
34 */
35 audio?: AudioCaptureOptions | boolean;
36 /**
37 * Publish video immediately after connecting to your LiveKit room.
38 * @defaultValue `false`
39 * @see https://docs.livekit.io/client-sdk-js/interfaces/VideoCaptureOptions.html
40 */
41 video?: VideoCaptureOptions | boolean;
42 /**
43 * Publish screen share immediately after connecting to your LiveKit room.
44 * @defaultValue `false`
45 * @see https://docs.livekit.io/client-sdk-js/interfaces/ScreenShareCaptureOptions.html
46 */
47 screen?: ScreenShareCaptureOptions | boolean;
48 /**
49 * If set to true a connection to LiveKit room is initiated.
50 * @defaultValue `true`
51 */
52 connect?: boolean;
53 /**
54 * Options for when creating a new room.
55 * When you pass your own room instance to this component, these options have no effect.
56 * Instead, set the options directly in the room instance.
57 *
58 * @see https://docs.livekit.io/client-sdk-js/interfaces/RoomOptions.html
59 */
60 options?: RoomOptions;
61 /**
62 * Define options how to connect to the LiveKit server.
63 *
64 * @see https://docs.livekit.io/client-sdk-js/interfaces/RoomConnectOptions.html
65 */
66 connectOptions?: RoomConnectOptions;
67 onConnected?: () => void;
68 onDisconnected?: (reason?: DisconnectReason) => void;
69 onError?: (error: Error) => void;
70 onMediaDeviceFailure?: (failure?: MediaDeviceFailure, kind?: MediaDeviceKind) => void;
71 onEncryptionError?: (error: Error) => void;
72 /**
73 * Optional room instance.
74 * By passing your own room instance you overwrite the `options` parameter,
75 * make sure to set the options directly on the room instance itself.
76 */
77 room?: Room;
78
79 simulateParticipants?: number | undefined;
80
81 /**
82 * @internal
83 */
84 featureFlags?: FeatureFlags;
85}
86
87/**
88 * The `LiveKitRoom` component provides the room context to all its child components.
89 * It is generally the starting point of your LiveKit app and the root of the LiveKit component tree.
90 * It provides the room state as a React context to all child components, so you don't have to pass it yourself.
91 *
92 * @example
93 * ```tsx
94 * <LiveKitRoom
95 * token='<livekit-token>'
96 * serverUrl='<url-to-livekit-server>'
97 * connect={true}
98 * >
99 * ...
100 * </LiveKitRoom>
101 * ```
102 * @public
103 */
104export const LiveKitRoom: (
105 props: React.PropsWithChildren<LiveKitRoomProps> & React.RefAttributes<HTMLDivElement>,
106) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
107 HTMLDivElement,
108 React.PropsWithChildren<LiveKitRoomProps>
109>(function LiveKitRoom(props: React.PropsWithChildren<LiveKitRoomProps>, ref) {
110 const { room, htmlProps } = useLiveKitRoom(props);
111 return (
112 <div ref={ref} {...htmlProps}>
113 {room && (
114 <RoomContext.Provider value={room}>
115 <LKFeatureContext.Provider value={props.featureFlags}>
116 {props.children}
117 </LKFeatureContext.Provider>
118 </RoomContext.Provider>
119 )}
120 </div>
121 );
122});
Added web/src/shared/livekit/react/components/ParticipantLoop.tsx web/src/shared/livekit/react/components/ParticipantLoop.tsx +41 −0 View web/src/shared/livekit/react/components/ParticipantLoop.tsx
@@ -0,0 +1,41 @@
1import type { Participant } from 'livekit-client';
2import * as React from 'react';
3import { ParticipantContext } from '../context';
4import { cloneSingleChild } from '../utils';
5
6/** @public */
7export interface ParticipantLoopProps {
8 /** The participants to loop over. Use `useParticipants()` hook to get participants. */
9 participants: Participant[];
10 /** The template component to be used in the loop. */
11 children: React.ReactNode;
12}
13
14/**
15 * The `ParticipantLoop` component loops over an array of participants to create a context for every participant.
16 * This component takes exactly one child component as a template.
17 * By providing your own template as a child you have full control over the look and feel of your participant representations.
18 *
19 * @remarks
20 * If you want to loop over individual tracks instead of participants, you can use the `TrackLoop` component.
21 *
22 * @example
23 * ```tsx
24 * const participants = useParticipants();
25 * <ParticipantLoop participants={participants}>
26 * <ParticipantName />
27 * </ParticipantLoop>
28 * ```
29 * @public
30 */
31export function ParticipantLoop({ participants, ...props }: ParticipantLoopProps) {
32 return (
33 <>
34 {participants.map((participant) => (
35 <ParticipantContext.Provider value={participant} key={participant.identity}>
36 {cloneSingleChild(props.children)}
37 </ParticipantContext.Provider>
38 ))}
39 </>
40 );
41}
Added web/src/shared/livekit/react/components/RoomAudioRenderer.tsx web/src/shared/livekit/react/components/RoomAudioRenderer.tsx +56 −0 View web/src/shared/livekit/react/components/RoomAudioRenderer.tsx
@@ -0,0 +1,56 @@
1import { getTrackReferenceId } from '@livekit/components-core';
2import { Room, Track } from 'livekit-client';
3import { useTracks } from '../hooks';
4import { AudioTrack } from './participant/AudioTrack';
5
6/** @public */
7export interface RoomAudioRendererProps {
8 room?: Room;
9
10 /** Sets the volume for all audio tracks rendered by this component. By default, the range is between `0.0` and `1.0`. */
11 volume?: number;
12
13 /**
14 * If set to `true`, mutes all audio tracks rendered by the component.
15 * @remarks
16 * If set to `true`, the server will stop sending audio track data to the client.
17 * @alpha
18 */
19 muted?: boolean;
20}
21
22/**
23 * The `RoomAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
24 * It takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
25 *
26 * @example
27 * ```tsx
28 * <LiveKitRoom>
29 * <RoomAudioRenderer />
30 * </LiveKitRoom>
31 * ```
32 * @public
33 */
34export function RoomAudioRenderer({ room, volume, muted }: RoomAudioRendererProps) {
35 const tracks = useTracks(
36 [Track.Source.Microphone, Track.Source.ScreenShareAudio, Track.Source.Unknown],
37 {
38 updateOnlyOn: [],
39 onlySubscribed: true,
40 room,
41 },
42 ).filter((ref) => !ref.participant.isLocal && ref.publication.kind === Track.Kind.Audio);
43
44 return (
45 <div style={{ display: 'none' }}>
46 {tracks.map((trackRef) => (
47 <AudioTrack
48 key={getTrackReferenceId(trackRef)}
49 trackRef={trackRef}
50 volume={volume}
51 muted={muted}
52 />
53 ))}
54 </div>
55 );
56}
Added web/src/shared/livekit/react/components/RoomName.tsx web/src/shared/livekit/react/components/RoomName.tsx +36 −0 View web/src/shared/livekit/react/components/RoomName.tsx
@@ -0,0 +1,36 @@
1import * as React from 'react';
2import { useRoomInfo } from '../hooks';
3
4/** @public */
5export interface RoomNameProps extends React.HTMLAttributes<HTMLSpanElement> {
6 childrenPosition?: 'before' | 'after';
7}
8
9/**
10 * The `RoomName` component renders the name of the connected LiveKit room inside a span tag.
11 *
12 * @example
13 * ```tsx
14 * <LiveKitRoom>
15 * <RoomName />
16 * </LiveKitRoom>
17 * ```
18 * @public
19 *
20 * @param props - RoomNameProps
21 */
22export const RoomName: React.FC<RoomNameProps & React.RefAttributes<HTMLSpanElement>> =
23 /* @__PURE__ */ React.forwardRef<HTMLSpanElement, RoomNameProps>(function RoomName(
24 { childrenPosition = 'before', children, ...htmlAttributes }: RoomNameProps,
25 ref,
26 ) {
27 const { name } = useRoomInfo();
28
29 return (
30 <span ref={ref} {...htmlAttributes}>
31 {childrenPosition === 'before' && children}
32 {name}
33 {childrenPosition === 'after' && children}
34 </span>
35 );
36 });
Added web/src/shared/livekit/react/components/SessionProvider.tsx web/src/shared/livekit/react/components/SessionProvider.tsx +22 −0 View web/src/shared/livekit/react/components/SessionProvider.tsx
@@ -0,0 +1,22 @@
1import * as React from 'react';
2import { type UseSessionReturn } from '../hooks';
3import { RoomContext } from '../context';
4import { SessionContext } from '../context/session-context';
5
6/** @beta */
7export type SessionProviderProps = {
8 session: UseSessionReturn;
9 children: React.ReactNode;
10};
11
12/**
13 * The `SessionProvider` component instantiates a SessionContext from the return of useSession
14 * @beta
15 */
16export function SessionProvider(props: SessionProviderProps) {
17 return (
18 <SessionContext.Provider value={props.session}>
19 <RoomContext.Provider value={props.session.room}>{props.children}</RoomContext.Provider>
20 </SessionContext.Provider>
21 );
22}
Added web/src/shared/livekit/react/components/Toast.tsx web/src/shared/livekit/react/components/Toast.tsx +18 −0 View web/src/shared/livekit/react/components/Toast.tsx
@@ -0,0 +1,18 @@
1import * as React from 'react';
2import { mergeProps } from '../utils';
3
4/**
5 * The `Toast` component is a rudimentary way to display a message to the user.
6 * This message should be short lived and not require user interaction.
7 * For example, displaying the current connection state like `ConnectionStateToast` does.
8 *
9 * @example
10 * ```tsx
11 * <Toast>Connecting...</Toast>
12 * ```
13 * @public
14 */
15export function Toast(props: React.HTMLAttributes<HTMLDivElement>) {
16 const htmlProps = React.useMemo(() => mergeProps(props, { className: 'lk-toast' }), [props]);
17 return <div {...htmlProps}>{props.children}</div>;
18}
Added web/src/shared/livekit/react/components/TrackLoop.tsx web/src/shared/livekit/react/components/TrackLoop.tsx +45 −0 View web/src/shared/livekit/react/components/TrackLoop.tsx
@@ -0,0 +1,45 @@
1import type { TrackReference, TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import * as React from 'react';
3import { TrackRefContext } from '../context/track-reference-context';
4import { cloneSingleChild } from '../utils';
5import { getTrackReferenceId } from '@livekit/components-core';
6
7/** @public */
8export interface TrackLoopProps {
9 /** Track references to loop over. You can the use `useTracks()` hook to get TrackReferences. */
10 tracks: TrackReference[] | TrackReferenceOrPlaceholder[];
11 /** The template component to be used in the loop. */
12 children: React.ReactNode;
13}
14
15/**
16 * The `TrackLoop` component loops over tracks. It is for example a easy way to loop over all participant camera and screen share tracks.
17 * `TrackLoop` creates a `TrackRefContext` for each track that you can use to e.g. render the track.
18 *
19 * @example
20 * ```tsx
21 * const trackRefs = useTracks([Track.Source.Camera]);
22 * <TrackLoop tracks={trackRefs} >
23 * <TrackRefContext.Consumer>
24 * {(trackRef) => trackRef && <VideoTrack trackRef={trackRef}/>}
25 * </TrackRefContext.Consumer>
26 * </TrackLoop>
27 * ```
28 * @public
29 */
30export function TrackLoop({ tracks, ...props }: TrackLoopProps) {
31 return (
32 <>
33 {tracks.map((trackReference) => {
34 return (
35 <TrackRefContext.Provider
36 value={trackReference}
37 key={getTrackReferenceId(trackReference)}
38 >
39 {cloneSingleChild(props.children)}
40 </TrackRefContext.Provider>
41 );
42 })}
43 </>
44 );
45}
Added web/src/shared/livekit/react/context/chat-context.ts web/src/shared/livekit/react/context/chat-context.ts +37 −0 View web/src/shared/livekit/react/context/chat-context.ts
@@ -0,0 +1,37 @@
1import type { WidgetState } from '@livekit/components-core';
2import type * as React from 'react';
3
4/** @internal */
5export type ChatContextAction =
6 | { msg: 'show_chat' }
7 | { msg: 'hide_chat' }
8 | { msg: 'toggle_chat' }
9 | { msg: 'unread_msg'; count: number }
10 | { msg: 'toggle_settings' };
11
12/** @internal */
13export type WidgetContextType = {
14 dispatch?: React.Dispatch<ChatContextAction>;
15 state?: WidgetState;
16};
17
18/** @internal */
19export function chatReducer(state: WidgetState, action: ChatContextAction): WidgetState {
20 if (action.msg === 'show_chat') {
21 return { ...state, showChat: true, unreadMessages: 0 };
22 } else if (action.msg === 'hide_chat') {
23 return { ...state, showChat: false };
24 } else if (action.msg === 'toggle_chat') {
25 const newState = { ...state, showChat: !state.showChat };
26 if (newState.showChat === true) {
27 newState.unreadMessages = 0;
28 }
29 return newState;
30 } else if (action.msg === 'unread_msg') {
31 return { ...state, unreadMessages: action.count };
32 } else if (action.msg === 'toggle_settings') {
33 return { ...state, showSettings: !state.showSettings };
34 } else {
35 return { ...state };
36 }
37}
Added web/src/shared/livekit/react/context/feature-context.ts web/src/shared/livekit/react/context/feature-context.ts +28 −0 View web/src/shared/livekit/react/context/feature-context.ts
@@ -0,0 +1,28 @@
1import * as React from 'react';
2
3/** @internal */
4export interface FeatureFlags {
5 autoSubscription?: boolean;
6}
7
8type FeatureContext<T extends boolean = false> = T extends true
9 ? FeatureFlags
10 : FeatureFlags | undefined;
11
12/** @internal */
13export const LKFeatureContext = React.createContext<FeatureFlags | undefined>(undefined);
14
15/**
16 * @internal
17 */
18export function useFeatureContext<T extends boolean>(require?: T): FeatureContext<T> {
19 const ctx = React.useContext(LKFeatureContext) as FeatureContext<T>;
20 if (require === true) {
21 if (ctx) {
22 return ctx;
23 } else {
24 throw Error('tried to access feature context, but none is present');
25 }
26 }
27 return ctx;
28}
Added web/src/shared/livekit/react/context/index.ts web/src/shared/livekit/react/context/index.ts +27 −0 View web/src/shared/livekit/react/context/index.ts
@@ -0,0 +1,27 @@
1export {} from './chat-context';
2export type { LayoutContextType } from './layout-context';
3export {
4 LayoutContext,
5 useCreateLayoutContext,
6 useEnsureCreateLayoutContext,
7 useEnsureLayoutContext,
8 useLayoutContext,
9 useMaybeLayoutContext,
10} from './layout-context';
11export {
12 ParticipantContext,
13 useEnsureParticipant,
14 useMaybeParticipantContext,
15 useParticipantContext,
16} from './participant-context';
17export {} from './pin-context';
18export { RoomContext, useEnsureRoom, useMaybeRoomContext, useRoomContext } from './room-context';
19export { useEnsureSession, useMaybeSessionContext, useSessionContext } from './session-context';
20export {
21 TrackRefContext,
22 useEnsureTrackRef,
23 useMaybeTrackRefContext,
24 useTrackRefContext,
25} from './track-reference-context';
26
27export { type FeatureFlags, useFeatureContext, LKFeatureContext } from './feature-context';
Added web/src/shared/livekit/react/context/layout-context.ts web/src/shared/livekit/react/context/layout-context.ts +72 −0 View web/src/shared/livekit/react/context/layout-context.ts
@@ -0,0 +1,72 @@
1import { PIN_DEFAULT_STATE, WIDGET_DEFAULT_STATE } from '@livekit/components-core';
2import * as React from 'react';
3import type { PinContextType } from './pin-context';
4import type { WidgetContextType } from './chat-context';
5import { chatReducer } from './chat-context';
6import { pinReducer } from './pin-context';
7
8/** @public */
9export type LayoutContextType = {
10 pin: PinContextType;
11 widget: WidgetContextType;
12};
13
14/** @public */
15export const LayoutContext = React.createContext<LayoutContextType | undefined>(undefined);
16
17/**
18 * Ensures that a layout context is provided via context.
19 * If no layout context is provided, an error is thrown.
20 * @public
21 */
22export function useLayoutContext(): LayoutContextType {
23 const layoutContext = React.useContext(LayoutContext);
24 if (!layoutContext) {
25 throw Error('Tried to access LayoutContext context outside a LayoutContextProvider provider.');
26 }
27 return layoutContext;
28}
29
30/**
31 * Ensures that a layout context is provided, either via context or explicitly as a parameter.
32 * If not inside a `LayoutContext` and no layout context is provided, an error is thrown.
33 * @public
34 */
35export function useEnsureLayoutContext(layoutContext?: LayoutContextType) {
36 const layout = useMaybeLayoutContext();
37 layoutContext ??= layout;
38 if (!layoutContext) {
39 throw Error('Tried to access LayoutContext context outside a LayoutContextProvider provider.');
40 }
41 return layoutContext;
42}
43
44/** @public */
45export function useCreateLayoutContext(): LayoutContextType {
46 const [pinState, pinDispatch] = React.useReducer(pinReducer, PIN_DEFAULT_STATE);
47 const [widgetState, widgetDispatch] = React.useReducer(chatReducer, WIDGET_DEFAULT_STATE);
48 return {
49 pin: { dispatch: pinDispatch, state: pinState },
50 widget: { dispatch: widgetDispatch, state: widgetState },
51 };
52}
53
54/** @public */
55export function useEnsureCreateLayoutContext(layoutContext?: LayoutContextType): LayoutContextType {
56 const [pinState, pinDispatch] = React.useReducer(pinReducer, PIN_DEFAULT_STATE);
57 const [widgetState, widgetDispatch] = React.useReducer(chatReducer, WIDGET_DEFAULT_STATE);
58 return (
59 layoutContext ?? {
60 pin: { dispatch: pinDispatch, state: pinState },
61 widget: { dispatch: widgetDispatch, state: widgetState },
62 }
63 );
64}
65
66/**
67 * Returns a layout context from the `LayoutContext` if it exists, otherwise `undefined`.
68 * @public
69 */
70export function useMaybeLayoutContext(): LayoutContextType | undefined {
71 return React.useContext(LayoutContext);
72}
Added web/src/shared/livekit/react/context/participant-context.ts web/src/shared/livekit/react/context/participant-context.ts +44 −0 View web/src/shared/livekit/react/context/participant-context.ts
@@ -0,0 +1,44 @@
1import type { Participant } from 'livekit-client';
2import * as React from 'react';
3import { useMaybeTrackRefContext } from './track-reference-context';
4
5/** @public */
6export const ParticipantContext = React.createContext<Participant | undefined>(undefined);
7
8/**
9 * Ensures that a participant is provided via context.
10 * If not inside a `ParticipantContext`, an error is thrown.
11 * @public
12 */
13export function useParticipantContext() {
14 const participant = React.useContext(ParticipantContext);
15 if (!participant) {
16 throw Error('tried to access participant context outside of participant context provider');
17 }
18 return participant;
19}
20
21/**
22 * Returns a participant from the `ParticipantContext` if it exists, otherwise `undefined`.
23 * @public
24 */
25export function useMaybeParticipantContext() {
26 return React.useContext(ParticipantContext);
27}
28
29/**
30 * Ensures that a participant is provided, either via context or explicitly as a parameter.
31 * If not inside a `ParticipantContext` and no participant is provided, an error is thrown.
32 * @public
33 */
34export function useEnsureParticipant(participant?: Participant) {
35 const context = useMaybeParticipantContext();
36 const trackContext = useMaybeTrackRefContext();
37 const p = participant ?? context ?? trackContext?.participant;
38 if (!p) {
39 throw new Error(
40 'No participant provided, make sure you are inside a participant context or pass the participant explicitly',
41 );
42 }
43 return p;
44}
Added web/src/shared/livekit/react/context/pin-context.ts web/src/shared/livekit/react/context/pin-context.ts +27 −0 View web/src/shared/livekit/react/context/pin-context.ts
@@ -0,0 +1,27 @@
1import type { PinState, TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import type * as React from 'react';
3
4/** @internal */
5export type PinAction =
6 | {
7 msg: 'set_pin';
8 trackReference: TrackReferenceOrPlaceholder;
9 }
10 | { msg: 'clear_pin' };
11
12/** @internal */
13export type PinContextType = {
14 dispatch?: React.Dispatch<PinAction>;
15 state?: PinState;
16};
17
18/** @internal */
19export function pinReducer(state: PinState, action: PinAction): PinState {
20 if (action.msg === 'set_pin') {
21 return [action.trackReference];
22 } else if (action.msg === 'clear_pin') {
23 return [];
24 } else {
25 return { ...state };
26 }
27}
Added web/src/shared/livekit/react/context/room-context.ts web/src/shared/livekit/react/context/room-context.ts +42 −0 View web/src/shared/livekit/react/context/room-context.ts
@@ -0,0 +1,42 @@
1import type { Room } from 'livekit-client';
2import * as React from 'react';
3
4/** @public */
5export const RoomContext = React.createContext<Room | undefined>(undefined);
6
7/**
8 * Ensures that a room is provided via context.
9 * If no room is provided, an error is thrown.
10 * @public
11 */
12export function useRoomContext() {
13 const ctx = React.useContext(RoomContext);
14 if (!ctx) {
15 throw Error('tried to access room context outside of livekit room component');
16 }
17 return ctx;
18}
19
20/**
21 * Returns the room context if it exists, otherwise undefined.
22 * @public
23 */
24export function useMaybeRoomContext() {
25 return React.useContext(RoomContext);
26}
27
28/**
29 * Ensures that a room is provided, either via context or explicitly as a parameter.
30 * If no room is provided, an error is thrown.
31 * @public
32 */
33export function useEnsureRoom(room?: Room) {
34 const context = useMaybeRoomContext();
35 const r = room ?? context;
36 if (!r) {
37 throw new Error(
38 'No room provided, make sure you are inside a Room context or pass the room explicitly',
39 );
40 }
41 return r;
42}
Added web/src/shared/livekit/react/context/session-context.ts web/src/shared/livekit/react/context/session-context.ts +43 −0 View web/src/shared/livekit/react/context/session-context.ts
@@ -0,0 +1,43 @@
1import * as React from 'react';
2
3import { type UseSessionReturn } from '../hooks/useSession';
4
5/** @internal */
6export const SessionContext = React.createContext<UseSessionReturn | undefined>(undefined);
7
8/**
9 * Ensures that a session is provided via context.
10 * If no session is provided, an error is thrown.
11 * @beta
12 */
13export function useSessionContext() {
14 const ctx = React.useContext(SessionContext);
15 if (!ctx) {
16 throw Error('tried to access session context outside of SessionProvider component');
17 }
18 return ctx;
19}
20
21/**
22 * Returns the session context if it exists, otherwise undefined.
23 * @beta
24 */
25export function useMaybeSessionContext() {
26 return React.useContext(SessionContext);
27}
28
29/**
30 * Ensures that a session is provided, either via context or explicitly as a parameter.
31 * If no session is provided, an error is thrown.
32 * @beta
33 */
34export function useEnsureSession(session?: UseSessionReturn) {
35 const context = useMaybeSessionContext();
36 const r = session ?? context;
37 if (!r) {
38 throw new Error(
39 'No session provided, make sure you are inside a Session context or pass the session explicitly',
40 );
41 }
42 return r;
43}
Added web/src/shared/livekit/react/context/track-reference-context.ts web/src/shared/livekit/react/context/track-reference-context.ts +47 −0 View web/src/shared/livekit/react/context/track-reference-context.ts
@@ -0,0 +1,47 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import * as React from 'react';
3
4/**
5 * This context provides a `TrackReferenceOrPlaceholder` to all child components.
6 * @public
7 */
8export const TrackRefContext = React.createContext<TrackReferenceOrPlaceholder | undefined>(
9 undefined,
10);
11
12/**
13 * Ensures that a track reference is provided via context.
14 * If not inside a `TrackRefContext`, an error is thrown.
15 * @public
16 */
17export function useTrackRefContext() {
18 const trackReference = React.useContext(TrackRefContext);
19 if (!trackReference) {
20 throw Error('tried to access track context outside of track context provider');
21 }
22 return trackReference;
23}
24
25/**
26 * Returns a track reference from the `TrackRefContext` if it exists, otherwise `undefined`.
27 * @public
28 */
29export function useMaybeTrackRefContext() {
30 return React.useContext(TrackRefContext);
31}
32
33/**
34 * Ensures that a track reference is provided, either via context or explicitly as a parameter.
35 * If not inside a `TrackRefContext` and no track reference is provided, an error is thrown.
36 * @public
37 */
38export function useEnsureTrackRef(trackRef?: TrackReferenceOrPlaceholder) {
39 const context = useMaybeTrackRefContext();
40 const ref = trackRef ?? context;
41 if (!ref) {
42 throw new Error(
43 'No TrackRef, make sure you are inside a TrackRefContext or pass the TrackRef explicitly',
44 );
45 }
46 return ref;
47}
Added web/src/shared/livekit/react/hooks/internal/index.ts web/src/shared/livekit/react/hooks/internal/index.ts +10 −0 View web/src/shared/livekit/react/hooks/internal/index.ts
@@ -0,0 +1,10 @@
1/**
2 * A collection of internal hooks that are not part of the public API
3 * and therefore can be modified or removed at any time.
4 *
5 * @internal
6 */
7
8export * from './useResizeObserver';
9export * from './useObservableState';
10export * from './useMediaQuery';
Added web/src/shared/livekit/react/hooks/internal/useMediaQuery.ts web/src/shared/livekit/react/hooks/internal/useMediaQuery.ts +46 −0 View web/src/shared/livekit/react/hooks/internal/useMediaQuery.ts
@@ -0,0 +1,46 @@
1import * as React from 'react';
2/**
3 * Implementation used from https://github.com/juliencrn/usehooks-ts
4 *
5 * @internal
6 */
7export function useMediaQuery(query: string): boolean {
8 const getMatches = (query: string): boolean => {
9 // Prevents SSR issues
10 if (typeof window !== 'undefined') {
11 return window.matchMedia(query).matches;
12 }
13 return false;
14 };
15
16 const [matches, setMatches] = React.useState<boolean>(getMatches(query));
17
18 function handleChange() {
19 setMatches(getMatches(query));
20 }
21
22 React.useEffect(() => {
23 const matchMedia = window.matchMedia(query);
24
25 // Triggered at the first client-side load and if query changes
26 handleChange();
27
28 // Listen matchMedia
29 if (matchMedia.addListener) {
30 matchMedia.addListener(handleChange);
31 } else {
32 matchMedia.addEventListener('change', handleChange);
33 }
34
35 return () => {
36 if (matchMedia.removeListener) {
37 matchMedia.removeListener(handleChange);
38 } else {
39 matchMedia.removeEventListener('change', handleChange);
40 }
41 };
42 // eslint-disable-next-line react-hooks/exhaustive-deps
43 }, [query]);
44
45 return matches;
46}
Added web/src/shared/livekit/react/hooks/internal/useObservableState.ts web/src/shared/livekit/react/hooks/internal/useObservableState.ts +24 −0 View web/src/shared/livekit/react/hooks/internal/useObservableState.ts
@@ -0,0 +1,24 @@
1import * as React from 'react';
2// @ts-ignore
3import type { Observable } from 'rxjs';
4
5/**
6 * @internal
7 */
8export function useObservableState<T>(
9 observable: Observable<T> | undefined,
10 startWith: T,
11 resetWhenObservableChanges = true,
12) {
13 const [state, setState] = React.useState<T>(startWith);
14 React.useEffect(() => {
15 if (resetWhenObservableChanges) {
16 setState(startWith);
17 }
18 // observable state doesn't run in SSR
19 if (typeof window === 'undefined' || !observable) return;
20 const subscription = observable.subscribe(setState);
21 return () => subscription.unsubscribe();
22 }, [observable, resetWhenObservableChanges]);
23 return state;
24}
Added web/src/shared/livekit/react/hooks/internal/useResizeObserver.ts web/src/shared/livekit/react/hooks/internal/useResizeObserver.ts +124 −0 View web/src/shared/livekit/react/hooks/internal/useResizeObserver.ts
@@ -0,0 +1,124 @@
1/* eslint-disable no-return-assign */
2/* eslint-disable no-underscore-dangle */
3import * as React from 'react';
4
5const useLatest = <T>(current: T) => {
6 const storedValue = React.useRef(current);
7 React.useEffect(() => {
8 storedValue.current = current;
9 });
10 return storedValue;
11};
12
13/**
14 * A React hook that fires a callback whenever ResizeObserver detects a change to its size
15 * code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
16 *
17 * @internal
18 */
19export function useResizeObserver<T extends HTMLElement>(
20 target: React.RefObject<T | null>,
21 callback: UseResizeObserverCallback,
22) {
23 const resizeObserver = getResizeObserver();
24 const storedCallback = useLatest(callback);
25
26 React.useLayoutEffect(() => {
27 let didUnsubscribe = false;
28
29 const targetEl = target.current;
30 if (!targetEl) return;
31
32 function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
33 if (didUnsubscribe) return;
34 storedCallback.current(entry, observer);
35 }
36
37 resizeObserver?.subscribe(targetEl as HTMLElement, cb);
38
39 return () => {
40 didUnsubscribe = true;
41 resizeObserver?.unsubscribe(targetEl as HTMLElement, cb);
42 };
43 }, [target.current, resizeObserver, storedCallback]);
44
45 return resizeObserver?.observer;
46}
47
48function createResizeObserver() {
49 let ticking = false;
50 let allEntries: ResizeObserverEntry[] = [];
51
52 const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map();
53
54 if (typeof window === 'undefined') {
55 return;
56 }
57
58 const observer = new ResizeObserver((entries: ResizeObserverEntry[], obs: ResizeObserver) => {
59 allEntries = allEntries.concat(entries);
60 if (!ticking) {
61 window.requestAnimationFrame(() => {
62 const triggered = new Set<Element>();
63 for (let i = 0; i < allEntries.length; i++) {
64 if (triggered.has(allEntries[i].target)) continue;
65 triggered.add(allEntries[i].target);
66 const cbs = callbacks.get(allEntries[i].target);
67 cbs?.forEach((cb) => cb(allEntries[i], obs));
68 }
69 allEntries = [];
70 ticking = false;
71 });
72 }
73 ticking = true;
74 });
75
76 return {
77 observer,
78 subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
79 observer.observe(target);
80 const cbs = callbacks.get(target) ?? [];
81 cbs.push(callback);
82 callbacks.set(target, cbs);
83 },
84 unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
85 const cbs = callbacks.get(target) ?? [];
86 if (cbs.length === 1) {
87 observer.unobserve(target);
88 callbacks.delete(target);
89 return;
90 }
91 const cbIndex = cbs.indexOf(callback);
92 if (cbIndex !== -1) cbs.splice(cbIndex, 1);
93 callbacks.set(target, cbs);
94 },
95 };
96}
97
98let _resizeObserver: ReturnType<typeof createResizeObserver>;
99
100const getResizeObserver = () =>
101 !_resizeObserver ? (_resizeObserver = createResizeObserver()) : _resizeObserver;
102
103export type UseResizeObserverCallback = (
104 entry: ResizeObserverEntry,
105 observer: ResizeObserver,
106) => unknown;
107
108export const useSize = (target: React.RefObject<HTMLDivElement | null>) => {
109 const [size, setSize] = React.useState({ width: 0, height: 0 });
110 React.useLayoutEffect(() => {
111 if (target.current) {
112 const { width, height } = target.current.getBoundingClientRect();
113 setSize({ width, height });
114 }
115 }, [target.current]);
116
117 const resizeCallback = React.useCallback(
118 (entry: ResizeObserverEntry) => setSize(entry.contentRect),
119 [],
120 );
121 // Where the magic happens
122 useResizeObserver(target, resizeCallback);
123 return size;
124};
Added web/src/shared/livekit/react/hooks/index.ts web/src/shared/livekit/react/hooks/index.ts +81 −0 View web/src/shared/livekit/react/hooks/index.ts
@@ -0,0 +1,81 @@
1export { useAudioPlayback } from './useAudioPlayback';
2export { useClearPinButton } from './useClearPinButton';
3export {
4 type ConnectionQualityIndicatorOptions,
5 useConnectionQualityIndicator,
6} from './useConnectionQualityIndicator';
7export { useConnectionState } from './useConnectionStatus';
8export { useDataChannel } from './useDataChannel';
9export { useDisconnectButton } from './useDisconnectButton';
10export { useFacingMode } from './useFacingMode';
11export { type UseFocusToggleProps, useFocusToggle } from './useFocusToggle';
12export { useGridLayout } from './useGridLayout';
13export { type UseIsMutedOptions, useIsMuted } from './useIsMuted';
14export { useIsSpeaking } from './useIsSpeaking';
15export { useLiveKitRoom } from './useLiveKitRoom';
16export { type UseLocalParticipantOptions, useLocalParticipant } from './useLocalParticipant';
17export { useLocalParticipantPermissions } from './useLocalParticipantPermissions';
18export { type UseMediaDeviceSelectProps, useMediaDeviceSelect } from './useMediaDeviceSelect';
19export { useMediaDevices } from './useMediaDevices';
20export { usePagination } from './usePagination';
21export { type UseParticipantInfoOptions, useParticipantInfo } from './useParticipantInfo';
22export {
23 type UseParticipantPermissionsOptions,
24 useParticipantPermissions,
25} from './useParticipantPermissions';
26export { type UseParticipantTileProps, useParticipantTile } from './useParticipantTile';
27export { type UseParticipantsOptions, useParticipants } from './useParticipants';
28export { usePinnedTracks } from './usePinnedTracks';
29export { type UseRemoteParticipantOptions, useRemoteParticipant } from './useRemoteParticipant';
30export { type UseRemoteParticipantsOptions, useRemoteParticipants } from './useRemoteParticipants';
31export { type UseRoomInfoOptions, useRoomInfo } from './useRoomInfo';
32export { useSortedParticipants } from './useSortedParticipants';
33export {
34 useSpeakingParticipants,
35 type UseSpeakingParticipantsOptions,
36} from './useSpeakingParticipants';
37export { type UseStartAudioProps, useStartAudio } from './useStartAudio';
38export { type UseStartVideoProps, useStartVideo } from './useStartVideo';
39export { type UseSwipeOptions, useSwipe } from './useSwipe';
40export { type UseChatToggleProps, useChatToggle } from './useChatToggle';
41export { type UseTokenOptions, type UserInfo, useToken } from './useToken';
42export { useTrackMutedIndicator } from './useTrackMutedIndicator';
43export { type UseTrackToggleProps, useTrackToggle } from './useTrackToggle';
44export { type UseTracksHookReturnType, type UseTracksOptions, useTracks } from './useTracks';
45export { type UseVisualStableUpdateOptions, useVisualStableUpdate } from './useVisualStableUpdate';
46// export { UseTrackOptions, useTrack } from './useTrack';
47export { useTrackByName } from './useTrackByName';
48export { useChat } from './useChat';
49export {
50 usePersistentUserChoices,
51 type UsePersistentUserChoicesOptions,
52} from './usePersistentUserChoices';
53export { type UseIsEncryptedOptions, useIsEncrypted } from './useIsEncrypted';
54export * from './useTrackVolume';
55export * from './useParticipantTracks';
56export * from './useTrackTranscription';
57export * from './useVoiceAssistant';
58export * from './useParticipantAttributes';
59export * from './useIsRecording';
60export * from './useTextStream';
61export * from './useAgentExpression';
62export * from './useTranscriptions';
63export * from './useSequentialRoomConnectDisconnect';
64export * from './useSession';
65export {
66 type AgentState,
67 AgentEvent,
68 type AgentCallbacks,
69 type UseAgentReturn,
70 useAgent,
71} from './useAgent';
72export * from './useEvents';
73export * from './useSessionMessages';
74export {
75 type RpcHandler,
76 type RpcCallParams,
77 type UseRpcOptions,
78 type RpcPerformFn,
79 type UseRpcReturn,
80 useRpc,
81} from './useRpc';
Added web/src/shared/livekit/react/hooks/useAgent.ts web/src/shared/livekit/react/hooks/useAgent.ts +926 −0 View web/src/shared/livekit/react/hooks/useAgent.ts
@@ -0,0 +1,926 @@
1import { ParticipantAgentAttributes, type TrackReference } from '@livekit/components-core';
2import { EventEmitter } from 'events';
3import {
4 ConnectionState,
5 LocalTrackPublication,
6 Participant,
7 ParticipantEvent,
8 ParticipantKind,
9 RemoteParticipant,
10 RoomEvent,
11 Track,
12} from 'livekit-client';
13import * as React from 'react';
14import type TypedEventEmitter from 'typed-emitter';
15
16import { useMaybeSessionContext } from '../context';
17import { useParticipantInfo } from './useParticipantInfo';
18import { useParticipantTracks } from './useParticipantTracks';
19import { useRemoteParticipants } from './useRemoteParticipants';
20import { type UseSessionReturn } from './useSession';
21
22// FIXME: make this 10 seconds once room dispatch booting info is discoverable
23const DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20_000;
24
25/** @see https://github.com/livekit/agents/blob/65170238db197f62f479eb7aaef1c0e18bfad6e7/livekit-agents/livekit/agents/voice/events.py#L97 */
26type AgentSdkStates = 'initializing' | 'idle' | 'listening' | 'thinking' | 'speaking';
27
28/**
29 * State representing the current status of the agent, whether it is ready for speach, etc
30 *
31 * For most agents (which have the preconnect audio buffer feature enabled), this is the lifecycle:
32 * connecting ➡️ pre-connect-buffering ➡️ initializing/listening/thinking/speaking
33 *
34 * For agents without the preconnect audio feature enabled:
35 * connecting ➡️ initializing ➡️ idle/listening/thinking/speaking
36 *
37 * If an agent fails to connect:
38 * connecting ➡️ pre-connect-buffering/initializing ➡️ failed
39 *
40 * Legacy useVoiceAssistant hook:
41 * disconnected ➡️ connecting ➡️ initializing ➡️ listening/thinking/speaking
42 *
43 * @beta
44 * */
45export type AgentState =
46 'disconnected' | 'connecting' | 'pre-connect-buffering' | 'failed' | AgentSdkStates;
47
48/** @beta */
49export const AgentEvent = {
50 CameraChanged: 'cameraChanged',
51 MicrophoneChanged: 'microphoneChanged',
52 StateChanged: 'stateChanged',} as const;
53export type AgentEvent = (typeof AgentEvent)[keyof typeof AgentEvent];
54
55/** @beta */
56export type AgentCallbacks = {
57 [AgentEvent.CameraChanged]: (newTrack: TrackReference | undefined) => void;
58 [AgentEvent.MicrophoneChanged]: (newTrack: TrackReference | undefined) => void;
59 [AgentEvent.StateChanged]: (newAgentState: AgentState) => void;
60};
61
62type AgentStateCommon = {
63 // FIXME: maybe add some sort of schema to this?
64 attributes: Participant['attributes'];
65
66 internal: {
67 emitter: TypedEventEmitter<AgentCallbacks>;
68
69 agentParticipant: RemoteParticipant | null;
70 workerParticipant: RemoteParticipant | null;
71 };
72};
73
74type AgentStateAvailable = AgentStateCommon & {
75 state: 'listening' | 'thinking' | 'speaking';
76 failureReasons: null;
77
78 /** The agent's assigned identity, coming from the JWT token. */
79 identity: Participant['identity'];
80 name: Participant['name'];
81 metadata: Participant['metadata'];
82
83 /** Is the agent connected to the client? */
84 isConnected: true;
85
86 /**
87 * Could the client be listening for user speech?
88 *
89 * Note that this may not mean that the agent is actually connected - the audio pre-connect
90 * buffer could be active and recording user input before the agent actually connects.
91 * */
92 canListen: true;
93
94 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
95 isFinished: false;
96
97 /** Is the agent currently connecting or setting itself up? */
98 isPending: false;
99
100 cameraTrack?: TrackReference;
101 microphoneTrack?: TrackReference;
102};
103
104type AgentStatePreConnectBuffering = AgentStateCommon & {
105 state: 'pre-connect-buffering';
106 failureReasons: null;
107
108 /** The client's assigned identity, coming from the JWT token. */
109 identity: Participant['identity'];
110 name: Participant['name'];
111 metadata: Participant['metadata'];
112
113 /** Is the agent connected to the client? */
114 isConnected: false;
115
116 /**
117 * Could the client be listening for user speech?
118 *
119 * Note that this may not mean that the agent is actually connected - the audio pre-connect
120 * buffer could be active and recording user input before the agent actually connects.
121 * */
122 canListen: true;
123
124 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
125 isFinished: false;
126
127 /** Is the agent currently connecting or setting itself up? */
128 isPending: false;
129
130 cameraTrack?: TrackReference;
131 microphoneTrack?: TrackReference;
132};
133
134type AgentStateUnAvailable = AgentStateCommon & {
135 state: 'initializing' | 'idle';
136 failureReasons: null;
137
138 /** The client's assigned identity, coming from the JWT token. */
139 identity: Participant['identity'];
140 name: Participant['name'];
141 metadata: Participant['metadata'];
142
143 /** Is the agent connected to the client? */
144 isConnected: false;
145
146 /**
147 * Could the client be listening for user speech?
148 *
149 * Note that this may not mean that the agent is actually connected - the audio pre-connect
150 * buffer could be active and recording user input before the agent actually connects.
151 * */
152 canListen: false;
153
154 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
155 isFinished: false;
156
157 /** Is the agent currently connecting or setting itself up? */
158 isPending: true;
159
160 cameraTrack?: TrackReference;
161 microphoneTrack?: TrackReference;
162};
163
164type AgentStateConnecting = AgentStateCommon & {
165 state: 'connecting';
166 failureReasons: null;
167
168 /** The client's assigned identity, coming from the JWT token. */
169 identity: undefined;
170 name: undefined;
171 metadata: undefined;
172
173 /** Is the agent connected to the client? */
174 isConnected: false;
175
176 /**
177 * Could the client be listening for user speech?
178 *
179 * Note that this may not mean that the agent is actually connected - the audio pre-connect
180 * buffer could be active and recording user input before the agent actually connects.
181 * */
182 canListen: false;
183
184 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
185 isFinished: false;
186
187 /** Is the agent currently connecting or setting itself up? */
188 isPending: true;
189
190 cameraTrack: undefined;
191 microphoneTrack: undefined;
192};
193
194type AgentStateDisconnected = AgentStateCommon & {
195 state: 'disconnected';
196 failureReasons: null;
197
198 /** The client's assigned identity, coming from the JWT token. */
199 identity: undefined;
200 name: undefined;
201 metadata: undefined;
202
203 /** Is the agent connected to the client? */
204 isConnected: false;
205
206 /**
207 * Could the client be listening for user speech?
208 *
209 * Note that this may not mean that the agent is actually connected - the audio pre-connect
210 * buffer could be active and recording user input before the agent actually connects.
211 * */
212 canListen: false;
213
214 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
215 isFinished: true;
216
217 /** Is the agent currently connecting or setting itself up? */
218 isPending: false;
219
220 cameraTrack: undefined;
221 microphoneTrack: undefined;
222};
223
224type AgentStateFailed = AgentStateCommon & {
225 state: 'failed';
226 failureReasons: Array<string>;
227
228 /** The client's assigned identity, coming from the JWT token. */
229 identity: undefined;
230 name: undefined;
231 metadata: undefined;
232
233 /** Is the agent connected to the client? */
234 isConnected: false;
235
236 /**
237 * Could the client be listening for user speech?
238 *
239 * Note that this may not mean that the agent is actually connected - the audio pre-connect
240 * buffer could be active and recording user input before the agent actually connects.
241 * */
242 canListen: false;
243
244 /** Has the client disconnected from the agent either for an expected or unexpected reason? */
245 isFinished: true;
246
247 /** Is the agent currently connecting or setting itself up? */
248 isPending: false;
249
250 cameraTrack: undefined;
251 microphoneTrack: undefined;
252};
253
254type AgentActions = {
255 /** Returns a promise that resolves once the agent is connected and available for user input */
256 waitUntilConnected: (signal?: AbortSignal) => Promise<void>;
257
258 /**
259 * Returns a promise that resolves once the client could be listening for user speech (`canListen` is true)
260 *
261 * Note that this may not mean that the agent is actually connected - the audio pre-connect
262 * buffer could be active and recording user input before the agent actually connects.
263 * */
264 waitUntilCouldBeListening: (signal?: AbortSignal) => Promise<void>;
265
266 /** Returns a promise that resolves once the client has disconnected from the agent either for an expected or unexpected reason. */
267 waitUntilFinished: (signal?: AbortSignal) => Promise<void>;
268
269 /** Returns a promise that resolves once the agent has published a camera track */
270 waitUntilCamera: (signal?: AbortSignal) => Promise<TrackReference>;
271
272 /** Returns a promise that resolves once the agent has published a microphone track */
273 waitUntilMicrophone: (signal?: AbortSignal) => Promise<TrackReference>;
274};
275
276type AgentStateCases =
277 | AgentStateConnecting
278 | AgentStateDisconnected
279 | AgentStateAvailable
280 | AgentStatePreConnectBuffering
281 | AgentStateUnAvailable
282 | AgentStateFailed;
283
284/** @beta */
285export type UseAgentReturn = AgentStateCases & AgentActions;
286
287const generateDerivedStateValues = <State extends AgentState>(state: State) =>
288 ({
289 isConnected: state === 'listening' || state === 'thinking' || state === 'speaking',
290 canListen:
291 state === 'pre-connect-buffering' ||
292 state === 'listening' ||
293 state === 'thinking' ||
294 state === 'speaking',
295 isFinished: state === 'disconnected' || state === 'failed',
296 isPending: state === 'connecting' || state === 'initializing' || state === 'idle',
297 }) as {
298 isConnected: State extends 'listening' | 'thinking' | 'speaking' ? true : false;
299 canListen: State extends 'pre-connect-buffering' | 'listening' | 'thinking' | 'speaking'
300 ? true
301 : false;
302 isFinished: State extends 'disconnected' | 'failed' ? true : false;
303 isPending: State extends 'connecting' | 'initializing' | 'idle' ? true : false;
304 };
305
306/** Internal hook used by useSession to store global agent state */
307export const useAgentTimeoutIdStore = (): {
308 agentTimeoutFailureReason: string | null;
309 startAgentTimeout: (agentConnectTimeoutMilliseconds?: number) => void;
310 clearAgentTimeout: () => void;
311 clearAgentTimeoutFailureReason: () => void;
312 updateAgentTimeoutState: (agentState: AgentState) => void;
313 updateAgentTimeoutParticipantExists: (agentParticipantExists: boolean) => void;
314} => {
315 const [agentTimeoutFailureReason, setAgentTimeoutFailureReason] = React.useState<string | null>(
316 null,
317 );
318 const [agentTimeoutId, setAgentTimeoutId] = React.useState<ReturnType<typeof setTimeout> | null>(
319 null,
320 );
321
322 const agentStateRef = React.useRef<AgentState>('connecting');
323 const agentParticipantExistsRef = React.useRef(false);
324
325 const startAgentConnectedTimeout = (agentConnectTimeoutMilliseconds?: number) => {
326 return setTimeout(() => {
327 if (!agentParticipantExistsRef.current) {
328 setAgentTimeoutFailureReason('Agent did not join the room.');
329 return;
330 }
331
332 const { isConnected } = generateDerivedStateValues(agentStateRef.current);
333 if (!isConnected) {
334 setAgentTimeoutFailureReason('Agent joined the room but did not complete initializing.');
335 return;
336 }
337 }, agentConnectTimeoutMilliseconds ?? DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS);
338 };
339
340 return {
341 agentTimeoutFailureReason,
342 startAgentTimeout: React.useCallback(
343 (agentConnectTimeoutMilliseconds?: number) => {
344 if (agentTimeoutId) {
345 clearTimeout(agentTimeoutId);
346 }
347
348 setAgentTimeoutFailureReason(null);
349 setAgentTimeoutId(startAgentConnectedTimeout(agentConnectTimeoutMilliseconds));
350 agentStateRef.current = 'connecting';
351 agentParticipantExistsRef.current = false;
352 },
353 [agentTimeoutId],
354 ),
355 clearAgentTimeout: React.useCallback(() => {
356 if (agentTimeoutId) {
357 clearTimeout(agentTimeoutId);
358 }
359
360 setAgentTimeoutFailureReason(null);
361 setAgentTimeoutId(null);
362 agentStateRef.current = 'connecting';
363 agentParticipantExistsRef.current = false;
364 }, [agentTimeoutId]),
365 clearAgentTimeoutFailureReason: React.useCallback(() => {
366 setAgentTimeoutFailureReason(null);
367 }, []),
368
369 updateAgentTimeoutState: React.useCallback((agentState: AgentState) => {
370 agentStateRef.current = agentState;
371 }, []),
372 updateAgentTimeoutParticipantExists: React.useCallback((agentParticipantExists: boolean) => {
373 agentParticipantExistsRef.current = agentParticipantExists;
374 }, []),
375 };
376};
377
378type SessionStub = Pick<UseSessionReturn, 'connectionState' | 'room' | 'internal'>;
379
380/** Internal hook used by useAgent which generates a function that when called, will return a
381 * promise which resolves when agent.isAvailable is enabled. */
382function useAgentWaitUntilDerivedStates(
383 emitter: TypedEventEmitter<AgentCallbacks>,
384 state: AgentState,
385) {
386 const stateRef = React.useRef(state);
387 React.useEffect(() => {
388 stateRef.current = state;
389 }, [state]);
390
391 const waitUntilConnected = React.useCallback(
392 async (signal?: AbortSignal) => {
393 const { isConnected } = generateDerivedStateValues(stateRef.current);
394 if (isConnected) {
395 return;
396 }
397
398 return new Promise<void>((resolve, reject) => {
399 const stateChangedHandler = (state: AgentState) => {
400 const { isConnected } = generateDerivedStateValues(state);
401 if (!isConnected) {
402 return;
403 }
404 cleanup();
405 resolve();
406 };
407 const abortHandler = () => {
408 cleanup();
409 reject(new Error('useAgent(/* ... */).waitUntilConnected - signal aborted'));
410 };
411
412 const cleanup = () => {
413 emitter.off(AgentEvent.StateChanged, stateChangedHandler);
414 signal?.removeEventListener('abort', abortHandler);
415 };
416
417 emitter.on(AgentEvent.StateChanged, stateChangedHandler);
418 signal?.addEventListener('abort', abortHandler);
419 });
420 },
421 [emitter],
422 );
423
424 const waitUntilCouldBeListening = React.useCallback(
425 async (signal?: AbortSignal) => {
426 const { canListen } = generateDerivedStateValues(stateRef.current);
427 if (canListen) {
428 return;
429 }
430
431 return new Promise<void>((resolve, reject) => {
432 const stateChangedHandler = (state: AgentState) => {
433 const { canListen } = generateDerivedStateValues(state);
434 if (!canListen) {
435 return;
436 }
437 cleanup();
438 resolve();
439 };
440 const abortHandler = () => {
441 cleanup();
442 reject(new Error('useAgent(/* ... */).waitUntilCouldBeListening - signal aborted'));
443 };
444
445 const cleanup = () => {
446 emitter.off(AgentEvent.StateChanged, stateChangedHandler);
447 signal?.removeEventListener('abort', abortHandler);
448 };
449
450 emitter.on(AgentEvent.StateChanged, stateChangedHandler);
451 signal?.addEventListener('abort', abortHandler);
452 });
453 },
454 [emitter],
455 );
456
457 const waitUntilFinished = React.useCallback(
458 async (signal?: AbortSignal) => {
459 const { isFinished } = generateDerivedStateValues(stateRef.current);
460 if (isFinished) {
461 return;
462 }
463
464 return new Promise<void>((resolve, reject) => {
465 const stateChangedHandler = (state: AgentState) => {
466 const { isFinished } = generateDerivedStateValues(state);
467 if (!isFinished) {
468 return;
469 }
470 cleanup();
471 resolve();
472 };
473 const abortHandler = () => {
474 cleanup();
475 reject(new Error('useAgent(/* ... */).waitUntilFinished - signal aborted'));
476 };
477
478 const cleanup = () => {
479 emitter.off(AgentEvent.StateChanged, stateChangedHandler);
480 signal?.removeEventListener('abort', abortHandler);
481 };
482
483 emitter.on(AgentEvent.StateChanged, stateChangedHandler);
484 signal?.addEventListener('abort', abortHandler);
485 });
486 },
487 [emitter],
488 );
489
490 return { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished };
491}
492
493/**
494 * useAgent encapculates all agent state, normalizing some quirks around how LiveKit Agents work.
495 * @beta
496 */
497export function useAgent(session?: SessionStub): UseAgentReturn {
498 const sessionFromContext = useMaybeSessionContext();
499 session = session ?? sessionFromContext;
500 if (!session) {
501 throw new Error(
502 'No session provided, make sure you are inside a Session context or pass the session explicitly',
503 );
504 }
505
506 const {
507 room,
508 internal: {
509 agentConnectTimeoutMilliseconds,
510
511 agentTimeoutFailureReason,
512 startAgentTimeout,
513 clearAgentTimeout,
514 clearAgentTimeoutFailureReason,
515 updateAgentTimeoutState,
516 updateAgentTimeoutParticipantExists,
517 },
518 } = session;
519
520 const emitter = React.useMemo(() => new EventEmitter() as TypedEventEmitter<AgentCallbacks>, []);
521
522 const roomRemoteParticipants = useRemoteParticipants({ room });
523
524 const agentParticipant = React.useMemo(() => {
525 return (
526 roomRemoteParticipants.find(
527 (p) =>
528 p.kind === ParticipantKind.AGENT &&
529 !(ParticipantAgentAttributes.PublishOnBehalf in p.attributes),
530 ) ?? null
531 );
532 }, [roomRemoteParticipants]);
533 const workerParticipant = React.useMemo(() => {
534 if (!agentParticipant) {
535 return null;
536 }
537 return (
538 roomRemoteParticipants.find(
539 (p) =>
540 p.kind === ParticipantKind.AGENT &&
541 p.attributes[ParticipantAgentAttributes.PublishOnBehalf] === agentParticipant.identity,
542 ) ?? null
543 );
544 }, [agentParticipant, roomRemoteParticipants]);
545
546 // 1. Derive attributes directly from the participant object
547 // Reactivity is already provided by useRemoteParticipants via RoomEvent.ParticipantAttributesChanged.
548 const agentParticipantAttributes = agentParticipant?.attributes ?? {};
549
550 // 2. Listen for track updates
551 const agentTracks = useParticipantTracks([Track.Source.Camera, Track.Source.Microphone], {
552 room,
553 participantIdentity: agentParticipant?.identity,
554 });
555 const workerTracks = useParticipantTracks([Track.Source.Camera, Track.Source.Microphone], {
556 room,
557 participantIdentity: workerParticipant?.identity,
558 });
559
560 const videoTrack = React.useMemo(
561 () =>
562 agentTracks.find((t) => t.source === Track.Source.Camera) ??
563 workerTracks.find((t) => t.source === Track.Source.Camera),
564 [agentTracks, workerTracks],
565 );
566 React.useEffect(() => {
567 emitter.emit(AgentEvent.CameraChanged, videoTrack);
568 }, [emitter, videoTrack]);
569
570 const audioTrack = React.useMemo(
571 () =>
572 agentTracks.find((t) => t.source === Track.Source.Microphone) ??
573 workerTracks.find((t) => t.source === Track.Source.Microphone),
574 [agentTracks, workerTracks],
575 );
576 React.useEffect(() => {
577 emitter.emit(AgentEvent.MicrophoneChanged, audioTrack);
578 }, [emitter, audioTrack]);
579
580 // Listen for room connection state updates
581 const [roomConnectionState, setRoomConnectionState] = React.useState(room.state);
582 React.useEffect(() => {
583 const handleConnectionStateChanged = (connectionState: ConnectionState) => {
584 setRoomConnectionState(connectionState);
585 };
586
587 room.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
588 return () => {
589 room.off(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
590 };
591 }, [room]);
592
593 // When the agent participant connects, reset the timeout failure state
594 React.useEffect(() => {
595 if (!agentParticipant) {
596 return;
597 }
598
599 clearAgentTimeoutFailureReason();
600 }, [agentParticipant]);
601
602 // If the agent participant disconnects in the middle of a conversation unexpectedly, mark that as an explicit failure
603 const [agentDisconnectedFailureReason, setAgentDisconnectedFailureReason] = React.useState<
604 string | null
605 >(null);
606 React.useEffect(() => {
607 if (!agentParticipant) {
608 return;
609 }
610
611 const onParticipantDisconnect = (participant: RemoteParticipant) => {
612 if (participant.identity !== agentParticipant?.identity) {
613 return;
614 }
615 setAgentDisconnectedFailureReason('Agent left the room unexpectedly.');
616 };
617
618 room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnect);
619
620 return () => {
621 room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnect);
622 };
623 }, [agentParticipant, room]);
624
625 React.useEffect(() => {
626 if (roomConnectionState !== ConnectionState.Disconnected) {
627 return;
628 }
629 // Clear the agent disconnect failure state when the room disconnects
630 setAgentDisconnectedFailureReason(null);
631 }, [roomConnectionState]);
632
633 const [localMicTrack, setLocalMicTrack] = React.useState<LocalTrackPublication | null>(
634 () => room.localParticipant.getTrackPublication(Track.Source.Microphone) ?? null,
635 );
636 React.useEffect(() => {
637 const handleLocalParticipantTrackPublished = () => {
638 setLocalMicTrack(room.localParticipant.getTrackPublication(Track.Source.Microphone) ?? null);
639 };
640 const handleLocalParticipantTrackUnPublished = () => {
641 setLocalMicTrack(null);
642 };
643
644 room.localParticipant.on(
645 ParticipantEvent.LocalTrackPublished,
646 handleLocalParticipantTrackPublished,
647 );
648 room.localParticipant.on(
649 ParticipantEvent.LocalTrackUnpublished,
650 handleLocalParticipantTrackUnPublished,
651 );
652 return () => {
653 room.localParticipant.off(
654 ParticipantEvent.LocalTrackPublished,
655 handleLocalParticipantTrackPublished,
656 );
657 room.localParticipant.off(
658 ParticipantEvent.LocalTrackUnpublished,
659 handleLocalParticipantTrackUnPublished,
660 );
661 };
662 }, [room.localParticipant]);
663
664 const failureReasons = React.useMemo(() => {
665 const reasons = [];
666 if (agentTimeoutFailureReason) {
667 reasons.push(agentTimeoutFailureReason);
668 }
669 if (agentDisconnectedFailureReason) {
670 reasons.push(agentDisconnectedFailureReason);
671 }
672 return reasons;
673 }, [agentTimeoutFailureReason, agentDisconnectedFailureReason]);
674
675 const state = React.useMemo(() => {
676 if (failureReasons.length > 0) {
677 return 'failed';
678 }
679
680 let state: AgentState = 'disconnected';
681
682 if (roomConnectionState !== ConnectionState.Disconnected) {
683 state = 'connecting';
684 }
685
686 // If the microphone preconnect buffer is active, then a special 'pre-connect-buffering' state
687 // is set
688 if (localMicTrack) {
689 state = 'pre-connect-buffering';
690 }
691
692 if (agentParticipant && agentParticipantAttributes[ParticipantAgentAttributes.AgentState]) {
693 state = agentParticipantAttributes[ParticipantAgentAttributes.AgentState] as AgentSdkStates;
694 }
695
696 return state;
697 }, [
698 failureReasons,
699 roomConnectionState,
700 localMicTrack,
701 agentParticipant,
702 agentParticipantAttributes,
703 ]);
704
705 React.useEffect(() => {
706 emitter.emit(AgentEvent.StateChanged, state);
707 updateAgentTimeoutState(state);
708 }, [emitter, state]);
709 React.useEffect(() => {
710 updateAgentTimeoutParticipantExists(agentParticipant !== null);
711 }, [agentParticipant]);
712
713 // When the session room begins connecting, start the agent timeout
714 const isSessionDisconnected = session.connectionState === 'disconnected';
715 React.useEffect(() => {
716 if (isSessionDisconnected) {
717 return;
718 }
719
720 startAgentTimeout(agentConnectTimeoutMilliseconds);
721 return () => {
722 clearAgentTimeout();
723 };
724 }, [isSessionDisconnected, agentConnectTimeoutMilliseconds]);
725
726 const {
727 identity: agentParticipantIdentity,
728 name: agentParticipantName,
729 metadata: agentParticipantMetadata,
730 } = useParticipantInfo({ participant: agentParticipant ?? undefined });
731
732 const agentState: AgentStateCases = React.useMemo(() => {
733 const common: AgentStateCommon = {
734 attributes: agentParticipantAttributes,
735
736 internal: {
737 agentParticipant,
738 workerParticipant,
739 emitter,
740 },
741 };
742
743 switch (state) {
744 case 'disconnected':
745 return {
746 ...common,
747 identity: undefined,
748 name: undefined,
749 metadata: undefined,
750
751 state,
752 ...generateDerivedStateValues(state),
753 failureReasons: null,
754
755 // Clear inner values if no longer connected
756 cameraTrack: undefined,
757 microphoneTrack: undefined,
758 };
759
760 case 'connecting':
761 return {
762 ...common,
763 identity: undefined,
764 name: undefined,
765 metadata: undefined,
766
767 state,
768 ...generateDerivedStateValues(state),
769 failureReasons: null,
770
771 // Clear inner values if no longer connected
772 cameraTrack: undefined,
773 microphoneTrack: undefined,
774 };
775
776 case 'initializing':
777 case 'idle':
778 return {
779 ...common,
780 identity: agentParticipantIdentity!,
781 name: agentParticipantName,
782 metadata: agentParticipantMetadata,
783
784 state,
785 ...generateDerivedStateValues(state),
786 failureReasons: null,
787
788 cameraTrack: videoTrack,
789 microphoneTrack: audioTrack,
790 };
791
792 case 'pre-connect-buffering':
793 return {
794 ...common,
795 identity: agentParticipantIdentity!,
796 name: agentParticipantName,
797 metadata: agentParticipantMetadata,
798
799 state,
800 ...generateDerivedStateValues(state),
801 failureReasons: null,
802
803 cameraTrack: videoTrack,
804 microphoneTrack: audioTrack,
805 };
806
807 case 'listening':
808 case 'thinking':
809 case 'speaking':
810 return {
811 ...common,
812 identity: agentParticipantIdentity!,
813 name: agentParticipantName,
814 metadata: agentParticipantMetadata,
815
816 state,
817 ...generateDerivedStateValues(state),
818 failureReasons: null,
819
820 cameraTrack: videoTrack,
821 microphoneTrack: audioTrack,
822 };
823
824 case 'failed':
825 return {
826 ...common,
827 identity: undefined,
828 name: undefined,
829 metadata: undefined,
830
831 state: 'failed',
832 ...generateDerivedStateValues('failed'),
833 failureReasons,
834
835 // Clear inner values if no longer connected
836 cameraTrack: undefined,
837 microphoneTrack: undefined,
838 };
839 }
840 }, [
841 agentParticipantIdentity,
842 agentParticipantName,
843 agentParticipantMetadata,
844 agentParticipantAttributes,
845 emitter,
846 agentParticipant,
847 state,
848 videoTrack,
849 audioTrack,
850 ]);
851
852 const { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished } =
853 useAgentWaitUntilDerivedStates(emitter, state);
854
855 const waitUntilCamera = React.useCallback(
856 (signal?: AbortSignal) => {
857 return new Promise<TrackReference>((resolve, reject) => {
858 const stateChangedHandler = (camera: TrackReference | undefined) => {
859 if (!camera) {
860 return;
861 }
862 cleanup();
863 resolve(camera);
864 };
865 const abortHandler = () => {
866 cleanup();
867 reject(new Error('useAgent(/* ... */).waitUntilCamera - signal aborted'));
868 };
869
870 const cleanup = () => {
871 emitter.off(AgentEvent.CameraChanged, stateChangedHandler);
872 signal?.removeEventListener('abort', abortHandler);
873 };
874
875 emitter.on(AgentEvent.CameraChanged, stateChangedHandler);
876 signal?.addEventListener('abort', abortHandler);
877 });
878 },
879 [emitter],
880 );
881
882 const waitUntilMicrophone = React.useCallback(
883 (signal?: AbortSignal) => {
884 return new Promise<TrackReference>((resolve, reject) => {
885 const stateChangedHandler = (microphone: TrackReference | undefined) => {
886 if (!microphone) {
887 return;
888 }
889 cleanup();
890 resolve(microphone);
891 };
892 const abortHandler = () => {
893 cleanup();
894 reject(new Error('useAgent(/* ... */).waitUntilMicrophone - signal aborted'));
895 };
896
897 const cleanup = () => {
898 emitter.off(AgentEvent.MicrophoneChanged, stateChangedHandler);
899 signal?.removeEventListener('abort', abortHandler);
900 };
901
902 emitter.on(AgentEvent.MicrophoneChanged, stateChangedHandler);
903 signal?.addEventListener('abort', abortHandler);
904 });
905 },
906 [emitter],
907 );
908
909 return React.useMemo(() => {
910 return {
911 ...agentState,
912 waitUntilConnected,
913 waitUntilCouldBeListening,
914 waitUntilFinished,
915 waitUntilCamera,
916 waitUntilMicrophone,
917 };
918 }, [
919 agentState,
920 waitUntilConnected,
921 waitUntilCouldBeListening,
922 waitUntilFinished,
923 waitUntilCamera,
924 waitUntilMicrophone,
925 ]);
926}
Added web/src/shared/livekit/react/hooks/useAgentExpression.ts web/src/shared/livekit/react/hooks/useAgentExpression.ts +175 −0 View web/src/shared/livekit/react/hooks/useAgentExpression.ts
@@ -0,0 +1,175 @@
1import * as React from 'react';
2import { useTranscriptions, type UseTranscriptionsOptions } from './useTranscriptions';
3import type { TextStreamData } from '@livekit/components-core';
4import { ParticipantAgentAttributes } from '@livekit/components-core';
5
6/**
7 * @beta
8 * Published per transcript segment by Expressive Mode: the segment's leading delivery tag.
9 **/
10export const EXPRESSION_ATTRIBUTE: 'lk.expression' = ParticipantAgentAttributes.Expression;
11
12/**
13 * @beta
14 * The agent's normalized emotional delivery.
15 *
16 * The wording the TTS provider emits is not a fixed vocabulary: Fish Audio emits single words
17 * from a closed set, Inworld emits free-form English ("soft, with genuine care"), and models
18 * routinely drift outside whichever set they were given. The agent normalizes that wording to this
19 * enum before publishing it, so every client SDK reads the same values.
20 */
21export type AgentMood =
22 | 'excited'
23 | 'happy'
24 | 'playful'
25 | 'curious'
26 | 'surprised'
27 | 'hopeful'
28 | 'empathetic'
29 | 'sad'
30 | 'angry'
31 | 'anxious'
32 | 'calm';
33
34/**
35 * @beta
36 * How many agent turns an expression survives before the mood decays back to null. Without decay,
37 * one excited sentence would leave the UI excited for the rest of the session.
38 */
39export const DEFAULT_MOOD_TTL_TURNS = 2;
40
41const EXPRESSION_SETTLE_INTERVAL_MS = 150;
42const EXPRESSION_SETTLE_TICKS = 20;
43
44function expressionSettledKey(
45 segment: TextStreamData,
46 segmentIndex: number,
47 parsed: UseAgentExpressionReturn,
48) {
49 const streamId =
50 segment.streamInfo.id ??
51 segment.streamInfo.attributes?.[ParticipantAgentAttributes.TranscriptionSegmentId] ??
52 segmentIndex;
53 return `${streamId}:${segment.participantInfo.identity}:${parsed.mood ?? ''}:${
54 parsed.expression ?? ''
55 }`;
56}
57
58/**
59 * @beta
60 */
61export interface UseAgentExpressionReturn {
62 /** The normalized mood, or null when the agent hasn't expressed anything recently. */
63 mood: AgentMood | null;
64 /** The provider's own delivery wording behind the mood, verbatim and free-form. */
65 expression: string | null;
66}
67
68/**
69 * @beta
70 * The expression published on a transcript segment, or null when it carries none.
71 */
72export function parseExpression(segment: TextStreamData): UseAgentExpressionReturn | null {
73 const raw = segment.streamInfo.attributes?.[EXPRESSION_ATTRIBUTE];
74 if (!raw) {
75 return null;
76 }
77
78 try {
79 const parsed = JSON.parse(raw) as { expression?: string; mood?: AgentMood };
80 const expression = parsed.expression?.trim() || null;
81 if (!expression && !parsed.mood) {
82 return null;
83 }
84 return { mood: parsed.mood ?? null, expression };
85 } catch {
86 return null;
87 }
88}
89
90/**
91 * @beta
92 */
93export interface UseAgentExpressionOptions extends UseTranscriptionsOptions {
94 /**
95 * Agent turns an expression survives before the mood decays to null. `0` disables decay.
96 * @defaultValue 2
97 */
98 ttlTurns?: number;
99}
100
101/**
102 * @beta
103 * useAgentExpression returns the agent's current emotional delivery, as published by Expressive Mode.
104 *
105 * Reads the transcript stream the session already subscribes to, so it adds no second text stream
106 * handler. The mood decays back to null after `ttlTurns` agent turns without a new expression, so
107 * a feeling doesn't outlive the moment that produced it.
108 *
109 * @example
110 * ```tsx
111 * const { mood, expression } = useAgentExpression();
112 * return <span title={expression ?? undefined}>{mood ?? 'neutral'}</span>;
113 * ```
114 */
115export function useAgentExpression(opts?: UseAgentExpressionOptions): UseAgentExpressionReturn {
116 const { ttlTurns = DEFAULT_MOOD_TTL_TURNS, ...transcriptionOpts } = opts ?? {};
117 const segments = useTranscriptions(transcriptionOpts);
118 const [settled, setSettled] = React.useState<string | null>(null);
119
120 // The expression rides the stream's *closing* trailer, which livekit-client merges into the
121 // streamInfo object we already hold, by mutation and without emitting. Nothing re-renders, so
122 // the mood would otherwise only surface once the next segment starts: a full turn late. Poll
123 // briefly after each change, and only re-render when the expression payload actually differs.
124 React.useEffect(() => {
125 let ticks = 0;
126 const id = setInterval(() => {
127 setSettled((previous) => {
128 let latest: string | null = null;
129 for (let i = segments.length - 1; i >= 0; i--) {
130 const parsed = parseExpression(segments[i]);
131 if (parsed) {
132 latest = expressionSettledKey(segments[i], i, parsed);
133 break;
134 }
135 }
136 return latest === previous ? previous : latest;
137 });
138 if (++ticks >= EXPRESSION_SETTLE_TICKS) {
139 clearInterval(id);
140 }
141 }, EXPRESSION_SETTLE_INTERVAL_MS);
142 return () => clearInterval(id);
143 }, [segments]);
144
145 return React.useMemo(() => {
146 // Walk backward from the most recent segment: the first expression found is the current one,
147 // and everything we passed on the way there (from that same speaker) ages it toward decay.
148 let current: UseAgentExpressionReturn | null = null;
149 let speaker: string | null = null;
150 const trailingIdentities: string[] = [];
151
152 for (let i = segments.length - 1; i >= 0; i--) {
153 const segment = segments[i];
154 const parsed = parseExpression(segment);
155 if (parsed) {
156 current = parsed;
157 speaker = segment.participantInfo.identity;
158 break;
159 }
160 // only the expressing participant's own later turns age the mood
161 trailingIdentities.push(segment.participantInfo.identity);
162 }
163
164 if (!current) {
165 return { mood: null, expression: null };
166 }
167
168 const turnsSince = trailingIdentities.filter((identity) => identity === speaker).length;
169 if (ttlTurns > 0 && turnsSince >= ttlTurns) {
170 return { mood: null, expression: null };
171 }
172 return current;
173 // `settled` is not read here: it exists to re-run this memo once the trailer lands
174 }, [segments, settled, ttlTurns]);
175}
Added web/src/shared/livekit/react/hooks/useAudioPlayback.ts web/src/shared/livekit/react/hooks/useAudioPlayback.ts +34 −0 View web/src/shared/livekit/react/hooks/useAudioPlayback.ts
@@ -0,0 +1,34 @@
1import type { Room } from 'livekit-client';
2import * as React from 'react';
3import { useObservableState } from './internal';
4import { roomAudioPlaybackAllowedObservable } from '@livekit/components-core';
5import { useEnsureRoom } from '../context';
6
7/**
8 * In many browsers to start audio playback, the user must perform a user-initiated event such as clicking a button.
9 * The `useAudioPlayback` hook returns an object with a boolean `canPlayAudio` flag that indicates whether audio
10 * playback is allowed in the current context, as well as a `startAudio` function that can be called in a button
11 * `onClick` callback to start audio playback in the current context.
12 *
13 * @see Autoplay policy on MDN web docs for more info: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
14 * @alpha
15 */
16export function useAudioPlayback(room?: Room): {
17 canPlayAudio: boolean;
18 startAudio: () => Promise<void>;
19} {
20 const roomEnsured = useEnsureRoom(room);
21 const startAudio = React.useCallback(async () => {
22 await roomEnsured.startAudio();
23 }, [roomEnsured]);
24
25 const observable = React.useMemo(
26 () => roomAudioPlaybackAllowedObservable(roomEnsured),
27 [roomEnsured],
28 );
29 const { canPlayAudio } = useObservableState(observable, {
30 canPlayAudio: roomEnsured.canPlaybackAudio,
31 });
32
33 return { canPlayAudio, startAudio };
34}
Added web/src/shared/livekit/react/hooks/useChat.ts web/src/shared/livekit/react/hooks/useChat.ts +57 −0 View web/src/shared/livekit/react/hooks/useChat.ts
@@ -0,0 +1,57 @@
1import * as React from 'react';
2import type { ChatOptions, ReceivedChatMessage } from '@livekit/components-core';
3import { setupChat } from '@livekit/components-core';
4import { ConnectionState, Room } from 'livekit-client';
5import { useEnsureRoom } from '../context';
6import { useObservableState } from './internal/useObservableState';
7import { useConnectionState } from './useConnectionStatus';
8
9/**
10 * The `useChat` hook provides chat functionality for a LiveKit room.
11 *
12 * @remarks
13 * Message history is not persisted and will be lost if the component is refreshed.
14 * You may want to persist message history in the browser, a cache or a database.
15 *
16 * @returns An object containing:
17 * - `chatMessages` - Array of received chat messages
18 * - `send` - Function to send a new message
19 * - `isSending` - Boolean indicating if a message is currently being sent
20 *
21 * @example
22 * ```tsx
23 * function ChatComponent() {
24 * const { chatMessages, send, isSending } = useChat();
25 *
26 * return (
27 * <div>
28 * {chatMessages.map((msg) => (
29 * <div key={msg.timestamp}>
30 * {msg.from?.identity}: {msg.message}
31 * </div>
32 * ))}
33 * <button disabled={isSending} onClick={() => send("Hello!")}>
34 * Send Message
35 * </button>
36 * </div>
37 * );
38 * }
39 * ```
40 * @public
41 */
42export function useChat(options?: ChatOptions & { room?: Room }) {
43 const room = useEnsureRoom(options?.room);
44 const connectionState = useConnectionState(room);
45 const isDisconnected = React.useMemo(
46 () => connectionState === ConnectionState.Disconnected,
47 [connectionState],
48 ); // used to reset the messages on room disconnect
49 const setup = React.useMemo<ReturnType<typeof setupChat>>(
50 () => setupChat(room, options),
51 [room, options, isDisconnected],
52 );
53 const isSending = useObservableState(setup.isSendingObservable, false);
54 const chatMessages = useObservableState<ReceivedChatMessage[]>(setup.messageObservable, []);
55
56 return { send: setup.send, chatMessages, isSending };
57}
Added web/src/shared/livekit/react/hooks/useChatToggle.ts web/src/shared/livekit/react/hooks/useChatToggle.ts +38 −0 View web/src/shared/livekit/react/hooks/useChatToggle.ts
@@ -0,0 +1,38 @@
1import { setupChatToggle } from '@livekit/components-core';
2import { useLayoutContext } from '../context';
3import { mergeProps } from '../mergeProps';
4import * as React from 'react';
5
6/** @public */
7export interface UseChatToggleProps {
8 props: React.ButtonHTMLAttributes<HTMLButtonElement>;
9}
10
11/**
12 * The `useChatToggle` hook provides state and functions for toggling the chat window.
13 * @remarks
14 * Depends on the `LayoutContext` to work properly.
15 * @see {@link ChatToggle}, {@link Chat}
16 * @public
17 */
18export function useChatToggle({ props }: UseChatToggleProps) {
19 const { dispatch, state } = useLayoutContext().widget;
20 const { className } = React.useMemo(() => setupChatToggle(), []);
21
22 const mergedProps = React.useMemo(() => {
23 return mergeProps(props, {
24 className,
25 onClick: () => {
26 if (dispatch) dispatch({ msg: 'toggle_chat' });
27 },
28 'aria-pressed': state?.showChat ? 'true' : 'false',
29 'data-lk-unread-msgs': state
30 ? state.unreadMessages < 10
31 ? state.unreadMessages.toFixed(0)
32 : '9+'
33 : '0',
34 });
35 }, [props, className, dispatch, state]);
36
37 return { mergedProps };
38}
Added web/src/shared/livekit/react/hooks/useClearPinButton.ts web/src/shared/livekit/react/hooks/useClearPinButton.ts +29 −0 View web/src/shared/livekit/react/hooks/useClearPinButton.ts
@@ -0,0 +1,29 @@
1import { setupClearPinButton } from '@livekit/components-core';
2import * as React from 'react';
3import { useLayoutContext } from '../context';
4import { mergeProps } from '../mergeProps';
5import type { ClearPinButtonProps } from '../components';
6
7/**
8 * The `useClearPinButton` hook provides props for the {@link ClearPinButton}
9 * or your custom implementation of it component. It adds the `onClick` handler
10 * to signal the `LayoutContext` that the tile in focus should be cleared.
11 * @public
12 */
13export function useClearPinButton(props: ClearPinButtonProps) {
14 const { state, dispatch } = useLayoutContext().pin;
15
16 const buttonProps = React.useMemo(() => {
17 const { className } = setupClearPinButton();
18 const mergedProps = mergeProps(props, {
19 className,
20 disabled: !state?.length,
21 onClick: () => {
22 if (dispatch) dispatch({ msg: 'clear_pin' });
23 },
24 });
25 return mergedProps;
26 }, [props, dispatch, state]);
27
28 return { buttonProps };
29}
Added web/src/shared/livekit/react/hooks/useConnectionQualityIndicator.ts web/src/shared/livekit/react/hooks/useConnectionQualityIndicator.ts +33 −0 View web/src/shared/livekit/react/hooks/useConnectionQualityIndicator.ts
@@ -0,0 +1,33 @@
1import { setupConnectionQualityIndicator } from '@livekit/components-core';
2import type { Participant } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureParticipant } from '../context';
5import { useObservableState } from './internal';
6
7/** @public */
8export interface ConnectionQualityIndicatorOptions {
9 participant?: Participant;
10}
11
12/**
13 * The `useConnectionQualityIndicator` hook provides props for the `ConnectionQualityIndicator` or your custom implementation of it component.
14 * @example
15 * ```tsx
16 * const { quality } = useConnectionQualityIndicator();
17 * // or
18 * const { quality } = useConnectionQualityIndicator({ participant });
19 * ```
20 * @public
21 */
22export function useConnectionQualityIndicator(options: ConnectionQualityIndicatorOptions = {}) {
23 const p = useEnsureParticipant(options.participant);
24
25 const { className, connectionQualityObserver } = React.useMemo(
26 () => setupConnectionQualityIndicator(p),
27 [p],
28 );
29
30 const quality = useObservableState(connectionQualityObserver, p.connectionQuality);
31
32 return { className, quality };
33}
Added web/src/shared/livekit/react/hooks/useConnectionStatus.ts web/src/shared/livekit/react/hooks/useConnectionStatus.ts +22 −0 View web/src/shared/livekit/react/hooks/useConnectionStatus.ts
@@ -0,0 +1,22 @@
1import { connectionStateObserver } from '@livekit/components-core';
2import type { Room } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureRoom } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useConnectionState` hook allows you to simply implement your own `ConnectionState` component.
9 *
10 * @example
11 * ```tsx
12 * const connectionState = useConnectionState(room);
13 * ```
14 * @public
15 */
16export function useConnectionState(room?: Room) {
17 // passed room takes precedence, if not supplied get current room context
18 const r = useEnsureRoom(room);
19 const observable = React.useMemo(() => connectionStateObserver(r), [r]);
20 const connectionState = useObservableState(observable, r.state);
21 return connectionState;
22}
Added web/src/shared/livekit/react/hooks/useDataChannel.ts web/src/shared/livekit/react/hooks/useDataChannel.ts +73 −0 View web/src/shared/livekit/react/hooks/useDataChannel.ts
@@ -0,0 +1,73 @@
1import type { ReceivedDataMessage } from '@livekit/components-core';
2import { setupDataMessageHandler } from '@livekit/components-core';
3import * as React from 'react';
4import type { DataPublishOptions } from 'livekit-client';
5import { useRoomContext } from '../context';
6import { useObservableState } from './internal';
7
8type UseDataChannelReturnType<T extends string | undefined = undefined> = {
9 isSending: boolean;
10 send: (payload: Uint8Array, options: DataPublishOptions) => Promise<void>;
11 message: ReceivedDataMessage<T> | undefined;
12};
13
14/**
15 * The `useDataChannel` hook returns the ability to send and receive messages.
16 * Pass an optional `topic` to narrow down which messages are returned in the messages array.
17 *
18 * @remarks
19 * There is only one data channel. Passing a `topic` does not open a new data channel.
20 * It is only used to filter out messages with no or a different `topic`.
21 *
22 * @example
23 * ```tsx
24 * // Send messages to all participants via the 'chat' topic.
25 * const { message: latestMessage, send } = useDataChannel('chat', (msg) => console.log("message received", msg));
26 * ```
27 *
28 * @example
29 * ```tsx
30 * // Receive all messages (no topic filtering)
31 * const { message: latestMessage, send } = useDataChannel((msg) => console.log("message received", msg));
32 * ```
33 *
34 * @public
35 */
36export function useDataChannel<T extends string>(
37 topic: T,
38 onMessage?: (msg: ReceivedDataMessage<T>) => void,
39): UseDataChannelReturnType<T>;
40
41/**
42 * Overload for `useDataChannel` without a topic. See {@link (useDataChannel:1)} for information and usage examples.
43 *
44 * @public
45 */
46export function useDataChannel(
47 onMessage?: (msg: ReceivedDataMessage) => void,
48): UseDataChannelReturnType;
49/**
50 * @internal
51 */
52export function useDataChannel<T extends string>(
53 topicOrCallback?: T | ((msg: ReceivedDataMessage) => void),
54 callback?: (msg: ReceivedDataMessage<T>) => void,
55) {
56 const onMessage = typeof topicOrCallback === 'function' ? topicOrCallback : callback;
57
58 const topic = typeof topicOrCallback === 'string' ? topicOrCallback : undefined;
59 const room = useRoomContext();
60 const { send, messageObservable, isSendingObservable } = React.useMemo(
61 () => setupDataMessageHandler(room, topic, onMessage),
62 [room, topic, onMessage],
63 );
64
65 const message = useObservableState(messageObservable, undefined);
66 const isSending = useObservableState(isSendingObservable, false);
67
68 return {
69 message,
70 send,
71 isSending,
72 };
73}
Added web/src/shared/livekit/react/hooks/useDisconnectButton.ts web/src/shared/livekit/react/hooks/useDisconnectButton.ts +36 −0 View web/src/shared/livekit/react/hooks/useDisconnectButton.ts
@@ -0,0 +1,36 @@
1import { setupDisconnectButton } from '@livekit/components-core';
2import { ConnectionState } from 'livekit-client';
3import * as React from 'react';
4import type { DisconnectButtonProps } from '../components';
5import { useRoomContext } from '../context';
6import { mergeProps } from '../mergeProps';
7import { useConnectionState } from './useConnectionStatus';
8
9/**
10 * The `useDisconnectButton` hook is used to implement the `DisconnectButton` or your
11 * custom implementation of it. It adds onClick handler to the button to disconnect
12 * from the room.
13 *
14 * @example
15 * ```tsx
16 * const { buttonProps } = useDisconnectButton(buttonProps);
17 * return <button {...buttonProps}>Disconnect</button>;
18 * ```
19 * @public
20 */
21export function useDisconnectButton(props: DisconnectButtonProps) {
22 const room = useRoomContext();
23 const connectionState = useConnectionState(room);
24
25 const buttonProps = React.useMemo(() => {
26 const { className, disconnect } = setupDisconnectButton(room);
27 const mergedProps = mergeProps(props, {
28 className,
29 onClick: () => disconnect(props.stopTracks ?? true),
30 disabled: connectionState === ConnectionState.Disconnected,
31 });
32 return mergedProps;
33 }, [room, props, connectionState]);
34
35 return { buttonProps };
36}
Added web/src/shared/livekit/react/hooks/useEvents.ts web/src/shared/livekit/react/hooks/useEvents.ts +40 −0 View web/src/shared/livekit/react/hooks/useEvents.ts
@@ -0,0 +1,40 @@
1import * as React from 'react';
2import type TypedEventEmitter from 'typed-emitter';
3import type { EventMap } from 'typed-emitter';
4
5/** @public */
6export function useEvents<
7 Emitter extends TypedEventEmitter<EventMap>,
8 EmitterEventMap extends (Emitter extends TypedEventEmitter<infer EM> ? EM : never),
9 Event extends Parameters<Emitter['on']>[0],
10 Callback extends EmitterEventMap[Event],
11>(
12 instance: Emitter | { internal: { emitter: Emitter } } | null | undefined,
13 event: Event,
14 handlerFn: Callback | undefined,
15 dependencies?: React.DependencyList,
16) {
17 const fallback = React.useMemo(() => () => {}, []);
18 const wrappedCallback = React.useCallback(handlerFn ?? fallback, dependencies ?? []);
19 const callback = dependencies ? wrappedCallback : handlerFn;
20
21 const emitter = React.useMemo(() => {
22 if (!instance) {
23 return null;
24 }
25 if ('internal' in instance) {
26 return instance.internal.emitter;
27 }
28 return instance;
29 }, [instance]);
30
31 React.useEffect(() => {
32 if (!emitter || !callback) {
33 return;
34 }
35 emitter.on(event, callback);
36 return () => {
37 emitter.off(event, callback);
38 };
39 }, [emitter, event, callback]);
40}
Added web/src/shared/livekit/react/hooks/useFacingMode.ts web/src/shared/livekit/react/hooks/useFacingMode.ts +22 −0 View web/src/shared/livekit/react/hooks/useFacingMode.ts
@@ -0,0 +1,22 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import { LocalTrackPublication, facingModeFromLocalTrack } from 'livekit-client';
3
4/**
5 * Try to determine the `facingMode` of a local participant video track.
6 * @remarks
7 * Works only on local video tracks.
8 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode | MDN docs on facingMode}
9 * @alpha
10 */
11export function useFacingMode(
12 trackReference: TrackReferenceOrPlaceholder,
13): 'user' | 'environment' | 'left' | 'right' | 'undefined' {
14 if (trackReference.publication instanceof LocalTrackPublication) {
15 const localTrack = trackReference.publication.track;
16 if (localTrack) {
17 const { facingMode } = facingModeFromLocalTrack(localTrack);
18 return facingMode;
19 }
20 }
21 return 'undefined';
22}
Added web/src/shared/livekit/react/hooks/useFocusToggle.ts web/src/shared/livekit/react/hooks/useFocusToggle.ts +59 −0 View web/src/shared/livekit/react/hooks/useFocusToggle.ts
@@ -0,0 +1,59 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import { setupFocusToggle, isTrackReferencePinned } from '@livekit/components-core';
3import { useEnsureTrackRef, useMaybeLayoutContext } from '../context';
4import { mergeProps } from '../mergeProps';
5import * as React from 'react';
6
7/** @public */
8export interface UseFocusToggleProps {
9 trackRef?: TrackReferenceOrPlaceholder;
10 props: React.ButtonHTMLAttributes<HTMLButtonElement>;
11}
12
13/**
14 * The `useFocusToggle` hook is used to implement the `FocusToggle` or your custom implementation of it.
15 * The `TrackReferenceOrPlaceholder` is used to register a onClick handler and to identify the track to focus on.
16 *
17 * @example
18 * ```tsx
19 * const { mergedProps, inFocus } = useFocusToggle({ trackRef, props: yourButtonProps });
20 * return <button {...mergedProps}>{inFocus ? 'Unfocus' : 'Focus'}</button>;
21 * ```
22 * @public
23 */
24export function useFocusToggle({ trackRef, props }: UseFocusToggleProps) {
25 const trackReference = useEnsureTrackRef(trackRef);
26
27 const layoutContext = useMaybeLayoutContext();
28 const { className } = React.useMemo(() => setupFocusToggle(), []);
29
30 const inFocus: boolean = React.useMemo(() => {
31 return isTrackReferencePinned(trackReference, layoutContext?.pin.state);
32 }, [trackReference, layoutContext?.pin.state]);
33
34 const mergedProps = React.useMemo(
35 () =>
36 mergeProps(props, {
37 className,
38 onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
39 // Call user defined on click callbacks.
40 props.onClick?.(event);
41
42 // Set or clear focus based on current focus state.
43 if (inFocus) {
44 layoutContext?.pin.dispatch?.({
45 msg: 'clear_pin',
46 });
47 } else {
48 layoutContext?.pin.dispatch?.({
49 msg: 'set_pin',
50 trackReference,
51 });
52 }
53 },
54 }),
55 [props, className, trackReference, inFocus, layoutContext?.pin],
56 );
57
58 return { mergedProps, inFocus };
59}
Added web/src/shared/livekit/react/hooks/useGridLayout.ts web/src/shared/livekit/react/hooks/useGridLayout.ts +44 −0 View web/src/shared/livekit/react/hooks/useGridLayout.ts
@@ -0,0 +1,44 @@
1import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core';
2import type { GridLayoutDefinition, GridLayoutInfo } from '@livekit/components-core';
3import * as React from 'react';
4import { useSize } from './internal';
5
6/**
7 * The `useGridLayout` hook tries to select the best layout to fit all tiles.
8 * If the available screen space is not enough, it will reduce the number of maximum visible
9 * tiles and select a layout that still works visually within the given limitations.
10 * As the order of tiles changes over time, the hook tries to keep visual updates to a minimum
11 * while trying to display important tiles such as speaking participants or screen shares.
12 *
13 * @example
14 * ```tsx
15 * const { layout } = useGridLayout(gridElement, trackCount);
16 * ```
17 * @public
18 */
19export function useGridLayout(
20 /** HTML element that contains the grid. */
21 gridElement: React.RefObject<HTMLDivElement | null>,
22 /** Count of tracks that should get layed out */
23 trackCount: number,
24 options: {
25 gridLayouts?: GridLayoutDefinition[];
26 } = {},
27): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } {
28 const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS;
29 const { width, height } = useSize(gridElement);
30 const layout = selectGridLayout(gridLayouts, trackCount, width, height);
31
32 React.useEffect(() => {
33 if (gridElement.current && layout) {
34 gridElement.current.style.setProperty('--lk-col-count', layout?.columns.toString());
35 gridElement.current.style.setProperty('--lk-row-count', layout?.rows.toString());
36 }
37 }, [gridElement, layout]);
38
39 return {
40 layout,
41 containerWidth: width,
42 containerHeight: height,
43 };
44}
Added web/src/shared/livekit/react/hooks/useIsEncrypted.ts web/src/shared/livekit/react/hooks/useIsEncrypted.ts +29 −0 View web/src/shared/livekit/react/hooks/useIsEncrypted.ts
@@ -0,0 +1,29 @@
1import * as React from 'react';
2import { LocalParticipant } from 'livekit-client';
3import type { Participant, Room } from 'livekit-client';
4import { encryptionStatusObservable } from '@livekit/components-core';
5import { useEnsureParticipant, useEnsureRoom } from '../context';
6import { useObservableState } from './internal';
7
8/**
9 * @alpha
10 */
11export interface UseIsEncryptedOptions {
12 room?: Room;
13}
14
15/**
16 * @alpha
17 */
18export function useIsEncrypted(participant?: Participant, options: UseIsEncryptedOptions = {}) {
19 const p = useEnsureParticipant(participant);
20
21 const room = useEnsureRoom(options.room);
22
23 const observer = React.useMemo(() => encryptionStatusObservable(room, p), [room, p]);
24 const isEncrypted = useObservableState(
25 observer,
26 p.isLocal ? (p as LocalParticipant).isE2EEEnabled : !!p?.isEncrypted,
27 );
28 return isEncrypted;
29}
Added web/src/shared/livekit/react/hooks/useIsMuted.ts web/src/shared/livekit/react/hooks/useIsMuted.ts +51 −0 View web/src/shared/livekit/react/hooks/useIsMuted.ts
@@ -0,0 +1,51 @@
1import {
2 type TrackReferenceOrPlaceholder,
3 getTrackReferenceId,
4 mutedObserver,
5} from '@livekit/components-core';
6import type { Participant, Track } from 'livekit-client';
7import * as React from 'react';
8import { useEnsureParticipant } from '../context';
9
10/** @public */
11export interface UseIsMutedOptions {
12 participant?: Participant;
13}
14
15/**
16 * The `useIsMuted` hook is used to implement the `TrackMutedIndicator` or your custom implementation of it.
17 * It returns a `boolean` that indicates if the track is muted or not.
18 *
19 * @example With a track reference
20 * ```tsx
21 * const isMuted = useIsMuted(track);
22 * ```
23 *
24 * @param trackRef - A `TrackReference` indicating the track to monitor.
25 * @returns boolean indicating if the track is muted
26 *
27 * @public
28 */
29export function useIsMuted(trackRef: TrackReferenceOrPlaceholder): boolean;
30export function useIsMuted(
31 sourceOrTrackRef: TrackReferenceOrPlaceholder | Track.Source,
32 options: UseIsMutedOptions = {},
33) {
34 const passedParticipant =
35 typeof sourceOrTrackRef === 'string' ? options.participant : sourceOrTrackRef.participant;
36 const p = useEnsureParticipant(passedParticipant);
37 const ref =
38 typeof sourceOrTrackRef === 'string'
39 ? { participant: p, source: sourceOrTrackRef }
40 : sourceOrTrackRef;
41 const [isMuted, setIsMuted] = React.useState(
42 !!(ref.publication?.isMuted || p.getTrackPublication(ref.source)?.isMuted),
43 );
44
45 React.useEffect(() => {
46 const listener = mutedObserver(ref).subscribe(setIsMuted);
47 return () => listener.unsubscribe();
48 }, [getTrackReferenceId(ref)]);
49
50 return isMuted;
51}
Added web/src/shared/livekit/react/hooks/useIsRecording.ts web/src/shared/livekit/react/hooks/useIsRecording.ts +23 −0 View web/src/shared/livekit/react/hooks/useIsRecording.ts
@@ -0,0 +1,23 @@
1import { recordingStatusObservable } from '@livekit/components-core';
2import * as React from 'react';
3import { useEnsureRoom } from '../context';
4import { useObservableState } from './internal';
5import { useConnectionState } from './useConnectionStatus';
6import type { Room } from 'livekit-client';
7
8/**
9 * The `useIsRecording` hook returns a `boolean` that indicates if the room is currently being recorded.
10 * @example
11 * ```tsx
12 * const isRecording = useIsRecording();
13 * ```
14 * @public
15 */
16export function useIsRecording(room?: Room) {
17 const r = useEnsureRoom(room);
18 const connectionState = useConnectionState(r);
19 const observable = React.useMemo(() => recordingStatusObservable(r), [r, connectionState]);
20 const isRecording = useObservableState(observable, r.isRecording);
21
22 return isRecording;
23}
Added web/src/shared/livekit/react/hooks/useIsSpeaking.ts web/src/shared/livekit/react/hooks/useIsSpeaking.ts +21 −0 View web/src/shared/livekit/react/hooks/useIsSpeaking.ts
@@ -0,0 +1,21 @@
1import { createIsSpeakingObserver } from '@livekit/components-core';
2import type { Participant } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureParticipant } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useIsSpeaking` hook returns a `boolean` that indicates if the participant is speaking or not.
9 * @example
10 * ```tsx
11 * const isSpeaking = useIsSpeaking(participant);
12 * ```
13 * @public
14 */
15export function useIsSpeaking(participant?: Participant) {
16 const p = useEnsureParticipant(participant);
17 const observable = React.useMemo(() => createIsSpeakingObserver(p), [p]);
18 const isSpeaking = useObservableState(observable, p.isSpeaking);
19
20 return isSpeaking;
21}
Added web/src/shared/livekit/react/hooks/useLiveKitRoom.ts web/src/shared/livekit/react/hooks/useLiveKitRoom.ts +186 −0 View web/src/shared/livekit/react/hooks/useLiveKitRoom.ts
@@ -0,0 +1,186 @@
1import { log, setupLiveKitRoom } from '@livekit/components-core';
2import type { DisconnectReason } from 'livekit-client';
3import { Room, MediaDeviceFailure, RoomEvent } from 'livekit-client';
4import * as React from 'react';
5import type { HTMLAttributes } from 'react';
6
7import type { LiveKitRoomProps } from '../components';
8import { mergeProps } from '../mergeProps';
9import { roomOptionsStringifyReplacer } from '../utils';
10
11const defaultRoomProps: Partial<LiveKitRoomProps> = {
12 connect: true,
13 audio: false,
14 video: false,
15};
16
17/**
18 * The `useLiveKitRoom` hook is used to implement the `LiveKitRoom` or your custom implementation of it.
19 * It returns a `Room` instance and HTML props that should be applied to the root element of the component.
20 *
21 * @example
22 * ```tsx
23 * const { room, htmlProps } = useLiveKitRoom();
24 * return <div {...htmlProps}>...</div>;
25 * ```
26 * @public
27 */
28export function useLiveKitRoom<T extends HTMLElement>(
29 props: LiveKitRoomProps,
30): {
31 room: Room | undefined;
32 htmlProps: HTMLAttributes<T>;
33} {
34 const {
35 token,
36 serverUrl,
37 options,
38 room: passedRoom,
39 connectOptions,
40 connect,
41 audio,
42 video,
43 screen,
44 onConnected,
45 onDisconnected,
46 onError,
47 onMediaDeviceFailure,
48 onEncryptionError,
49 simulateParticipants,
50 ...rest
51 } = { ...defaultRoomProps, ...props };
52 if (options && passedRoom) {
53 log.warn(
54 'when using a manually created room, the options object will be ignored. set the desired options directly when creating the room instead.',
55 );
56 }
57
58 const [room, setRoom] = React.useState<Room | undefined>();
59
60 const shouldConnect = React.useRef(connect);
61
62 React.useEffect(() => {
63 setRoom(passedRoom ?? new Room(options));
64 }, [passedRoom, JSON.stringify(options, roomOptionsStringifyReplacer)]);
65
66 const htmlProps = React.useMemo(() => {
67 const { className } = setupLiveKitRoom();
68 return mergeProps(rest, { className }) as HTMLAttributes<T>;
69 }, [rest]);
70
71 React.useEffect(() => {
72 if (!room) return;
73 const onSignalConnected = () => {
74 const localP = room.localParticipant;
75
76 log.debug('trying to publish local tracks');
77 Promise.all([
78 localP.setMicrophoneEnabled(!!audio, typeof audio !== 'boolean' ? audio : undefined),
79 localP.setCameraEnabled(!!video, typeof video !== 'boolean' ? video : undefined),
80 localP.setScreenShareEnabled(!!screen, typeof screen !== 'boolean' ? screen : undefined),
81 ]).catch((e) => {
82 log.warn(e);
83 onError?.(e as Error);
84 });
85 };
86
87 const handleMediaDeviceError = (e: Error, kind?: MediaDeviceKind) => {
88 const mediaDeviceFailure = MediaDeviceFailure.getFailure(e);
89 onMediaDeviceFailure?.(mediaDeviceFailure, kind);
90 };
91 const handleEncryptionError = (e: Error) => {
92 onEncryptionError?.(e);
93 };
94 const handleDisconnected = (reason?: DisconnectReason) => {
95 onDisconnected?.(reason);
96 };
97 const handleConnected = () => {
98 onConnected?.();
99 };
100
101 room
102 .on(RoomEvent.SignalConnected, onSignalConnected)
103 .on(RoomEvent.MediaDevicesError, handleMediaDeviceError)
104 .on(RoomEvent.EncryptionError, handleEncryptionError)
105 .on(RoomEvent.Disconnected, handleDisconnected)
106 .on(RoomEvent.Connected, handleConnected);
107
108 return () => {
109 room
110 .off(RoomEvent.SignalConnected, onSignalConnected)
111 .off(RoomEvent.MediaDevicesError, handleMediaDeviceError)
112 .off(RoomEvent.EncryptionError, handleEncryptionError)
113 .off(RoomEvent.Disconnected, handleDisconnected)
114 .off(RoomEvent.Connected, handleConnected);
115 };
116 }, [
117 room,
118 audio,
119 video,
120 screen,
121 onError,
122 onEncryptionError,
123 onMediaDeviceFailure,
124 onConnected,
125 onDisconnected,
126 ]);
127
128 React.useEffect(() => {
129 if (!room) return;
130
131 if (simulateParticipants) {
132 room.simulateParticipants({
133 participants: {
134 count: simulateParticipants,
135 },
136 publish: {
137 audio: true,
138 useRealTracks: true,
139 },
140 });
141 return;
142 }
143
144 if (connect) {
145 shouldConnect.current = true;
146 log.debug('connecting');
147 if (!token) {
148 log.debug('no token yet');
149 return;
150 }
151 if (!serverUrl) {
152 log.warn('no livekit url provided');
153 onError?.(Error('no livekit url provided'));
154 return;
155 }
156 room.connect(serverUrl, token, connectOptions).catch((e) => {
157 log.warn(e);
158 if (shouldConnect.current === true) {
159 onError?.(e as Error);
160 }
161 });
162 } else {
163 log.debug('disconnecting because connect is false');
164 shouldConnect.current = false;
165 room.disconnect();
166 }
167 }, [
168 connect,
169 token,
170 JSON.stringify(connectOptions),
171 room,
172 onError,
173 serverUrl,
174 simulateParticipants,
175 ]);
176
177 React.useEffect(() => {
178 if (!room) return;
179 return () => {
180 log.info('disconnecting on onmount');
181 room.disconnect();
182 };
183 }, [room]);
184
185 return { room, htmlProps };
186}
Added web/src/shared/livekit/react/hooks/useLocalParticipant.ts web/src/shared/livekit/react/hooks/useLocalParticipant.ts +73 −0 View web/src/shared/livekit/react/hooks/useLocalParticipant.ts
@@ -0,0 +1,73 @@
1import type { ParticipantMedia } from '@livekit/components-core';
2import { observeParticipantMedia } from '@livekit/components-core';
3import type { TrackPublication, LocalParticipant, Room } from 'livekit-client';
4import * as React from 'react';
5import { useEnsureRoom } from '../context';
6
7/** @public */
8export interface UseLocalParticipantOptions {
9 /**
10 * The room to use. If not provided, the hook will use the room from the context.
11 */
12 room?: Room;
13}
14
15/**
16 * The `useLocalParticipant` hook returns the local participant and the associated state
17 * around the participant.
18 *
19 * @example
20 * ```tsx
21 * const { localParticipant } = useLocalParticipant();
22 * ```
23 * @public
24 */
25export function useLocalParticipant(options: UseLocalParticipantOptions = {}) {
26 const room = useEnsureRoom(options.room);
27 const [localParticipant, setLocalParticipant] = React.useState(room.localParticipant);
28
29 const [isMicrophoneEnabled, setIsMicrophoneEnabled] = React.useState(
30 localParticipant.isMicrophoneEnabled,
31 );
32 const [isCameraEnabled, setIsCameraEnabled] = React.useState(localParticipant.isCameraEnabled);
33 const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(
34 localParticipant.isScreenShareEnabled,
35 );
36
37 const [lastMicrophoneError, setLastMicrophoneError] = React.useState(
38 localParticipant.lastMicrophoneError,
39 );
40 const [lastCameraError, setLastCameraError] = React.useState(localParticipant.lastCameraError);
41
42 const [microphoneTrack, setMicrophoneTrack] = React.useState<TrackPublication | undefined>(
43 undefined,
44 );
45 const [cameraTrack, setCameraTrack] = React.useState<TrackPublication | undefined>(undefined);
46
47 const handleUpdate = (media: ParticipantMedia<LocalParticipant>) => {
48 setIsCameraEnabled(media.isCameraEnabled);
49 setIsMicrophoneEnabled(media.isMicrophoneEnabled);
50 setIsScreenShareEnabled(media.isScreenShareEnabled);
51 setCameraTrack(media.cameraTrack);
52 setMicrophoneTrack(media.microphoneTrack);
53 setLastMicrophoneError(media.participant.lastMicrophoneError);
54 setLastCameraError(media.participant.lastCameraError);
55 setLocalParticipant(media.participant);
56 };
57 React.useEffect(() => {
58 const listener = observeParticipantMedia(room.localParticipant).subscribe(handleUpdate);
59 // TODO also listen to permission and metadata etc. events
60 return () => listener.unsubscribe();
61 }, [room]);
62
63 return {
64 isMicrophoneEnabled,
65 isScreenShareEnabled,
66 isCameraEnabled,
67 microphoneTrack,
68 cameraTrack,
69 lastMicrophoneError,
70 lastCameraError,
71 localParticipant,
72 };
73}
Added web/src/shared/livekit/react/hooks/useLocalParticipantPermissions.ts web/src/shared/livekit/react/hooks/useLocalParticipantPermissions.ts +24 −0 View web/src/shared/livekit/react/hooks/useLocalParticipantPermissions.ts
@@ -0,0 +1,24 @@
1import { participantPermissionObserver } from '@livekit/components-core';
2import type { ParticipantPermission } from '@livekit/protocol';
3import * as React from 'react';
4import { useRoomContext } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useLocalParticipantPermissions` hook returns the local participant's permissions.
9 *
10 * @example
11 * ```tsx
12 * const { canPublish, canPublishData } = useLocalParticipantPermissions();
13 * ```
14 * @public
15 */
16export function useLocalParticipantPermissions(): ParticipantPermission | undefined {
17 const room = useRoomContext();
18 const permissionObserver = React.useMemo(
19 () => participantPermissionObserver(room.localParticipant),
20 [room],
21 );
22 const permissions = useObservableState(permissionObserver, room.localParticipant.permissions);
23 return permissions;
24}
Added web/src/shared/livekit/react/hooks/useMediaDevices.ts web/src/shared/livekit/react/hooks/useMediaDevices.ts +28 −0 View web/src/shared/livekit/react/hooks/useMediaDevices.ts
@@ -0,0 +1,28 @@
1import * as React from 'react';
2import { useObservableState } from './internal';
3import { createMediaDeviceObserver } from '@livekit/components-core';
4
5/**
6 * The `useMediaDevices` hook returns the list of media devices of a given kind.
7 *
8 * @example
9 * ```tsx
10 * const videoDevices = useMediaDevices({ kind: 'videoinput' });
11 * const audioDevices = useMediaDevices({ kind: 'audioinput' });
12 * ```
13 * @public
14 */
15export function useMediaDevices({
16 kind,
17 onError,
18}: {
19 kind: MediaDeviceKind;
20 onError?: (e: Error) => void;
21}) {
22 const deviceObserver = React.useMemo(
23 () => createMediaDeviceObserver(kind, onError),
24 [kind, onError],
25 );
26 const devices = useObservableState(deviceObserver, [] as MediaDeviceInfo[]);
27 return devices;
28}
Added web/src/shared/livekit/react/hooks/useMediaDeviceSelect.ts web/src/shared/livekit/react/hooks/useMediaDeviceSelect.ts +81 −0 View web/src/shared/livekit/react/hooks/useMediaDeviceSelect.ts
@@ -0,0 +1,81 @@
1import { createMediaDeviceObserver, setupDeviceSelector, log } from '@livekit/components-core';
2import { Room, type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client';
3import * as React from 'react';
4import { useMaybeRoomContext } from '../context';
5import { useObservableState } from './internal';
6
7/** @public */
8export interface UseMediaDeviceSelectProps {
9 kind: MediaDeviceKind;
10 room?: Room;
11 track?: LocalAudioTrack | LocalVideoTrack;
12 /**
13 * this will call getUserMedia if the permissions are not yet given to enumerate the devices with device labels.
14 * in some browsers multiple calls to getUserMedia result in multiple permission prompts.
15 * It's generally advised only flip this to true, once a (preview) track has been acquired successfully with the
16 * appropriate permissions.
17 *
18 * @see {@link MediaDeviceMenu}
19 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices | MDN enumerateDevices}
20 */
21 requestPermissions?: boolean;
22 /**
23 * this callback gets called if an error is thrown when failing to select a device and also if a user
24 * denied permissions, eventhough the `requestPermissions` option is set to `true`.
25 * Most commonly this will emit a MediaDeviceError
26 */
27 onError?: (e: Error) => void;
28}
29
30/**
31 * The `useMediaDeviceSelect` hook is used to implement the `MediaDeviceSelect` component and
32 * returns o.a. the list of devices of a given kind (audioinput or videoinput), the currently active device
33 * and a function to set the the active device.
34 *
35 * @example
36 * ```tsx
37 * const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({kind: 'audioinput'});
38 * ```
39 * @public
40 */
41export function useMediaDeviceSelect({
42 kind,
43 room,
44 track,
45 requestPermissions,
46 onError,
47}: UseMediaDeviceSelectProps) {
48 const roomContext = useMaybeRoomContext();
49
50 const roomFallback = React.useMemo(() => room ?? roomContext ?? new Room(), [room, roomContext]);
51
52 // List of all devices.
53 const deviceObserver = React.useMemo(
54 () => createMediaDeviceObserver(kind, onError, requestPermissions),
55 [kind, requestPermissions, onError],
56 );
57 const devices = useObservableState(deviceObserver, [] as MediaDeviceInfo[]);
58 // Active device management.
59 const [currentDeviceId, setCurrentDeviceId] = React.useState<string>(
60 roomFallback?.getActiveDevice(kind) ?? 'default',
61 );
62 const { className, activeDeviceObservable, setActiveMediaDevice } = React.useMemo(
63 () => setupDeviceSelector(kind, roomFallback, track),
64 [kind, roomFallback, track],
65 );
66
67 React.useEffect(() => {
68 const listener = activeDeviceObservable.subscribe((deviceId) => {
69 if (!deviceId) {
70 return;
71 }
72 log.info('setCurrentDeviceId', deviceId);
73 setCurrentDeviceId(deviceId);
74 });
75 return () => {
76 listener?.unsubscribe();
77 };
78 }, [activeDeviceObservable]);
79
80 return { devices, className, activeDeviceId: currentDeviceId, setActiveMediaDevice };
81}
Added web/src/shared/livekit/react/hooks/useMediaTrackBySourceOrName.ts web/src/shared/livekit/react/hooks/useMediaTrackBySourceOrName.ts +97 −0 View web/src/shared/livekit/react/hooks/useMediaTrackBySourceOrName.ts
@@ -0,0 +1,97 @@
1import type { TrackIdentifier } from '@livekit/components-core';
2import {
3 getTrackByIdentifier,
4 isTrackReference,
5 log,
6 setupMediaTrack,
7} from '@livekit/components-core';
8import * as React from 'react';
9import { mergeProps } from '../utils';
10
11/** @public */
12export interface UseMediaTrackOptions {
13 element?: React.RefObject<HTMLMediaElement | null> | null;
14 props?: React.HTMLAttributes<HTMLVideoElement | HTMLAudioElement>;
15}
16
17/**
18 * @internal
19 */
20export function useMediaTrackBySourceOrName(
21 observerOptions: TrackIdentifier,
22 options: UseMediaTrackOptions = {},
23) {
24 const [publication, setPublication] = React.useState(getTrackByIdentifier(observerOptions));
25
26 const [isMuted, setMuted] = React.useState(publication?.isMuted);
27 const [isSubscribed, setSubscribed] = React.useState(publication?.isSubscribed);
28
29 const [track, setTrack] = React.useState(publication?.track);
30 const [orientation, setOrientation] = React.useState<'landscape' | 'portrait'>('landscape');
31 const previousElement = React.useRef<HTMLMediaElement | undefined | null>(null);
32
33 const { className, trackObserver } = React.useMemo(() => {
34 return setupMediaTrack(observerOptions);
35 }, [
36 observerOptions.participant.sid ?? observerOptions.participant.identity,
37 observerOptions.source,
38 isTrackReference(observerOptions) && observerOptions.publication.trackSid,
39 ]);
40
41 React.useEffect(() => {
42 const subscription = trackObserver.subscribe((publication) => {
43 log.debug('update track', publication);
44 setPublication(publication);
45 setMuted(publication?.isMuted);
46 setSubscribed(publication?.isSubscribed);
47 setTrack(publication?.track);
48 });
49 return () => subscription?.unsubscribe();
50 }, [trackObserver]);
51
52 React.useEffect(() => {
53 if (track) {
54 if (previousElement.current) {
55 track.detach(previousElement.current);
56 }
57 if (
58 options.element?.current &&
59 !(observerOptions.participant.isLocal && track?.kind === 'audio')
60 ) {
61 track.attach(options.element.current);
62 }
63 }
64 previousElement.current = options.element?.current;
65 return () => {
66 if (previousElement.current) {
67 track?.detach(previousElement.current);
68 }
69 };
70 }, [track, options.element]);
71
72 React.useEffect(() => {
73 // Set the orientation of the video track.
74 // TODO: This does not handle changes in orientation after a track got published (e.g when rotating a phone camera from portrait to landscape).
75 if (
76 typeof publication?.dimensions?.width === 'number' &&
77 typeof publication?.dimensions?.height === 'number'
78 ) {
79 const orientation_ =
80 publication.dimensions.width > publication.dimensions.height ? 'landscape' : 'portrait';
81 setOrientation(orientation_);
82 }
83 }, [publication]);
84
85 return {
86 publication,
87 isMuted,
88 isSubscribed,
89 track,
90 elementProps: mergeProps(options.props, {
91 className,
92 'data-lk-local-participant': observerOptions.participant.isLocal,
93 'data-lk-source': publication?.source,
94 ...(publication?.kind === 'video' && { 'data-lk-orientation': orientation }),
95 }),
96 };
97}
Added web/src/shared/livekit/react/hooks/usePagination.ts web/src/shared/livekit/react/hooks/usePagination.ts +67 −0 View web/src/shared/livekit/react/hooks/usePagination.ts
@@ -0,0 +1,67 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import * as React from 'react';
3import { useVisualStableUpdate } from './useVisualStableUpdate';
4
5/**
6 * The `usePagination` hook implements simple pagination logic for use with arrays.
7 * @example
8 * ```tsx
9 * const tracks = useTracks();
10 * const pagination = usePagination(4, tracks);
11 *
12 * <TrackLoop tracks={pagination.tracks} />
13 * ```
14 * @alpha
15 */
16export function usePagination(itemPerPage: number, trackReferences: TrackReferenceOrPlaceholder[]) {
17 const [currentPage, setCurrentPage] = React.useState(1);
18 const totalPageCount = Math.max(Math.ceil(trackReferences.length / itemPerPage), 1);
19 if (currentPage > totalPageCount) {
20 setCurrentPage(totalPageCount);
21 }
22 const lastItemIndex = currentPage * itemPerPage;
23 const firstItemIndex = lastItemIndex - itemPerPage;
24
25 const changePage = (direction: 'next' | 'previous') => {
26 setCurrentPage((state) => {
27 if (direction === 'next') {
28 if (state === totalPageCount) {
29 return state;
30 }
31 return state + 1;
32 } else {
33 if (state === 1) {
34 return state;
35 }
36 return state - 1;
37 }
38 });
39 };
40
41 const goToPage = (num: number) => {
42 if (num > totalPageCount) {
43 setCurrentPage(totalPageCount);
44 } else if (num < 1) {
45 setCurrentPage(1);
46 } else {
47 setCurrentPage(num);
48 }
49 };
50
51 const updatedTrackReferences = useVisualStableUpdate(trackReferences, itemPerPage);
52
53 const tracksOnPage = updatedTrackReferences.slice(firstItemIndex, lastItemIndex);
54
55 return {
56 totalPageCount,
57 nextPage: () => changePage('next'),
58 prevPage: () => changePage('previous'),
59 setPage: goToPage,
60 firstItemIndex,
61 lastItemIndex,
62 tracks: tracksOnPage,
63 currentPage,
64 };
65}
66
67export default usePagination;
Added web/src/shared/livekit/react/hooks/useParticipantAttributes.ts web/src/shared/livekit/react/hooks/useParticipantAttributes.ts +70 −0 View web/src/shared/livekit/react/hooks/useParticipantAttributes.ts
@@ -0,0 +1,70 @@
1import { participantAttributesObserver } from '@livekit/components-core';
2import type { Participant } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureParticipant, useMaybeParticipantContext } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useParticipantAttributes` hook returns the attributes of a given participant and allows to set them.
9 * It requires a `Participant` object passed as property or via the `ParticipantContext`.
10 *
11 * @example
12 * ```tsx
13 * const { attributes } = useParticipantAttributes({ participant });
14 * ```
15 * @public
16 */
17export interface UseParticipantAttributesOptions {
18 participant?: Participant;
19}
20
21/** @public */
22export function useParticipantAttributes(props: UseParticipantAttributesOptions = {}) {
23 const participantContext = useMaybeParticipantContext();
24 const p = props.participant ?? participantContext;
25 const attributeObserver = React.useMemo(
26 // weird typescript constraint
27 () => (p ? participantAttributesObserver(p) : undefined),
28 [p],
29 );
30 const attributeState = useObservableState(attributeObserver, {
31 changed: {},
32 attributes: p?.attributes ?? {},
33 });
34
35 return attributeState;
36}
37
38/**
39 * The `useParticipantAttribute` hook returns the latest value of a given attribute key of a participant.
40 * It requires a `Participant` object passed as property in the `UseParticipantAttributesOptions` or via the `ParticipantContext`.
41 *
42 * @example
43 * ```tsx
44 * const myAttributeValue = useParticipantAttribute('targetAttributeName');
45 * ```
46 * @public
47 */
48export function useParticipantAttribute(
49 attributeKey: string,
50 options: UseParticipantAttributesOptions = {},
51) {
52 const p = useEnsureParticipant(options.participant);
53 const [attribute, setAttribute] = React.useState(p.attributes[attributeKey]);
54
55 React.useEffect(() => {
56 if (!p) {
57 return;
58 }
59 const subscription = participantAttributesObserver(p).subscribe((val) => {
60 if (val.changed[attributeKey] !== undefined) {
61 setAttribute(val.attributes[attributeKey]);
62 }
63 });
64 return () => {
65 subscription.unsubscribe();
66 };
67 }, [p, attributeKey]);
68
69 return attribute;
70}
Added web/src/shared/livekit/react/hooks/useParticipantInfo.ts web/src/shared/livekit/react/hooks/useParticipantInfo.ts +35 −0 View web/src/shared/livekit/react/hooks/useParticipantInfo.ts
@@ -0,0 +1,35 @@
1import { participantInfoObserver } from '@livekit/components-core';
2import type { Participant } from 'livekit-client';
3import * as React from 'react';
4import { useMaybeParticipantContext } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useParticipantInfo` hook returns the identity, name, and metadata of a given participant.
9 * It requires a `Participant` object passed as property or via the `ParticipantContext`.
10 *
11 * @example
12 * ```tsx
13 * const { identity, name, metadata } = useParticipantInfo({ participant });
14 * ```
15 * @public
16 */
17export interface UseParticipantInfoOptions {
18 participant?: Participant;
19}
20
21/** @public */
22export function useParticipantInfo(props: UseParticipantInfoOptions = {}) {
23 let p = useMaybeParticipantContext();
24 if (props.participant) {
25 p = props.participant;
26 }
27 const infoObserver = React.useMemo(() => participantInfoObserver(p), [p]);
28 const { identity, name, metadata } = useObservableState(infoObserver, {
29 name: p?.name,
30 identity: p?.identity,
31 metadata: p?.metadata,
32 });
33
34 return { identity, name, metadata };
35}
Added web/src/shared/livekit/react/hooks/useParticipantPermissions.ts web/src/shared/livekit/react/hooks/useParticipantPermissions.ts +29 −0 View web/src/shared/livekit/react/hooks/useParticipantPermissions.ts
@@ -0,0 +1,29 @@
1import { participantPermissionObserver } from '@livekit/components-core';
2import type { ParticipantPermission } from '@livekit/protocol';
3import type { Participant } from 'livekit-client';
4import * as React from 'react';
5import { useEnsureParticipant } from '../context';
6import { useObservableState } from './internal/useObservableState';
7
8/**
9 * The `useParticipantPermissions` hook returns the permissions of a given participant.
10 *
11 * @example
12 * ```tsx
13 * const permissions = useParticipantPermissions({ participant });
14 * ```
15 * @public
16 */
17export interface UseParticipantPermissionsOptions {
18 participant?: Participant;
19}
20
21/** @public */
22export function useParticipantPermissions(
23 options: UseParticipantPermissionsOptions = {},
24): ParticipantPermission | undefined {
25 const p = useEnsureParticipant(options.participant);
26 const permissionObserver = React.useMemo(() => participantPermissionObserver(p), [p]);
27 const permissions = useObservableState(permissionObserver, p.permissions);
28 return permissions;
29}
Added web/src/shared/livekit/react/hooks/useParticipants.ts web/src/shared/livekit/react/hooks/useParticipants.ts +42 −0 View web/src/shared/livekit/react/hooks/useParticipants.ts
@@ -0,0 +1,42 @@
1import type { Room, RoomEvent } from 'livekit-client';
2import { useLocalParticipant } from './useLocalParticipant';
3import { useRemoteParticipants } from './useRemoteParticipants';
4import * as React from 'react';
5
6/** @public */
7export interface UseParticipantsOptions {
8 /**
9 * To optimize performance, you can use the `updateOnlyOn` property to decide on what RoomEvents the hook updates.
10 * By default it updates on all relevant RoomEvents to keep the returned participants array up to date.
11 * The minimal set of non-overwriteable `RoomEvents` is: `[RoomEvent.ParticipantConnected, RoomEvent.ParticipantDisconnected, RoomEvent.ConnectionStateChanged]`
12 */
13 updateOnlyOn?: RoomEvent[];
14 /**
15 * The room to use. If not provided, the hook will use the room from the context.
16 */
17 room?: Room;
18}
19
20/**
21 * The `useParticipants` hook returns all participants (local and remote) of the current room.
22 * @remarks
23 * To optimize performance, you can use the `updateOnlyOn` property to decide on what `RoomEvents` the hook updates.
24 *
25 * @example
26 * ```tsx
27 * const participants = useParticipants();
28 * <ParticipantLoop participants={participants}>
29 * <ParticipantName />
30 * </ParticipantLoop>
31 * ```
32 * @public
33 */
34export function useParticipants(options: UseParticipantsOptions = {}) {
35 const remoteParticipants = useRemoteParticipants(options);
36 const { localParticipant } = useLocalParticipant(options);
37
38 return React.useMemo(
39 () => [localParticipant, ...remoteParticipants],
40 [localParticipant, remoteParticipants],
41 );
42}
Added web/src/shared/livekit/react/hooks/useParticipantTile.ts web/src/shared/livekit/react/hooks/useParticipantTile.ts +81 −0 View web/src/shared/livekit/react/hooks/useParticipantTile.ts
@@ -0,0 +1,81 @@
1import type { ParticipantClickEvent, TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import { setupParticipantTile } from '@livekit/components-core';
3import * as React from 'react';
4import { useEnsureTrackRef } from '../context';
5import { mergeProps } from '../mergeProps';
6import { useFacingMode } from './useFacingMode';
7import { useIsMuted } from './useIsMuted';
8import { useIsSpeaking } from './useIsSpeaking';
9import { Track } from 'livekit-client';
10
11/** @public */
12export interface UseParticipantTileProps<T extends HTMLElement> extends React.HTMLAttributes<T> {
13 /** The track reference to display. */
14 trackRef?: TrackReferenceOrPlaceholder;
15 disableSpeakingIndicator?: boolean;
16 onParticipantClick?: (event: ParticipantClickEvent) => void;
17 htmlProps: React.HTMLAttributes<T>;
18}
19
20/**
21 * The `useParticipantTile` hook is used to implement the `ParticipantTile` and returns the props needed to render the tile.
22 * @remarks
23 * The returned props include many data attributes that are useful for CSS styling purposes because they
24 * indicate the state of the participant and the track.
25 * For example: `data-lk-audio-muted`, `data-lk-video-muted`, `data-lk-speaking`, `data-lk-local-participant`, `data-lk-source`, `data-lk-facing-mode`.
26 * @public
27 */
28export function useParticipantTile<T extends HTMLElement>({
29 trackRef,
30 onParticipantClick,
31 disableSpeakingIndicator,
32 htmlProps,
33}: UseParticipantTileProps<T>) {
34 const trackReference = useEnsureTrackRef(trackRef);
35
36 const mergedProps = React.useMemo(() => {
37 const { className } = setupParticipantTile();
38 return mergeProps(htmlProps, {
39 className,
40 onClick: (event: React.MouseEvent<T, MouseEvent>) => {
41 htmlProps.onClick?.(event);
42 if (typeof onParticipantClick === 'function') {
43 const track =
44 trackReference.publication ??
45 trackReference.participant.getTrackPublication(trackReference.source);
46 onParticipantClick({ participant: trackReference.participant, track });
47 }
48 },
49 });
50 }, [
51 htmlProps,
52 onParticipantClick,
53 trackReference.publication,
54 trackReference.source,
55 trackReference.participant,
56 ]);
57
58 const micTrack = trackReference.participant.getTrackPublication(Track.Source.Microphone);
59 const micRef = React.useMemo(() => {
60 return {
61 participant: trackReference.participant,
62 source: Track.Source.Microphone,
63 publication: micTrack,
64 };
65 }, [micTrack, trackReference.participant]);
66 const isVideoMuted = useIsMuted(trackReference);
67 const isAudioMuted = useIsMuted(micRef);
68 const isSpeaking = useIsSpeaking(trackReference.participant);
69 const facingMode = useFacingMode(trackReference);
70 return {
71 elementProps: {
72 'data-lk-audio-muted': isAudioMuted,
73 'data-lk-video-muted': isVideoMuted,
74 'data-lk-speaking': disableSpeakingIndicator === true ? false : isSpeaking,
75 'data-lk-local-participant': trackReference.participant.isLocal,
76 'data-lk-source': trackReference.source,
77 'data-lk-facing-mode': facingMode,
78 ...mergedProps,
79 } as React.HTMLAttributes<T>,
80 };
81}
Added web/src/shared/livekit/react/hooks/useParticipantTracks.ts web/src/shared/livekit/react/hooks/useParticipantTracks.ts +53 −0 View web/src/shared/livekit/react/hooks/useParticipantTracks.ts
@@ -0,0 +1,53 @@
1import * as React from 'react';
2import type { TrackReference } from '@livekit/components-core';
3import { participantTracksObservable } from '@livekit/components-core';
4import { useObservableState } from './internal';
5import type { Room, Track } from 'livekit-client';
6import { useMaybeParticipantContext } from '../context';
7import { useParticipants } from './useParticipants';
8
9type UseParticipantTracksOptions = {
10 participantIdentity?: string;
11 room?: Room;
12};
13
14/**
15 * `useParticipantTracks` is a custom React that allows you to get tracks of a specific participant only, by specifiying the participant's identity.
16 * If the participant identity is not passed the hook will try to get the participant from a participant context.
17 * @public
18 */
19export function useParticipantTracks<TrackSource extends Track.Source>(
20 sources: Array<TrackSource>,
21 optionsOrParticipantIdentity:
22 UseParticipantTracksOptions | UseParticipantTracksOptions['participantIdentity'] = {},
23): Array<TrackReference> {
24 let participantIdentity: UseParticipantTracksOptions['participantIdentity'];
25 let room: UseParticipantTracksOptions['room'];
26 if (typeof optionsOrParticipantIdentity === 'string') {
27 participantIdentity = optionsOrParticipantIdentity;
28 } else {
29 participantIdentity = optionsOrParticipantIdentity?.participantIdentity;
30 room = optionsOrParticipantIdentity?.room;
31 }
32
33 const participantContext = useMaybeParticipantContext();
34 const participants = useParticipants({ room, updateOnlyOn: [] });
35
36 const p = React.useMemo(() => {
37 if (participantIdentity) {
38 return participants.find((p) => p.identity === participantIdentity);
39 }
40 return participantContext;
41 }, [participantIdentity, participants, participantContext]);
42
43 const observable = React.useMemo(() => {
44 if (!p) {
45 return undefined;
46 }
47 return participantTracksObservable(p, { sources });
48 }, [p, JSON.stringify(sources)]);
49
50 const trackRefs = useObservableState(observable, [] as Array<TrackReference>);
51
52 return trackRefs;
53}
Added web/src/shared/livekit/react/hooks/usePersistentUserChoices.ts web/src/shared/livekit/react/hooks/usePersistentUserChoices.ts +64 −0 View web/src/shared/livekit/react/hooks/usePersistentUserChoices.ts
@@ -0,0 +1,64 @@
1import type { LocalUserChoices } from '@livekit/components-core';
2import { loadUserChoices, saveUserChoices } from '@livekit/components-core';
3import * as React from 'react';
4
5/**
6 * Options for the `usePersistentDeviceSettings` hook.
7 * @alpha
8 */
9export interface UsePersistentUserChoicesOptions {
10 /**
11 * The default value to use if reading from local storage returns no results or fails.
12 */
13 defaults?: Partial<LocalUserChoices>;
14 /**
15 * Whether to prevent saving to persistent storage.
16 * @defaultValue false
17 */
18 preventSave?: boolean;
19 /**
20 * Whether to prevent loading user choices from persistent storage and use `defaults` instead.
21 * @defaultValue false
22 */
23 preventLoad?: boolean;
24}
25
26/**
27 * A hook that provides access to user choices stored in local storage, such as
28 * selected media devices and their current state (on or off), as well as the user name.
29 * @alpha
30 */
31export function usePersistentUserChoices(options: UsePersistentUserChoicesOptions = {}) {
32 const [userChoices, setSettings] = React.useState<LocalUserChoices>(
33 loadUserChoices(options.defaults, options.preventLoad ?? false),
34 );
35
36 const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
37 setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }));
38 }, []);
39 const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
40 setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }));
41 }, []);
42 const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
43 setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }));
44 }, []);
45 const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
46 setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }));
47 }, []);
48 const saveUsername = React.useCallback((username: string) => {
49 setSettings((prev) => ({ ...prev, username: username }));
50 }, []);
51
52 React.useEffect(() => {
53 saveUserChoices(userChoices, options.preventSave ?? false);
54 }, [userChoices, options.preventSave]);
55
56 return {
57 userChoices,
58 saveAudioInputEnabled,
59 saveVideoInputEnabled,
60 saveAudioInputDeviceId,
61 saveVideoInputDeviceId,
62 saveUsername,
63 };
64}
Added web/src/shared/livekit/react/hooks/usePinnedTracks.ts web/src/shared/livekit/react/hooks/usePinnedTracks.ts +24 −0 View web/src/shared/livekit/react/hooks/usePinnedTracks.ts
@@ -0,0 +1,24 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import * as React from 'react';
3import type { LayoutContextType } from '../context';
4import { useEnsureLayoutContext } from '../context';
5
6/**
7 * The `usePinnedTracks` hook returns a array of the pinned tracks of the current room.
8 * @remarks
9 * To function properly, this hook must be called within a `LayoutContext`.
10 * @example
11 * ```tsx
12 * const pinnedTracks = usePinnedTracks();
13 * ```
14 * @public
15 */
16export function usePinnedTracks(layoutContext?: LayoutContextType): TrackReferenceOrPlaceholder[] {
17 layoutContext = useEnsureLayoutContext(layoutContext);
18 return React.useMemo(() => {
19 if (layoutContext?.pin.state !== undefined && layoutContext.pin.state.length >= 1) {
20 return layoutContext.pin.state;
21 }
22 return [];
23 }, [layoutContext.pin.state]);
24}
Added web/src/shared/livekit/react/hooks/useRemoteParticipant.ts web/src/shared/livekit/react/hooks/useRemoteParticipant.ts +79 −0 View web/src/shared/livekit/react/hooks/useRemoteParticipant.ts
@@ -0,0 +1,79 @@
1import {
2 type ParticipantIdentifier,
3 connectedParticipantObserver,
4 participantByIdentifierObserver,
5} from '@livekit/components-core';
6import type { ParticipantEvent, RemoteParticipant } from 'livekit-client';
7import * as React from 'react';
8import { useRoomContext } from '../context';
9
10/** @public */
11export interface UseRemoteParticipantOptions {
12 /**
13 * To optimize performance, you can use the `updateOnlyOn` property to decide on what `ParticipantEvents` the hook updates.
14 * By default it updates on all relevant ParticipantEvents to keep the returned participant up to date.
15 */
16 updateOnlyOn?: ParticipantEvent[];
17}
18
19/**
20 * The `useRemoteParticipant` hook returns the first RemoteParticipant by either identity and/or based on the participant kind.
21 * @remarks
22 * To optimize performance, you can use the `updateOnlyOn` property to decide on what `ParticipantEvents` the hook updates.
23 *
24 * @example
25 * ```tsx
26 * const participant = useRemoteParticipant({kind: ParticipantKind.Agent, identity: 'myAgent'});
27 * ```
28 * @public
29 */
30export function useRemoteParticipant(
31 identifier: ParticipantIdentifier,
32 options?: UseRemoteParticipantOptions,
33): RemoteParticipant | undefined;
34/**
35 * The `useRemoteParticipant` hook returns the first RemoteParticipant by either identity or based on the participant kind.
36 * @remarks
37 * To optimize performance, you can use the `updateOnlyOn` property to decide on what `ParticipantEvents` the hook updates.
38 *
39 * @example
40 * ```tsx
41 * const participant = useRemoteParticipant('Russ');
42 * ```
43 * @public
44 */
45export function useRemoteParticipant(
46 identity: string,
47 options?: UseRemoteParticipantOptions,
48): RemoteParticipant | undefined;
49export function useRemoteParticipant(
50 identityOrIdentifier: string | ParticipantIdentifier,
51 options: UseRemoteParticipantOptions = {},
52): RemoteParticipant | undefined {
53 const room = useRoomContext();
54 const [updateOnlyOn] = React.useState(options.updateOnlyOn);
55
56 const observable = React.useMemo(() => {
57 if (typeof identityOrIdentifier === 'string') {
58 return connectedParticipantObserver(room, identityOrIdentifier, {
59 additionalEvents: updateOnlyOn,
60 });
61 } else {
62 return participantByIdentifierObserver(room, identityOrIdentifier, {
63 additionalEvents: updateOnlyOn,
64 });
65 }
66 }, [room, JSON.stringify(identityOrIdentifier), updateOnlyOn]);
67
68 // Using `wrapperParticipant` to ensure a new object reference,
69 // triggering a re-render when the participant events fire.
70 const [participantWrapper, setParticipantWrapper] = React.useState({
71 p: undefined as RemoteParticipant | undefined,
72 });
73 React.useEffect(() => {
74 const listener = observable.subscribe((p) => setParticipantWrapper({ p }));
75 return () => listener.unsubscribe();
76 }, [observable]);
77
78 return participantWrapper.p;
79}
Added web/src/shared/livekit/react/hooks/useRemoteParticipants.ts web/src/shared/livekit/react/hooks/useRemoteParticipants.ts +45 −0 View web/src/shared/livekit/react/hooks/useRemoteParticipants.ts
@@ -0,0 +1,45 @@
1import { connectedParticipantsObserver } from '@livekit/components-core';
2import type { RoomEvent, RemoteParticipant, Room } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureRoom } from '../context';
5
6/** @public */
7export interface UseRemoteParticipantsOptions {
8 /**
9 * To optimize performance, you can use the `updateOnlyOn` property to decide on what RoomEvents the hook updates.
10 * By default it updates on all relevant RoomEvents to keep the returned participants array up to date.
11 * The minimal set of non-overwriteable `RoomEvents` is: `[RoomEvent.ParticipantConnected, RoomEvent.ParticipantDisconnected, RoomEvent.ConnectionStateChanged]`
12 */
13 updateOnlyOn?: RoomEvent[];
14 /**
15 * The room to use. If not provided, the hook will use the room from the context.
16 */
17 room?: Room;
18}
19
20/**
21 * The `useRemoteParticipants` hook returns all remote participants (without the local) of the current room.
22 * @remarks
23 * To optimize performance, you can use the `updateOnlyOn` property to decide on what `RoomEvents` the hook updates.
24 *
25 * @example
26 * ```tsx
27 * const participants = useRemoteParticipants();
28 * <ParticipantLoop participants={participants}>
29 * <ParticipantName />
30 * </ParticipantLoop>
31 * ```
32 * @public
33 */
34export function useRemoteParticipants(options: UseRemoteParticipantsOptions = {}) {
35 const room = useEnsureRoom(options.room);
36 const [participants, setParticipants] = React.useState<RemoteParticipant[]>([]);
37
38 React.useEffect(() => {
39 const listener = connectedParticipantsObserver(room, {
40 additionalRoomEvents: options.updateOnlyOn,
41 }).subscribe(setParticipants);
42 return () => listener.unsubscribe();
43 }, [room, JSON.stringify(options.updateOnlyOn)]);
44 return participants;
45}
Added web/src/shared/livekit/react/hooks/useRoomInfo.ts web/src/shared/livekit/react/hooks/useRoomInfo.ts +32 −0 View web/src/shared/livekit/react/hooks/useRoomInfo.ts
@@ -0,0 +1,32 @@
1import { roomInfoObserver } from '@livekit/components-core';
2import type { Room } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureRoom } from '../context';
5import { useObservableState } from './internal';
6
7/**
8 * The `useRoomInfo` hook returns the name and metadata of the given `Room`.
9 * @remarks
10 * Needs to be called inside a `RoomContext` or by passing a `Room` instance.
11 *
12 * @example
13 * ```tsx
14 * const { name, metadata } = useRoomInfo();
15 * ```
16 * @public
17 */
18export interface UseRoomInfoOptions {
19 room?: Room;
20}
21
22/** @public */
23export function useRoomInfo(options: UseRoomInfoOptions = {}) {
24 const room = useEnsureRoom(options.room);
25 const infoObserver = React.useMemo(() => roomInfoObserver(room), [room]);
26 const { name, metadata } = useObservableState(infoObserver, {
27 name: room.name,
28 metadata: room.metadata,
29 });
30
31 return { name, metadata };
32}
Added web/src/shared/livekit/react/hooks/useRpc.ts web/src/shared/livekit/react/hooks/useRpc.ts +210 −0 View web/src/shared/livekit/react/hooks/useRpc.ts
@@ -0,0 +1,210 @@
1import * as React from 'react';
2import {
3 RpcError,
4 type RpcInvocationData,
5 type PerformRpcParams,
6 type Serializer,
7 isSerializer,
8 type SerializerInput,
9 type SerializerOutput,
10 serializers,
11} from 'livekit-client';
12
13import { useEnsureSession } from '../context';
14import { isUseSessionReturn, type UseSessionReturn } from './useSession';
15
16// ---------------------------------------------------------------------------
17// RPC types
18// ---------------------------------------------------------------------------
19
20/** @beta */
21export type RpcHandler<Input = any, Output = any> = (
22 payload: Input,
23 data: RpcInvocationData,
24) => Promise<Output>;
25
26/**
27 * Base RPC call parameters with an arbitrary payload type (used when the payload
28 * will be serialized by a serializer).
29 *
30 * @beta
31 */
32export type RpcCallParams<Payload> = Omit<PerformRpcParams, 'payload'> & { payload: Payload };
33
34/**
35 * Options for {@link (useRpc:1)}.
36 * @beta
37 */
38export type UseRpcOptions<S extends Serializer<any, any> = Serializer<any, any>> = {
39 /** Only accept RPCs from this participant. Others will receive UNSUPPORTED_METHOD. */
40 fromIdentity?: string;
41 /**
42 * Serializer applied to the data coming in and leaving the handler. Defaults to `serializers.json()`
43 */
44 serializer?: S;
45};
46
47// ---------------------------------------------------------------------------
48// useRpc hook
49// ---------------------------------------------------------------------------
50
51/** @beta */
52export type RpcPerformFn = {
53 /** Serializer-wrapped call: payload is serialized and response is parsed by the serializer. */
54 <Output = string, Input = unknown>(
55 params: RpcCallParams<Input>,
56 serializer: Serializer<Output, Input>,
57 ): Promise<Output>;
58 /** Plain call: payload is already a string, response is returned as a string. */
59 (params: PerformRpcParams): Promise<string>;
60};
61
62/** @beta */
63export type UseRpcReturn = {
64 perform: RpcPerformFn;
65};
66
67/**
68 * Hook for declarative RPC method registration and outbound RPC calls.
69 *
70 * Registers a handler for an incoming RPC method and returns a `performRpc`
71 * function for outbound calls. The handler is registered on mount and
72 * unregistered on unmount. Handler identity does not matter (captured by ref),
73 * so inline functions work without `useCallback`.
74 *
75 * @example
76 * ```tsx
77 * const { performRpc } = useRpc(session, "getUserLocation", async (payload: { highAccuracy: boolean }) => {
78 * const pos = await getPosition(payload.highAccuracy);
79 * return { lat: pos.coords.latitude, lng: pos.coords.longitude };
80 * });
81 * ```
82 *
83 * @beta
84 */
85export function useRpc<S extends Serializer<any, any>>(
86 session: UseSessionReturn,
87 methodName: string,
88 handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>,
89 options?: UseRpcOptions<S>,
90): UseRpcReturn;
91/** @beta */
92export function useRpc<S extends Serializer<any, any>>(
93 methodName: string,
94 handler: RpcHandler<SerializerInput<S>, SerializerOutput<S>>,
95 options?: UseRpcOptions<S>,
96): UseRpcReturn;
97/** @beta */
98export function useRpc(session: UseSessionReturn): UseRpcReturn;
99/** @beta */
100export function useRpc(): UseRpcReturn;
101export function useRpc(
102 methodNameOrSession?: string | UseSessionReturn,
103 handlerOrMethodName?: RpcHandler<any, any> | string,
104 optionsOrHandler?: UseRpcOptions<Serializer<any, any>> | RpcHandler<any, any>,
105 maybeOptions?: UseRpcOptions<Serializer<any, any>>,
106): UseRpcReturn {
107 let session: UseSessionReturn | undefined;
108 let methodName: string | undefined;
109 let handler: RpcHandler<any, any> | undefined;
110 let options: UseRpcOptions<Serializer<any, any>> | undefined;
111
112 if (isUseSessionReturn(methodNameOrSession)) {
113 session = methodNameOrSession;
114 methodName = handlerOrMethodName as string;
115 handler = optionsOrHandler as RpcHandler<any, any>;
116 options = maybeOptions;
117 } else {
118 methodName = methodNameOrSession;
119 handler = handlerOrMethodName as RpcHandler<any, any>;
120 options = optionsOrHandler as UseRpcOptions<any>;
121 }
122
123 const { room } = useEnsureSession(session);
124
125 // Ref that always holds the latest handler — updated synchronously on render
126 const handlerRef = React.useRef(handler);
127 handlerRef.current = handler;
128
129 // Ref that always holds the latest options
130 const optionsRef = React.useRef(options);
131 optionsRef.current = options;
132
133 React.useEffect(() => {
134 if (!methodName) {
135 return;
136 }
137
138 room.registerRpcMethod(methodName, async (data: RpcInvocationData) => {
139 const fromIdentity = optionsRef.current?.fromIdentity;
140 if (fromIdentity && data.callerIdentity !== fromIdentity) {
141 throw RpcError.builtIn(
142 'UNSUPPORTED_METHOD',
143 `Method not available for caller ${data.callerIdentity}`,
144 );
145 }
146
147 const currentHandler = handlerRef.current;
148 if (!currentHandler) {
149 throw RpcError.builtIn(
150 'APPLICATION_ERROR',
151 `No handler registered for method "${methodName}"`,
152 );
153 }
154
155 const serializer = optionsRef.current?.serializer ?? serializers.json();
156
157 let parsed;
158 try {
159 parsed = serializer.parse(data.payload);
160 } catch (e) {
161 throw RpcError.builtIn('APPLICATION_ERROR', `Failed to parse RPC payload: ${e}`);
162 }
163
164 const result = await currentHandler(parsed, data);
165
166 try {
167 return serializer.serialize(result);
168 } catch (e) {
169 throw RpcError.builtIn('APPLICATION_ERROR', `Failed to serialize RPC response: ${e}`);
170 }
171 });
172
173 return () => {
174 room.unregisterRpcMethod(methodName);
175 };
176 }, [room, methodName]);
177
178 // Stable rpc calling function
179 const performRpc: RpcPerformFn = React.useCallback(
180 async (
181 params: RpcCallParams<unknown>,
182 serializer: Serializer<any, any> = serializers.json(),
183 ) => {
184 if (isSerializer(serializer)) {
185 let serialized: string;
186 try {
187 serialized = serializer.serialize(params.payload);
188 } catch (e) {
189 throw RpcError.builtIn('APPLICATION_ERROR', `Failed to serialize RPC payload: ${e}`);
190 }
191 const rawResponse = await room.localParticipant.performRpc({
192 destinationIdentity: params.destinationIdentity,
193 method: params.method,
194 payload: serialized,
195 responseTimeout: params.responseTimeout,
196 });
197 try {
198 return serializer.parse(rawResponse);
199 } catch (e) {
200 throw RpcError.builtIn('APPLICATION_ERROR', `Failed to parse RPC response: ${e}`);
201 }
202 } else {
203 return room.localParticipant.performRpc(params as PerformRpcParams);
204 }
205 },
206 [room],
207 );
208
209 return React.useMemo(() => ({ perform: performRpc }), [performRpc]);
210}
Added web/src/shared/livekit/react/hooks/useSequentialRoomConnectDisconnect.ts web/src/shared/livekit/react/hooks/useSequentialRoomConnectDisconnect.ts +171 −0 View web/src/shared/livekit/react/hooks/useSequentialRoomConnectDisconnect.ts
@@ -0,0 +1,171 @@
1import { Mutex, type Room } from 'livekit-client';
2import { useCallback, useEffect, useMemo, useRef } from 'react';
3import { log } from '@livekit/components-core';
4
5const CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY = 2;
6const CONNECT_DISCONNECT_WARNING_THRESHOLD_MS = 400;
7
8const ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY = 3;
9const ROOM_CHANGE_WARNING_THRESHOLD_MS = 1000;
10
11/** @public */
12export type UseSequentialRoomConnectDisconnectResults<R extends Room | undefined> = {
13 connect: typeof Room.prototype.connect & (R extends undefined ? null : unknown);
14 disconnect: typeof Room.prototype.disconnect & (R extends undefined ? null : unknown);
15};
16
17/**
18 * When calling room.disconnect() as part of a React useEffect cleanup function, it is possible for
19 * a room.connect(...) in the effect body to start running while the room.disconnect() is still
20 * running. This hook sequentializes these two operations, so they always happen in order and
21 * never overlap.
22 *
23 * @example
24 * ```ts
25 * const { connect, disconnect } = useSequentialRoomConnectDisconnect(room);
26 *
27 * // Connecting to a room:
28 * useEffect(() => {
29 * connect();
30 * return () => disconnect();
31 * }, [connect, disconnect]);
32 * ```
33 *
34 * @public
35 */
36export function useSequentialRoomConnectDisconnect<R extends Room | undefined>(
37 room: R,
38): UseSequentialRoomConnectDisconnectResults<R> {
39 const connectDisconnectQueueRef = useRef<
40 Array<
41 | {
42 type: 'connect';
43 room: Room;
44 args: Parameters<typeof Room.prototype.connect>;
45 resolve: (value: Awaited<ReturnType<typeof Room.prototype.connect>>) => void;
46 reject: (err: Error) => void;
47 }
48 | {
49 type: 'disconnect';
50 room: Room;
51 args: Parameters<typeof Room.prototype.disconnect>;
52 resolve: (value: Awaited<ReturnType<typeof Room.prototype.disconnect>>) => void;
53 reject: (err: Error) => void;
54 }
55 >
56 >([]);
57
58 // Process room connection / disconnection events and execute them in series
59 // The main queue is a ref, so one invocation of this function can continue to process newly added
60 // events
61 const processConnectsAndDisconnectsLock = useMemo(() => new Mutex(), []);
62 const processConnectsAndDisconnects = useCallback(async () => {
63 return processConnectsAndDisconnectsLock.lock().then(async (unlock) => {
64 while (true) {
65 const message = connectDisconnectQueueRef.current.pop();
66 if (!message) {
67 unlock();
68 break;
69 }
70
71 switch (message.type) {
72 case 'connect':
73 await message.room
74 .connect(...message.args)
75 .then(message.resolve)
76 .catch(message.reject);
77 break;
78 case 'disconnect':
79 await message.room
80 .disconnect(...message.args)
81 .then(message.resolve)
82 .catch(message.reject);
83 break;
84 }
85 }
86 });
87 }, []);
88
89 const roomChangedTimesRef = useRef<Array<Date>>([]);
90 const checkRoomThreshold = useCallback((now: Date) => {
91 let roomChangesInThreshold = 0;
92 roomChangedTimesRef.current = roomChangedTimesRef.current.filter((i) => {
93 const isWithinThreshold = now.getTime() - i.getTime() < ROOM_CHANGE_WARNING_THRESHOLD_MS;
94 if (isWithinThreshold) {
95 roomChangesInThreshold += 1;
96 }
97 return isWithinThreshold;
98 });
99
100 if (roomChangesInThreshold > ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY) {
101 log.warn(
102 `useSequentialRoomConnectDisconnect: room changed reference rapidly (over ${ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY}x in ${ROOM_CHANGE_WARNING_THRESHOLD_MS}ms). This is not recommended.`,
103 );
104 }
105 }, []);
106
107 // When the room changes, clear any pending connect / disconnect calls and log when it happened
108 useEffect(() => {
109 connectDisconnectQueueRef.current = [];
110
111 const now = new Date();
112 roomChangedTimesRef.current.push(now);
113 checkRoomThreshold(now);
114 }, [room, checkRoomThreshold]);
115
116 const connectDisconnectEnqueueTimes = useRef<Array<Date>>([]);
117 const checkConnectDisconnectThreshold = useCallback((now: Date) => {
118 let connectDisconnectsInThreshold = 0;
119 connectDisconnectEnqueueTimes.current = connectDisconnectEnqueueTimes.current.filter((i) => {
120 const isWithinThreshold =
121 now.getTime() - i.getTime() < CONNECT_DISCONNECT_WARNING_THRESHOLD_MS;
122 if (isWithinThreshold) {
123 connectDisconnectsInThreshold += 1;
124 }
125 return isWithinThreshold;
126 });
127
128 if (connectDisconnectsInThreshold > CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY) {
129 log.warn(
130 `useSequentialRoomConnectDisconnect: room connect / disconnect occurring in rapid sequence (over ${CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY}x in ${CONNECT_DISCONNECT_WARNING_THRESHOLD_MS}ms). This is not recommended and may be the sign of a bug like a useEffect dependency changing every render.`,
131 );
132 }
133 }, []);
134
135 const connect = useCallback(
136 async (...args: Parameters<typeof Room.prototype.connect>) => {
137 return new Promise((resolve, reject) => {
138 if (!room) {
139 throw new Error('Called connect(), but room was unset');
140 }
141 const now = new Date();
142 checkConnectDisconnectThreshold(now);
143 connectDisconnectQueueRef.current.push({ type: 'connect', room, args, resolve, reject });
144 connectDisconnectEnqueueTimes.current.push(now);
145 processConnectsAndDisconnects();
146 });
147 },
148 [room, checkConnectDisconnectThreshold, processConnectsAndDisconnects],
149 );
150
151 const disconnect = useCallback(
152 async (...args: Parameters<typeof Room.prototype.disconnect>) => {
153 return new Promise((resolve, reject) => {
154 if (!room) {
155 throw new Error('Called discconnect(), but room was unset');
156 }
157 const now = new Date();
158 checkConnectDisconnectThreshold(now);
159 connectDisconnectQueueRef.current.push({ type: 'disconnect', room, args, resolve, reject });
160 connectDisconnectEnqueueTimes.current.push(now);
161 processConnectsAndDisconnects();
162 });
163 },
164 [room, checkConnectDisconnectThreshold, processConnectsAndDisconnects],
165 );
166
167 return {
168 connect: room ? connect : null,
169 disconnect: room ? disconnect : null,
170 } as UseSequentialRoomConnectDisconnectResults<R>;
171}
Added web/src/shared/livekit/react/hooks/useSession.ts web/src/shared/livekit/react/hooks/useSession.ts +799 −0 View web/src/shared/livekit/react/hooks/useSession.ts
@@ -0,0 +1,799 @@
1import * as React from 'react';
2import type TypedEventEmitter from 'typed-emitter';
3import {
4 Room,
5 RoomEvent,
6 ConnectionState,
7 type TrackPublishOptions,
8 Track,
9 TokenSourceConfigurable,
10 TokenSourceFixed,
11 type TokenSourceFetchOptions,
12 type RoomConnectOptions,
13 decodeTokenPayload,
14 BaseKeyProvider,
15 type RoomOptions,
16 ExternalE2EEKeyProvider,
17 type BaseE2EEManager,
18} from 'livekit-client';
19import { EventEmitter } from 'events';
20
21import { useMaybeRoomContext } from '../context';
22import { type AgentState, useAgent, useAgentTimeoutIdStore } from './useAgent';
23import { type TrackReference, log } from '@livekit/components-core';
24import { useLocalParticipant } from './useLocalParticipant';
25
26/** @beta */
27export const SessionEvent = {
28 ConnectionStateChanged: 'connectionStateChanged',
29 /**
30 * Emits when an error is encountered while attempting to create a track.
31 * Use MediaDeviceFailure.getFailure(error) to get the reason of failure.
32 * args: (error: Error, kind: MediaDeviceKind)
33 */
34 MediaDevicesError: 'mediaDevicesError',
35 /**
36 * Emits when an error is received while decrypting frame received frame information.
37 * args: (error: Error)
38 */
39 EncryptionError: 'encryptionError',} as const;
40export type SessionEvent = (typeof SessionEvent)[keyof typeof SessionEvent];
41
42/** @beta */
43export type SessionCallbacks = {
44 [SessionEvent.ConnectionStateChanged]: (newAgentConnectionState: ConnectionState) => void;
45 [SessionEvent.MediaDevicesError]: (error: Error) => void;
46 [SessionEvent.EncryptionError]: (error: Error) => void;
47};
48
49/** @beta */
50export type SessionConnectOptions = {
51 /** Optional abort signal which if triggered will terminate connecting even if it isn't complete */
52 signal?: AbortSignal;
53
54 tracks?: {
55 microphone?: {
56 enabled?: boolean;
57 publishOptions?: TrackPublishOptions;
58 };
59 camera?: {
60 enabled?: boolean;
61 publishOptions?: TrackPublishOptions;
62 };
63 screenShare?: {
64 enabled?: boolean;
65 publishOptions?: TrackPublishOptions;
66 };
67 };
68
69 /** Options for Room.connect(.., .., opts) */
70 roomConnectOptions?: RoomConnectOptions;
71};
72
73/** @beta */
74export type SwitchActiveDeviceOptions = {
75 /**
76 * If true, adds an `exact` constraint to the getUserMedia request.
77 * The request will fail if this option is true and the device specified is not actually available
78 */
79 exact?: boolean;
80};
81
82type SessionStateCommon = {
83 room: Room;
84 internal: {
85 emitter: TypedEventEmitter<SessionCallbacks>;
86 tokenSource: TokenSourceConfigurable | TokenSourceFixed;
87 agentConnectTimeoutMilliseconds?: number;
88
89 agentTimeoutFailureReason: string | null;
90 startAgentTimeout: (agentConnectTimeoutMilliseconds?: number) => void;
91 clearAgentTimeout: () => void;
92 clearAgentTimeoutFailureReason: () => void;
93 updateAgentTimeoutState: (agentState: AgentState) => void;
94 updateAgentTimeoutParticipantExists: (agentParticipantExists: boolean) => void;
95 };
96};
97
98type SessionStateConnecting = SessionStateCommon & {
99 connectionState: ConnectionState.Connecting;
100 isConnected: false;
101
102 local: {
103 cameraTrack: undefined;
104 microphoneTrack: undefined;
105 screenShareTrack: undefined;
106 };
107};
108
109type SessionStateConnected = SessionStateCommon & {
110 connectionState:
111 ConnectionState.Connected | ConnectionState.Reconnecting | ConnectionState.SignalReconnecting;
112 isConnected: true;
113
114 local: {
115 cameraTrack?: TrackReference;
116 microphoneTrack?: TrackReference;
117 screenShareTrack?: TrackReference;
118 };
119};
120
121type SessionStateDisconnected = SessionStateCommon & {
122 connectionState: ConnectionState.Disconnected;
123 isConnected: false;
124
125 local: {
126 cameraTrack: undefined;
127 microphoneTrack: undefined;
128 screenShareTrack: undefined;
129 };
130};
131
132type SessionActions = {
133 /** Returns a promise that resolves once the room connects. */
134 waitUntilConnected: (signal?: AbortSignal) => Promise<void>;
135 /** Returns a promise that resolves once the room disconnects */
136 waitUntilDisconnected: (signal?: AbortSignal) => Promise<void>;
137
138 prepareConnection: () => Promise<void>;
139
140 /** Connect to the underlying room and dispatch any agents */
141 start: (options?: SessionConnectOptions) => Promise<void>;
142
143 /** Disconnect from the underlying room */
144 end: () => Promise<void>;
145
146 /** Enable or disable E2EE. */
147 setEncryptionEnabled: (enabled: boolean) => Promise<void>;
148};
149
150/** @beta */
151export type UseSessionReturn = (
152 SessionStateConnecting | SessionStateConnected | SessionStateDisconnected
153) &
154 SessionActions;
155
156/** @internal */
157export function isUseSessionReturn(value: unknown): value is UseSessionReturn {
158 return (
159 typeof value === 'object' &&
160 value !== null &&
161 'room' in value &&
162 'connectionState' in value &&
163 'internal' in value
164 );
165}
166
167type UseSessionCommonOptions = {
168 /**
169 * Amount of time in milliseonds the system will wait for an agent to join the room, before
170 * transitioning to the "failure" state.
171 */
172 agentConnectTimeoutMilliseconds?: number;
173};
174
175type UseSessionWithRoomOptions = {
176 room: Room;
177 encryption?: never;
178};
179
180type UseSessionEncryptionOptions =
181 | {
182 /**
183 * Accepts a passphrase that's used to create the crypto keys.
184 * When passing in a string, PBKDF2 is used. (recommended for maximum compatibility across SDKs)
185 * When passing in an ArrayBuffer of cryptographically random numbers, HKDF is used.
186 *
187 * Note: Not all client SDKs support HKDF.
188 */
189 key: string | ArrayBuffer | BaseKeyProvider;
190
191 /** An instance of the E2EE webworker, which must be constructed using your js build tool's
192 * webworker construction mechanism. */
193 worker: Worker;
194
195 e2eeManager?: undefined;
196 }
197 | {
198 key?: undefined;
199 worker?: undefined;
200 /**
201 * For React Native usage: Pass the e2eeManager obtained from the `useRNE2EEManager()` hook
202 * in the `\@livekit/react-native` package.
203 */
204 e2eeManager: BaseE2EEManager;
205 }
206 | {
207 key?: undefined;
208 worker?: undefined;
209 e2eeManager?: undefined;
210 };
211
212type UseSessionWithoutRoomOptions = {
213 // NOTE: This must be here to make typescript go down this discriminated union branch when
214 // "room" is omitted.
215 room?: never;
216
217 /** Configuration for room-level E2EE */
218 encryption?: UseSessionEncryptionOptions;
219};
220
221type UseSessionRoomOptions = UseSessionWithRoomOptions | UseSessionWithoutRoomOptions;
222
223type UseSessionConfigurableOptions = UseSessionCommonOptions &
224 UseSessionRoomOptions &
225 TokenSourceFetchOptions;
226type UseSessionFixedOptions = UseSessionCommonOptions & UseSessionRoomOptions;
227
228/**
229 * Given two TokenSourceFetchOptions values, check to see if they are deep equal.
230 *
231 * FIXME: swap this for an import from livekit-client once
232 * https://github.com/livekit/client-sdk-js/pull/1733 is merged and published!
233 * */
234function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSourceFetchOptions) {
235 const allKeysSet = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<
236 keyof TokenSourceFetchOptions
237 >;
238
239 for (const key of allKeysSet) {
240 switch (key) {
241 case 'roomName':
242 case 'participantName':
243 case 'participantIdentity':
244 case 'participantMetadata':
245 case 'participantAttributes':
246 case 'agentName':
247 case 'agentMetadata':
248 case 'deployment':
249 if (a[key] !== b[key]) {
250 return false;
251 }
252 break;
253 default:
254 // ref: https://stackoverflow.com/a/58009992
255 const exhaustiveCheckedKey: never = key;
256 throw new Error(`Options key ${exhaustiveCheckedKey} not being checked for equality!`);
257 }
258 }
259
260 return true;
261}
262
263/** Internal hook used by useSession to manage creating a function which can be used to wait
264 * until the session is in a given state before resolving. */
265function useSessionWaitUntilConnectionState(
266 emitter: TypedEventEmitter<SessionCallbacks>,
267 connectionState: UseSessionReturn['connectionState'],
268) {
269 const connectionStateRef = React.useRef(connectionState);
270 React.useEffect(() => {
271 connectionStateRef.current = connectionState;
272 }, [connectionState]);
273
274 const waitUntilConnectionState = React.useCallback(
275 async (state: UseSessionReturn['connectionState'], signal?: AbortSignal) => {
276 if (connectionStateRef.current === state) {
277 return;
278 }
279
280 return new Promise<void>((resolve, reject) => {
281 const onceEventOccurred = (newState: UseSessionReturn['connectionState']) => {
282 if (newState !== state) {
283 return;
284 }
285 cleanup();
286 resolve();
287 };
288 const abortHandler = () => {
289 cleanup();
290 reject(
291 new Error(
292 `useSession(/* ... */).waitUntilConnectionState(${state}, /* signal */) - signal aborted`,
293 ),
294 );
295 };
296
297 const cleanup = () => {
298 emitter.off(SessionEvent.ConnectionStateChanged, onceEventOccurred);
299 signal?.removeEventListener('abort', abortHandler);
300 };
301
302 emitter.on(SessionEvent.ConnectionStateChanged, onceEventOccurred);
303 signal?.addEventListener('abort', abortHandler);
304 });
305 },
306 [emitter],
307 );
308
309 return waitUntilConnectionState;
310}
311
312/** Internal hook used by useSession to manage creating a function that properly invokes
313 * tokenSource.fetch(...) with any fetch options */
314function useSessionTokenSourceFetch(
315 tokenSource: TokenSourceConfigurable | TokenSourceFixed,
316 unstableRestOptions: Exclude<UseSessionConfigurableOptions, keyof UseSessionCommonOptions>,
317) {
318 const isConfigurable = tokenSource instanceof TokenSourceConfigurable;
319
320 const memoizedTokenFetchOptionsRef = React.useRef<TokenSourceFetchOptions | null>(
321 isConfigurable ? unstableRestOptions : null,
322 );
323
324 React.useEffect(() => {
325 if (!isConfigurable) {
326 memoizedTokenFetchOptionsRef.current = null;
327 return;
328 }
329
330 if (
331 memoizedTokenFetchOptionsRef.current !== null &&
332 areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptionsRef.current, unstableRestOptions)
333 ) {
334 return;
335 }
336 memoizedTokenFetchOptionsRef.current = unstableRestOptions;
337 }, [isConfigurable, unstableRestOptions]);
338
339 const tokenSourceFetch = React.useCallback(
340 async (force?: boolean) => {
341 if (isConfigurable) {
342 if (!memoizedTokenFetchOptionsRef.current) {
343 throw new Error(
344 `AgentSession - memoized token fetch options are not set, but the passed tokenSource was an instance of TokenSourceConfigurable. If you are seeing this please make a new GitHub issue!`,
345 );
346 }
347 return tokenSource.fetch(memoizedTokenFetchOptionsRef.current, force);
348 } else {
349 return tokenSource.fetch();
350 }
351 },
352 [isConfigurable, tokenSource],
353 );
354
355 return tokenSourceFetch;
356}
357
358/**
359 * A Session represents a managed connection to a Room which can contain Agents.
360 * @beta
361 */
362export function useSession(
363 tokenSource: TokenSourceConfigurable,
364 options?: UseSessionConfigurableOptions,
365): UseSessionReturn;
366/**
367 * A Session represents a managed connection to a Room which can contain Agents.
368 * @beta
369 */
370export function useSession(
371 tokenSource: TokenSourceFixed,
372 options?: UseSessionFixedOptions,
373): UseSessionReturn;
374export function useSession(
375 tokenSource: TokenSourceConfigurable | TokenSourceFixed,
376 options: UseSessionConfigurableOptions | UseSessionFixedOptions = {},
377): UseSessionReturn {
378 const {
379 room: optionsRoom,
380 agentConnectTimeoutMilliseconds,
381 encryption: unstableEncryption,
382 ...unstableRestOptions
383 } = options;
384
385 const encryptionE2eeManager =
386 unstableEncryption && 'e2eeManager' in unstableEncryption
387 ? unstableEncryption.e2eeManager
388 : null;
389 const encryptionKey =
390 unstableEncryption && !('e2eeManager' in unstableEncryption)
391 ? (unstableEncryption.key ?? null)
392 : null;
393 const encryptionWorker =
394 unstableEncryption && !('e2eeManager' in unstableEncryption)
395 ? (unstableEncryption.worker ?? null)
396 : null;
397
398 const roomFromContext = useMaybeRoomContext();
399
400 const externalKeyProviderRef = React.useRef<ExternalE2EEKeyProvider | null>(null);
401
402 const keyProvider = React.useMemo(() => {
403 if (typeof encryptionKey === 'string' || encryptionKey instanceof ArrayBuffer) {
404 if (externalKeyProviderRef.current === null) {
405 externalKeyProviderRef.current = new ExternalE2EEKeyProvider();
406 }
407 externalKeyProviderRef.current.setKey(encryptionKey).catch((e) => log.error(e));
408 return externalKeyProviderRef.current;
409 } else {
410 return encryptionKey;
411 }
412 }, [encryptionKey]);
413
414 const room = React.useMemo(() => {
415 const preGeneratedRoom = roomFromContext ?? optionsRoom;
416 if (preGeneratedRoom) {
417 return preGeneratedRoom;
418 }
419
420 const encryptionViaWorker = !!(keyProvider && encryptionWorker);
421 const encryptionViaManager = !!encryptionE2eeManager;
422 const encryptionEnabled = encryptionViaWorker || encryptionViaManager;
423
424 const roomOptions: RoomOptions = {};
425 if (encryptionViaWorker) {
426 roomOptions.encryption = {
427 keyProvider,
428 worker: encryptionWorker,
429 };
430 } else if (encryptionViaManager) {
431 roomOptions.encryption = {
432 e2eeManager: encryptionE2eeManager,
433 };
434 } else if (unstableEncryption !== undefined) {
435 log.warn(
436 'useSession options encryption was set, but neither encryption.key with encryption.worker nor encryption.e2eeManager was provided.',
437 );
438 }
439 const room = new Room(roomOptions);
440 if (encryptionEnabled) {
441 room.setE2EEEnabled(true);
442 }
443 return room;
444 }, [roomFromContext, optionsRoom, keyProvider, encryptionWorker, encryptionE2eeManager]);
445
446 React.useEffect(() => {
447 return () => {
448 room.disconnect();
449 };
450 }, [room]);
451
452 const emitter = React.useMemo(
453 () => new EventEmitter() as TypedEventEmitter<SessionCallbacks>,
454 [],
455 );
456
457 const generateDerivedConnectionStateValues = React.useCallback(
458 <State extends UseSessionReturn['connectionState']>(connectionState: State) =>
459 ({
460 isConnected:
461 connectionState === ConnectionState.Connected ||
462 connectionState === ConnectionState.Reconnecting ||
463 connectionState === ConnectionState.SignalReconnecting,
464 }) as {
465 isConnected: State extends
466 | ConnectionState.Connected
467 | ConnectionState.Reconnecting
468 | ConnectionState.SignalReconnecting
469 ? true
470 : false;
471 },
472 [],
473 );
474
475 const [roomConnectionState, setRoomConnectionState] = React.useState(room.state);
476 React.useEffect(() => {
477 const handleConnectionStateChanged = (connectionState: ConnectionState) => {
478 setRoomConnectionState(connectionState);
479 };
480
481 room.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
482 return () => {
483 room.off(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
484 };
485 }, [room]);
486
487 React.useEffect(() => {
488 const handleMediaDevicesError = async (error: Error) => {
489 emitter.emit(SessionEvent.MediaDevicesError, error);
490 };
491
492 room.on(RoomEvent.MediaDevicesError, handleMediaDevicesError);
493 return () => {
494 room.off(RoomEvent.MediaDevicesError, handleMediaDevicesError);
495 };
496 }, [room, emitter]);
497
498 React.useEffect(() => {
499 const handleEncryptionError = async (error: Error) => {
500 emitter.emit(SessionEvent.EncryptionError, error);
501 };
502
503 room.on(RoomEvent.EncryptionError, handleEncryptionError);
504 return () => {
505 room.off(RoomEvent.EncryptionError, handleEncryptionError);
506 };
507 }, [room, emitter]);
508
509 const { localParticipant } = useLocalParticipant({ room });
510 const cameraPublication = localParticipant.getTrackPublication(Track.Source.Camera);
511 const localCamera = React.useMemo(() => {
512 if (!cameraPublication) {
513 return undefined;
514 }
515 return {
516 source: Track.Source.Camera,
517 participant: localParticipant,
518 publication: cameraPublication,
519 };
520 }, [localParticipant, cameraPublication]);
521 const microphonePublication = localParticipant.getTrackPublication(Track.Source.Microphone);
522 const localMicrophone = React.useMemo(() => {
523 if (!microphonePublication) {
524 return undefined;
525 }
526 return {
527 source: Track.Source.Microphone,
528 participant: localParticipant,
529 publication: microphonePublication,
530 };
531 }, [localParticipant, microphonePublication]);
532 const screenSharePublication = localParticipant.getTrackPublication(Track.Source.ScreenShare);
533 const localScreenShare = React.useMemo(() => {
534 if (!screenSharePublication) {
535 return undefined;
536 }
537 return {
538 source: Track.Source.ScreenShare,
539 participant: localParticipant,
540 publication: screenSharePublication,
541 };
542 }, [localParticipant, screenSharePublication]);
543
544 const {
545 agentTimeoutFailureReason,
546 startAgentTimeout,
547 clearAgentTimeout,
548 clearAgentTimeoutFailureReason,
549 updateAgentTimeoutState,
550 updateAgentTimeoutParticipantExists,
551 } = useAgentTimeoutIdStore();
552
553 const sessionInternal: UseSessionReturn['internal'] = React.useMemo(
554 () => ({
555 emitter,
556 tokenSource,
557 agentConnectTimeoutMilliseconds,
558
559 agentTimeoutFailureReason,
560 startAgentTimeout,
561 clearAgentTimeout,
562 clearAgentTimeoutFailureReason,
563 updateAgentTimeoutState,
564 updateAgentTimeoutParticipantExists,
565 }),
566 [
567 emitter,
568 agentConnectTimeoutMilliseconds,
569 tokenSource,
570 agentTimeoutFailureReason,
571 startAgentTimeout,
572 clearAgentTimeout,
573 clearAgentTimeoutFailureReason,
574 updateAgentTimeoutState,
575 updateAgentTimeoutParticipantExists,
576 ],
577 );
578
579 const conversationState = React.useMemo(():
580 SessionStateConnecting | SessionStateConnected | SessionStateDisconnected => {
581 const common: SessionStateCommon = {
582 room,
583 internal: sessionInternal,
584 };
585
586 switch (roomConnectionState) {
587 case ConnectionState.Connecting:
588 return {
589 ...common,
590
591 connectionState: ConnectionState.Connecting,
592 ...generateDerivedConnectionStateValues(ConnectionState.Connecting),
593
594 local: {
595 cameraTrack: undefined,
596 microphoneTrack: undefined,
597 screenShareTrack: undefined,
598 },
599 };
600
601 case ConnectionState.Connected:
602 case ConnectionState.Reconnecting:
603 case ConnectionState.SignalReconnecting:
604 return {
605 ...common,
606
607 connectionState: roomConnectionState,
608 ...generateDerivedConnectionStateValues(roomConnectionState),
609
610 local: {
611 cameraTrack: localCamera,
612 microphoneTrack: localMicrophone,
613 screenShareTrack: localScreenShare,
614 },
615 };
616
617 case ConnectionState.Disconnected:
618 return {
619 ...common,
620
621 connectionState: ConnectionState.Disconnected,
622 ...generateDerivedConnectionStateValues(ConnectionState.Disconnected),
623
624 local: {
625 cameraTrack: undefined,
626 microphoneTrack: undefined,
627 screenShareTrack: undefined,
628 },
629 };
630 }
631 }, [
632 sessionInternal,
633 room,
634 roomConnectionState,
635 localCamera,
636 localMicrophone,
637 generateDerivedConnectionStateValues,
638 ]);
639 React.useEffect(() => {
640 emitter.emit(SessionEvent.ConnectionStateChanged, conversationState.connectionState);
641 }, [emitter, conversationState.connectionState]);
642
643 const waitUntilConnectionState = useSessionWaitUntilConnectionState(
644 emitter,
645 conversationState.connectionState,
646 );
647
648 const waitUntilConnected = React.useCallback(
649 async (signal?: AbortSignal) => {
650 return waitUntilConnectionState(
651 ConnectionState.Connected /* FIXME: should I check for other states too? */,
652 signal,
653 );
654 },
655 [waitUntilConnectionState],
656 );
657
658 const waitUntilDisconnected = React.useCallback(
659 async (signal?: AbortSignal) => {
660 return waitUntilConnectionState(ConnectionState.Disconnected, signal);
661 },
662 [waitUntilConnectionState],
663 );
664
665 const setEncryptionEnabled = React.useCallback(
666 async (enabled: boolean) => room.setE2EEEnabled(enabled),
667 [room],
668 );
669
670 const agent = useAgent(
671 React.useMemo(
672 () => ({
673 connectionState: conversationState.connectionState,
674 room,
675 internal: sessionInternal,
676 }),
677 [conversationState, room, sessionInternal],
678 ),
679 );
680
681 const tokenSourceFetch = useSessionTokenSourceFetch(tokenSource, unstableRestOptions);
682
683 const wasSessionEndCalledRef = React.useRef(false);
684
685 const start = React.useCallback(
686 async (connectOptions: SessionConnectOptions = {}) => {
687 const {
688 signal,
689 tracks = { microphone: { enabled: true, publishOptions: { preConnectBuffer: true } } },
690 roomConnectOptions,
691 } = connectOptions;
692
693 await waitUntilDisconnected(signal);
694 wasSessionEndCalledRef.current = false;
695
696 const onSignalAbort = () => {
697 room.disconnect();
698 };
699 signal?.addEventListener('abort', onSignalAbort);
700
701 const onDisconnected = () => {
702 // on disconnection force a new token to be fetched in order to avoid reusing the same room right after
703 // this works around the fact that agents won't rejoin a room that existed previously
704 // and depends on the assumption that the endpoint will return a token for a different room
705 if (!wasSessionEndCalledRef.current) {
706 tokenSourceFetch(true);
707 }
708 };
709 room.once(RoomEvent.Disconnected, onDisconnected);
710
711 let tokenDispatchesAgent = false;
712 await Promise.all([
713 tokenSourceFetch().then(({ serverUrl, participantToken }) => {
714 const participantTokenPayload = decodeTokenPayload(participantToken);
715 const participantTokenAgentDispatchCount =
716 participantTokenPayload.roomConfig?.agents?.length ?? 0;
717 tokenDispatchesAgent = participantTokenAgentDispatchCount > 0;
718
719 return room.connect(serverUrl, participantToken, roomConnectOptions);
720 }),
721
722 // Start microphone (with preconnect buffer) by default
723 tracks.microphone?.enabled
724 ? room.localParticipant.setMicrophoneEnabled(
725 true,
726 undefined,
727 tracks.microphone?.publishOptions ?? {},
728 )
729 : Promise.resolve(),
730 tracks.camera?.enabled
731 ? room.localParticipant.setCameraEnabled(
732 true,
733 undefined,
734 tracks.camera?.publishOptions ?? {},
735 )
736 : Promise.resolve(),
737 tracks.screenShare?.enabled
738 ? room.localParticipant.setScreenShareEnabled(
739 true,
740 undefined,
741 tracks.screenShare?.publishOptions ?? {},
742 )
743 : Promise.resolve(),
744 ]);
745
746 await waitUntilConnected(signal);
747 if (tokenDispatchesAgent) {
748 await agent.waitUntilConnected(signal);
749 }
750
751 signal?.removeEventListener('abort', onSignalAbort);
752 },
753 [room, waitUntilDisconnected, tokenSourceFetch, waitUntilConnected, agent.waitUntilConnected],
754 );
755
756 const end = React.useCallback(async () => {
757 wasSessionEndCalledRef.current = true;
758 tokenSourceFetch(true);
759 await room.disconnect();
760 }, [room, tokenSourceFetch]);
761
762 const prepareConnection = React.useCallback(async () => {
763 const credentials = await tokenSourceFetch();
764 await room.prepareConnection(credentials.serverUrl, credentials.participantToken);
765 }, [tokenSourceFetch, room]);
766 React.useEffect(
767 () => {
768 prepareConnection().catch((err) => {
769 // FIXME: figure out a better logging solution?
770 console.warn('WARNING: Room.prepareConnection failed:', err);
771 });
772 },
773 [/* note: no prepareConnection here, this effect should only ever run once! */],
774 );
775
776 return React.useMemo(
777 () => ({
778 ...conversationState,
779
780 waitUntilConnected,
781 waitUntilDisconnected,
782
783 prepareConnection,
784 start,
785 end,
786
787 setEncryptionEnabled,
788 }),
789 [
790 conversationState,
791 waitUntilConnected,
792 waitUntilDisconnected,
793 prepareConnection,
794 start,
795 end,
796 setEncryptionEnabled,
797 ],
798 );
799}
Added web/src/shared/livekit/react/hooks/useSessionMessages.ts web/src/shared/livekit/react/hooks/useSessionMessages.ts +161 −0 View web/src/shared/livekit/react/hooks/useSessionMessages.ts
@@ -0,0 +1,161 @@
1import * as React from 'react';
2import type TypedEventEmitter from 'typed-emitter';
3import { type SendTextOptions } from 'livekit-client';
4import { EventEmitter } from 'events';
5import {
6 type ReceivedMessage,
7 type ReceivedChatMessage,
8 type TextStreamData,
9 type ReceivedUserTranscriptionMessage,
10 type ReceivedAgentTranscriptionMessage,
11} from '@livekit/components-core';
12
13import { useAgent } from './useAgent';
14import { useTranscriptions } from './useTranscriptions';
15import { useChat } from './useChat';
16import { type UseSessionReturn } from './useSession';
17import { useEnsureSession } from '../context';
18
19/** @beta */
20export type UseSessionMessagesReturn = {
21 messages: Array<ReceivedMessage>;
22
23 /** Is a send operation currently in progress? */
24 isSending: boolean;
25
26 send: (message: string, options?: SendTextOptions) => Promise<ReceivedChatMessage>;
27
28 internal: {
29 emitter: TypedEventEmitter<MessagesCallbacks>;
30 };
31};
32
33/** @beta */
34export const MessagesEvent = {
35 /**
36 * Emits when a new message is received from a participant
37 * args: (message: ReceivedMessage)
38 */
39 MessageReceived: 'messageReceived',} as const;
40export type MessagesEvent = (typeof MessagesEvent)[keyof typeof MessagesEvent];
41
42/** @beta */
43export type MessagesCallbacks = {
44 [MessagesEvent.MessageReceived]: (message: ReceivedMessage) => void;
45};
46
47/** @beta */
48export function useSessionMessages(session?: UseSessionReturn): UseSessionMessagesReturn {
49 const { room } = useEnsureSession(session);
50
51 const emitter = React.useMemo(
52 () => new EventEmitter() as TypedEventEmitter<MessagesCallbacks>,
53 [],
54 );
55
56 const agent = useAgent(session);
57
58 const transcriptions: Array<TextStreamData> = useTranscriptions({ room });
59 const chatOptions = React.useMemo(() => ({ room }), [room]);
60 const chat = useChat(chatOptions);
61
62 const transcriptionMessages: Array<
63 ReceivedUserTranscriptionMessage | ReceivedAgentTranscriptionMessage
64 > = React.useMemo(() => {
65 return transcriptions.map((transcription) => {
66 switch (transcription.participantInfo.identity) {
67 case room.localParticipant.identity:
68 return {
69 type: 'userTranscript',
70 message: transcription.text,
71
72 id: transcription.streamInfo.id,
73 timestamp: transcription.streamInfo.timestamp,
74 attributes: transcription.streamInfo.attributes,
75 from: room.localParticipant,
76 };
77
78 case agent.internal.agentParticipant?.identity:
79 case agent.internal.workerParticipant?.identity:
80 return {
81 type: 'agentTranscript',
82 message: transcription.text,
83
84 id: transcription.streamInfo.id,
85 timestamp: transcription.streamInfo.timestamp,
86 attributes: transcription.streamInfo.attributes,
87 from:
88 agent.internal.agentParticipant?.identity === transcription.participantInfo.identity
89 ? agent.internal.agentParticipant
90 : agent.internal.workerParticipant!,
91 };
92
93 default:
94 // FIXME: what should happen if an associated participant is not found?
95 //
96 // For now, just assume it is an agent transcription, since maybe it is from an agent
97 // which disconencted from the room or something like that.
98 return {
99 type: 'agentTranscript',
100 message: transcription.text,
101
102 id: transcription.streamInfo.id,
103 timestamp: transcription.streamInfo.timestamp,
104 attributes: transcription.streamInfo.attributes,
105 from: Array.from(room.remoteParticipants.values()).find(
106 (p) => p.identity === transcription.participantInfo.identity,
107 ),
108 };
109 }
110 });
111 }, [transcriptions, room]);
112
113 const receivedMessages = React.useMemo(() => {
114 const merged: Array<ReceivedMessage> = [...transcriptionMessages, ...chat.chatMessages];
115 return merged;
116 }, [transcriptionMessages, chat.chatMessages]);
117
118 const messageFirstReceivedTimeMapRef = React.useRef(new Map<ReceivedMessage['id'], Date>());
119 const sortedReceivedMessages = React.useMemo(() => {
120 const now = new Date();
121 for (const message of receivedMessages) {
122 if (messageFirstReceivedTimeMapRef.current.has(message.id)) {
123 continue;
124 }
125
126 messageFirstReceivedTimeMapRef.current.set(message.id, now);
127 }
128
129 return receivedMessages.sort((a, b) => {
130 const aFirstReceivedAt = messageFirstReceivedTimeMapRef.current.get(a.id);
131 const bFirstReceivedAt = messageFirstReceivedTimeMapRef.current.get(b.id);
132 if (typeof aFirstReceivedAt === 'undefined' || typeof bFirstReceivedAt === 'undefined') {
133 return 0;
134 }
135
136 return aFirstReceivedAt.getTime() - bFirstReceivedAt.getTime();
137 });
138 }, [receivedMessages]);
139
140 const previouslyReceivedMessageIdsRef = React.useRef(new Set());
141 React.useEffect(() => {
142 for (const message of sortedReceivedMessages) {
143 if (previouslyReceivedMessageIdsRef.current.has(message.id)) {
144 continue;
145 }
146
147 previouslyReceivedMessageIdsRef.current.add(message.id);
148 emitter.emit(MessagesEvent.MessageReceived, message);
149 }
150 }, [sortedReceivedMessages]);
151
152 return React.useMemo(
153 () => ({
154 messages: sortedReceivedMessages,
155 send: chat.send,
156 isSending: chat.isSending,
157 internal: { emitter },
158 }),
159 [sortedReceivedMessages, chat.send, chat.isSending],
160 );
161}
Added web/src/shared/livekit/react/hooks/useSettingsToggle.ts web/src/shared/livekit/react/hooks/useSettingsToggle.ts +32 −0 View web/src/shared/livekit/react/hooks/useSettingsToggle.ts
@@ -0,0 +1,32 @@
1import { useLayoutContext } from '../context';
2import { mergeProps } from '../mergeProps';
3import * as React from 'react';
4
5/** @alpha */
6export interface UseSettingsToggleProps {
7 props: React.ButtonHTMLAttributes<HTMLButtonElement>;
8}
9
10/**
11 * The `useSettingsToggle` hook provides state and functions for toggling the settings menu.
12 * @remarks
13 * Depends on the `LayoutContext` to work properly.
14 * @see {@link SettingsMenu}
15 * @alpha
16 */
17export function useSettingsToggle({ props }: UseSettingsToggleProps) {
18 const { dispatch, state } = useLayoutContext().widget;
19 const className = 'lk-button lk-settings-toggle';
20
21 const mergedProps = React.useMemo(() => {
22 return mergeProps(props, {
23 className,
24 onClick: () => {
25 if (dispatch) dispatch({ msg: 'toggle_settings' });
26 },
27 'aria-pressed': state?.showSettings ? 'true' : 'false',
28 });
29 }, [props, className, dispatch, state]);
30
31 return { mergedProps };
32}
Added web/src/shared/livekit/react/hooks/useSortedParticipants.ts web/src/shared/livekit/react/hooks/useSortedParticipants.ts +20 −0 View web/src/shared/livekit/react/hooks/useSortedParticipants.ts
@@ -0,0 +1,20 @@
1import { sortParticipants } from '@livekit/components-core';
2import type { Participant } from 'livekit-client';
3import * as React from 'react';
4import { useSpeakingParticipants } from './useSpeakingParticipants';
5
6/**
7 * The `useSortedParticipants` hook returns the participants sorted by importance.
8 * @public
9 */
10export function useSortedParticipants(participants: Array<Participant>) {
11 const [sortedParticipants, setSortedParticipants] = React.useState(
12 sortParticipants(participants),
13 );
14 const activeSpeakers = useSpeakingParticipants();
15
16 React.useEffect(() => {
17 setSortedParticipants(sortParticipants(participants));
18 }, [activeSpeakers, participants]);
19 return sortedParticipants;
20}
Added web/src/shared/livekit/react/hooks/useSpeakingParticipants.ts web/src/shared/livekit/react/hooks/useSpeakingParticipants.ts +27 −0 View web/src/shared/livekit/react/hooks/useSpeakingParticipants.ts
@@ -0,0 +1,27 @@
1import { activeSpeakerObserver } from '@livekit/components-core';
2import * as React from 'react';
3import { useEnsureRoom } from '../context';
4import { useObservableState } from './internal';
5import { Room } from 'livekit-client';
6
7/** @public */
8export type UseSpeakingParticipantsOptions = {
9 room?: Room;
10};
11
12/**
13 * The `useSpeakingParticipants` hook returns only the active speakers of all participants.
14 *
15 * @example
16 * ```tsx
17 * const activeSpeakers = useSpeakingParticipants();
18 * ```
19 * @public
20 */
21export function useSpeakingParticipants(options?: UseSpeakingParticipantsOptions) {
22 const ensuredRoom = useEnsureRoom(options?.room);
23
24 const speakerObserver = React.useMemo(() => activeSpeakerObserver(ensuredRoom), [ensuredRoom]);
25 const activeSpeakers = useObservableState(speakerObserver, ensuredRoom.activeSpeakers);
26 return activeSpeakers;
27}
Added web/src/shared/livekit/react/hooks/useStartAudio.ts web/src/shared/livekit/react/hooks/useStartAudio.ts +50 −0 View web/src/shared/livekit/react/hooks/useStartAudio.ts
@@ -0,0 +1,50 @@
1import { setupStartAudio } from '@livekit/components-core';
2import type { Room } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureRoom } from '../context';
5import { mergeProps } from '../mergeProps';
6import { useObservableState } from './internal';
7
8/** @alpha */
9export interface UseStartAudioProps {
10 room?: Room;
11 props: React.ButtonHTMLAttributes<HTMLButtonElement>;
12}
13
14/**
15 * In many browsers to start audio playback, the user must perform a user-initiated event such as clicking a button.
16 * The `useStatAudio` hook returns an object with a boolean `canPlayAudio` flag
17 * that indicates whether audio playback is allowed in the current context,
18 * as well as a `startAudio` function that can be called in a button `onClick` callback to start audio playback in the current context.
19 *
20 * @see Autoplay policy on MDN web docs for more info: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
21 * @alpha
22 */
23export function useStartAudio({ room, props }: UseStartAudioProps) {
24 const roomEnsured = useEnsureRoom(room);
25 const { className, roomAudioPlaybackAllowedObservable, handleStartAudioPlayback } = React.useMemo(
26 () => setupStartAudio(),
27 [],
28 );
29 const observable = React.useMemo(
30 () => roomAudioPlaybackAllowedObservable(roomEnsured),
31 [roomEnsured, roomAudioPlaybackAllowedObservable],
32 );
33 const { canPlayAudio } = useObservableState(observable, {
34 canPlayAudio: roomEnsured.canPlaybackAudio,
35 });
36
37 const mergedProps = React.useMemo(
38 () =>
39 mergeProps(props, {
40 className,
41 onClick: () => {
42 handleStartAudioPlayback(roomEnsured);
43 },
44 style: { display: canPlayAudio ? 'none' : 'block' },
45 }),
46 [props, className, canPlayAudio, handleStartAudioPlayback, roomEnsured],
47 );
48
49 return { mergedProps, canPlayAudio };
50}
Added web/src/shared/livekit/react/hooks/useStartVideo.ts web/src/shared/livekit/react/hooks/useStartVideo.ts +49 −0 View web/src/shared/livekit/react/hooks/useStartVideo.ts
@@ -0,0 +1,49 @@
1import { setupStartVideo } from '@livekit/components-core';
2import type { Room } from 'livekit-client';
3import * as React from 'react';
4import { useEnsureRoom } from '../context';
5import { mergeProps } from '../mergeProps';
6import { useObservableState } from './internal';
7
8/** @alpha */
9export interface UseStartVideoProps {
10 room?: Room;
11 props: React.ButtonHTMLAttributes<HTMLButtonElement>;
12}
13
14/**
15 * In some browsers to start video playback in low power mode, the user must perform a user-initiated event such as clicking a button.
16 * The `useStartVideo` hook returns an object with a boolean `canPlayVideo` flag
17 * that indicates whether video playback is allowed in the current context,
18 * as well as a `startVideo` function that can be called in a button `onClick` callback to start video playback in the current context.
19 *
20 * @alpha
21 */
22export function useStartVideo({ room, props }: UseStartVideoProps) {
23 const roomEnsured = useEnsureRoom(room);
24 const { className, roomVideoPlaybackAllowedObservable, handleStartVideoPlayback } = React.useMemo(
25 () => setupStartVideo(),
26 [],
27 );
28 const observable = React.useMemo(
29 () => roomVideoPlaybackAllowedObservable(roomEnsured),
30 [roomEnsured, roomVideoPlaybackAllowedObservable],
31 );
32 const { canPlayVideo } = useObservableState(observable, {
33 canPlayVideo: roomEnsured.canPlaybackVideo,
34 });
35
36 const mergedProps = React.useMemo(
37 () =>
38 mergeProps(props, {
39 className,
40 onClick: () => {
41 handleStartVideoPlayback(roomEnsured);
42 },
43 style: { display: canPlayVideo ? 'none' : 'block' },
44 }),
45 [props, className, canPlayVideo, handleStartVideoPlayback, roomEnsured],
46 );
47
48 return { mergedProps, canPlayVideo };
49}
Added web/src/shared/livekit/react/hooks/useSwipe.ts web/src/shared/livekit/react/hooks/useSwipe.ts +68 −0 View web/src/shared/livekit/react/hooks/useSwipe.ts
@@ -0,0 +1,68 @@
1import * as React from 'react';
2
3/**
4 * @alpha
5 */
6export type UseSwipeOptions = {
7 minSwipeDistance?: number;
8 onLeftSwipe?: () => void;
9 onRightSwipe?: () => void;
10};
11
12/**
13 * Simple implementation to detect horizontal swipe actions.
14 * Accepts callbacks for on right and left swipes.
15 * @example
16 * ```tsx
17 * <div
18 onTouchStart={onTouchStart}
19 onTouchMove={onTouchMove}
20 onTouchEnd={onTouchEnd}
21 >
22 * ```
23 * @alpha
24 */
25export function useSwipe(element: React.RefObject<HTMLElement | null>, options: UseSwipeOptions = {}) {
26 const touchStart = React.useRef<number | null>(null);
27 const touchEnd = React.useRef<number | null>(null);
28
29 // The required distance between touchStart and touchEnd to be detected as a swipe.
30 const minSwipeDistance = options.minSwipeDistance ?? 50;
31
32 const onTouchStart = (event: TouchEvent) => {
33 touchEnd.current = null; // Otherwise the swipe is fired even with usual touch events.
34 touchStart.current = event.targetTouches[0].clientX;
35 };
36
37 const onTouchMove = (event: TouchEvent) => {
38 touchEnd.current = event.targetTouches[0].clientX;
39 };
40
41 const onTouchEnd = React.useCallback(() => {
42 if (!touchStart.current || !touchEnd.current) {
43 return;
44 }
45 const distance = touchStart.current - touchEnd.current;
46 const isLeftSwipe = distance > minSwipeDistance;
47 const isRightSwipe = distance < -minSwipeDistance;
48
49 if (isLeftSwipe && options.onLeftSwipe) options.onLeftSwipe();
50 if (isRightSwipe && options.onRightSwipe) options.onRightSwipe();
51 }, [minSwipeDistance, options]);
52
53 React.useEffect(() => {
54 const elementCopy = element.current;
55 if (elementCopy) {
56 elementCopy.addEventListener('touchstart', onTouchStart, { passive: true });
57 elementCopy.addEventListener('touchmove', onTouchMove, { passive: true });
58 elementCopy.addEventListener('touchend', onTouchEnd, { passive: true });
59 }
60 return () => {
61 if (elementCopy) {
62 elementCopy.removeEventListener('touchstart', onTouchStart);
63 elementCopy.removeEventListener('touchmove', onTouchMove);
64 elementCopy.removeEventListener('touchend', onTouchEnd);
65 }
66 };
67 }, [element, onTouchEnd]);
68}
Added web/src/shared/livekit/react/hooks/useTextStream.ts web/src/shared/livekit/react/hooks/useTextStream.ts +35 −0 View web/src/shared/livekit/react/hooks/useTextStream.ts
@@ -0,0 +1,35 @@
1import * as React from 'react';
2import { ConnectionState, Room } from 'livekit-client';
3import { setupTextStream, type TextStreamData } from '@livekit/components-core';
4import { useEnsureRoom } from '../context';
5import { useConnectionState } from './useConnectionStatus';
6import { useObservableState } from './internal';
7
8/** @beta */
9export type UseTextStreamOptions = {
10 room?: Room;
11};
12
13/**
14 * @beta
15 * @param topic - the topic to listen to
16 * @returns an array of TextStreamData that holds the text, participantInfo, and streamInfo
17 * @example
18 * ```tsx
19 * const { textStreams } = useTextStream('my-topic');
20 * return <div>{textStreams.map((textStream) => textStream.text)}</div>;
21 * ```
22 */
23export function useTextStream(topic: string, options?: UseTextStreamOptions) {
24 const room = useEnsureRoom(options?.room);
25
26 const connectionState = useConnectionState(room);
27 const isDisconnected = connectionState === ConnectionState.Disconnected;
28
29 const textStreamData = React.useMemo(() => setupTextStream(room, topic), [room, topic]);
30 const textStreamObservable = isDisconnected ? undefined : textStreamData;
31
32 const textStreams = useObservableState<TextStreamData[]>(textStreamObservable, []);
33
34 return { textStreams };
35}
Added web/src/shared/livekit/react/hooks/useToken.ts web/src/shared/livekit/react/hooks/useToken.ts +54 −0 View web/src/shared/livekit/react/hooks/useToken.ts
@@ -0,0 +1,54 @@
1import { log } from '@livekit/components-core';
2import * as React from 'react';
3
4/** @public */
5export interface UserInfo {
6 identity?: string;
7 name?: string;
8 metadata?: string;
9}
10
11/** @public */
12export interface UseTokenOptions {
13 userInfo?: UserInfo;
14}
15
16/**
17 * The `useToken` hook fetches a token from the given token endpoint with the given user info.
18 *
19 * @example
20 * ```tsx
21 * const token = useToken(<token-endpoint>, roomName, { userInfo: { identity, name }});
22 * ```
23 * @public */
24export function useToken(
25 tokenEndpoint: string | undefined,
26 roomName: string,
27 options: UseTokenOptions = {},
28) {
29 const [token, setToken] = React.useState<string | undefined>(undefined);
30
31 React.useEffect(() => {
32 if (tokenEndpoint === undefined) {
33 throw Error('token endpoint needs to be defined');
34 }
35 if (options.userInfo?.identity === undefined) {
36 return;
37 }
38 const tokenFetcher = async () => {
39 log.debug('fetching token');
40 const params = new URLSearchParams({ ...options.userInfo, roomName });
41 const res = await fetch(`${tokenEndpoint}?${params.toString()}`);
42 if (!res.ok) {
43 log.error(
44 `Could not fetch token. Server responded with status ${res.status}: ${res.statusText}`,
45 );
46 return;
47 }
48 const { accessToken } = await res.json();
49 setToken(accessToken);
50 };
51 tokenFetcher();
52 }, [tokenEndpoint, roomName, JSON.stringify(options)]);
53 return token;
54}
Added web/src/shared/livekit/react/hooks/useTrack.ts web/src/shared/livekit/react/hooks/useTrack.ts +11 −0 View web/src/shared/livekit/react/hooks/useTrack.ts
@@ -0,0 +1,11 @@
1// import type { Participant, Track } from 'livekit-client';
2// import { useTrackRefBySourceOrName } from './useTrackRefBySourceOrName';
3// import { useEnsureParticipant } from '../context';
4
5/**
6 * TODO decide whether we want to expose this
7 */
8// function useTrack(source: Track.Source, participant?: Participant) {
9// const p = useEnsureParticipant(participant);
10// return useTrackRefBySourceOrName({ source, participant: p });
11// }
Added web/src/shared/livekit/react/hooks/useTrackByName.ts web/src/shared/livekit/react/hooks/useTrackByName.ts +15 −0 View web/src/shared/livekit/react/hooks/useTrackByName.ts
@@ -0,0 +1,15 @@
1import type { Participant } from 'livekit-client';
2import { useEnsureParticipant } from '../context';
3import { useTrackRefBySourceOrName } from './useTrackRefBySourceOrName';
4
5/**
6 * This function `useTrackByName` allows you to access a track by referencing its track name.
7 * Inside the function, it ensures that the a valid `participant` reference is available by checking
8 * for both a passed participant argument and, if not available, a valid participant context.
9 *
10 * @public
11 */
12export function useTrackByName(name: string, participant?: Participant) {
13 const p = useEnsureParticipant(participant);
14 return useTrackRefBySourceOrName({ name, participant: p });
15}
Added web/src/shared/livekit/react/hooks/useTrackMutedIndicator.ts web/src/shared/livekit/react/hooks/useTrackMutedIndicator.ts +44 −0 View web/src/shared/livekit/react/hooks/useTrackMutedIndicator.ts
@@ -0,0 +1,44 @@
1import {
2 type TrackReferenceOrPlaceholder,
3 setupTrackMutedIndicator,
4 getTrackReferenceId,
5} from '@livekit/components-core';
6import * as React from 'react';
7import { useEnsureTrackRef } from '../context';
8import { useObservableState } from './internal';
9
10interface TrackMutedIndicatorReturnType {
11 isMuted: boolean;
12 className: string;
13}
14
15/**
16 * The `useTrackMutedIndicator` hook is used to implement the `TrackMutedIndicator` component
17 * and returns the muted state of the given track.
18 *
19 * @example
20 * ```tsx
21 * const { isMuted } = useTrackMutedIndicator(trackRef);
22 * ```
23 * @public
24 */
25export function useTrackMutedIndicator(
26 trackRef?: TrackReferenceOrPlaceholder,
27): TrackMutedIndicatorReturnType {
28 const trackReference = useEnsureTrackRef(trackRef);
29
30 const { className, mediaMutedObserver } = React.useMemo(
31 () => setupTrackMutedIndicator(trackReference),
32 [getTrackReferenceId(trackReference)],
33 );
34
35 const isMuted = useObservableState(
36 mediaMutedObserver,
37 !!(
38 trackReference.publication?.isMuted ||
39 trackReference.participant.getTrackPublication(trackReference.source)?.isMuted
40 ),
41 );
42
43 return { isMuted, className };
44}
Added web/src/shared/livekit/react/hooks/useTrackRefBySourceOrName.ts web/src/shared/livekit/react/hooks/useTrackRefBySourceOrName.ts +30 −0 View web/src/shared/livekit/react/hooks/useTrackRefBySourceOrName.ts
@@ -0,0 +1,30 @@
1import type { TrackReferenceOrPlaceholder, TrackSource } from '@livekit/components-core';
2import { setupMediaTrack, getTrackByIdentifier } from '@livekit/components-core';
3import * as React from 'react';
4import { Track } from 'livekit-client';
5
6/**
7 * @internal
8 */
9export function useTrackRefBySourceOrName(
10 source: TrackSource<Track.Source>,
11): TrackReferenceOrPlaceholder {
12 const [publication, setPublication] = React.useState(getTrackByIdentifier(source));
13
14 const { trackObserver } = React.useMemo(() => {
15 return setupMediaTrack(source);
16 }, [source.participant.sid ?? source.participant.identity, source.source]);
17
18 React.useEffect(() => {
19 const subscription = trackObserver.subscribe((publication) => {
20 setPublication(publication);
21 });
22 return () => subscription?.unsubscribe();
23 }, [trackObserver]);
24
25 return {
26 participant: source.participant,
27 source: source.source ?? Track.Source.Unknown,
28 publication,
29 };
30}
Added web/src/shared/livekit/react/hooks/useTracks.ts web/src/shared/livekit/react/hooks/useTracks.ts +154 −0 View web/src/shared/livekit/react/hooks/useTracks.ts
@@ -0,0 +1,154 @@
1import type {
2 SourcesArray,
3 TrackReference,
4 TrackReferenceOrPlaceholder,
5 TrackSourceWithOptions,
6 TrackReferencePlaceholder,
7} from '@livekit/components-core';
8import {
9 isSourcesWithOptions,
10 isSourceWitOptions,
11 log,
12 trackReferencesObservable,
13} from '@livekit/components-core';
14import type { Participant, Room, RoomEvent } from 'livekit-client';
15import { Track } from 'livekit-client';
16import * as React from 'react';
17import { useEnsureRoom } from '../context';
18
19/** @public */
20export type UseTracksOptions = {
21 updateOnlyOn?: RoomEvent[];
22 onlySubscribed?: boolean;
23 room?: Room;
24};
25
26/** @public */
27export type UseTracksHookReturnType<T> = T extends Track.Source[]
28 ? TrackReference[]
29 : T extends TrackSourceWithOptions[]
30 ? TrackReferenceOrPlaceholder[]
31 : never;
32
33/**
34 * The `useTracks` hook returns an array of `TrackReference` or `TrackReferenceOrPlaceholder` depending on the provided `sources` property.
35 * If only subscribed tracks are desired, set the `onlySubscribed` property to `true`.
36 * @example
37 * ```ts
38 * // Return all camera track publications.
39 * const trackReferences: TrackReference[] = useTracks([Track.Source.Camera])
40 * ```
41 * @example
42 * ```ts
43 * // Return all subscribed camera tracks as well as placeholders for
44 * // participants without a camera subscription.
45 * const trackReferencesWithPlaceholders: TrackReferenceOrPlaceholder[] = useTracks([{source: Track.Source.Camera, withPlaceholder: true}])
46 * ```
47 * @public
48 */
49export function useTracks<T extends SourcesArray = Track.Source[]>(
50 sources: T = [
51 Track.Source.Camera,
52 Track.Source.Microphone,
53 Track.Source.ScreenShare,
54 Track.Source.ScreenShareAudio,
55 Track.Source.Unknown,
56 ] as T,
57 options: UseTracksOptions = {},
58): UseTracksHookReturnType<T> {
59 const room = useEnsureRoom(options.room);
60 const [trackReferences, setTrackReferences] = React.useState<TrackReference[]>([]);
61 const [participants, setParticipants] = React.useState<Participant[]>([]);
62
63 const sources_ = React.useMemo(() => {
64 return sources.map((s) => (isSourceWitOptions(s) ? s.source : s));
65 }, [JSON.stringify(sources)]);
66
67 React.useEffect(() => {
68 const subscription = trackReferencesObservable(room, sources_, {
69 additionalRoomEvents: options.updateOnlyOn,
70 onlySubscribed: options.onlySubscribed,
71 }).subscribe(({ trackReferences, participants }) => {
72 log.debug('setting track bundles', trackReferences, participants);
73 setTrackReferences(trackReferences);
74 setParticipants(participants);
75 });
76 return () => subscription.unsubscribe();
77 }, [
78 room,
79 JSON.stringify(options.onlySubscribed),
80 JSON.stringify(options.updateOnlyOn),
81 JSON.stringify(sources),
82 ]);
83
84 const maybeTrackReferences = React.useMemo(() => {
85 if (isSourcesWithOptions(sources)) {
86 const requirePlaceholder = requiredPlaceholders(sources, participants);
87 const trackReferencesWithPlaceholders: TrackReferenceOrPlaceholder[] =
88 Array.from(trackReferences);
89 participants.forEach((participant) => {
90 if (requirePlaceholder.has(participant.identity)) {
91 const sourcesToAddPlaceholder = requirePlaceholder.get(participant.identity) ?? [];
92 sourcesToAddPlaceholder.forEach((placeholderSource) => {
93 if (
94 trackReferences.find(
95 ({ participant: p, publication }) =>
96 participant.identity === p.identity && publication.source === placeholderSource,
97 )
98 ) {
99 return;
100 }
101 log.debug(
102 `Add ${placeholderSource} placeholder for participant ${participant.identity}.`,
103 );
104 const placeholder: TrackReferencePlaceholder = {
105 participant,
106 source: placeholderSource,
107 };
108 trackReferencesWithPlaceholders.push(placeholder);
109 });
110 }
111 });
112 return trackReferencesWithPlaceholders;
113 } else {
114 return trackReferences;
115 }
116 }, [trackReferences, participants, sources]);
117
118 return maybeTrackReferences as UseTracksHookReturnType<T>;
119}
120
121function difference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
122 const _difference = new Set(setA);
123 for (const elem of setB) {
124 _difference.delete(elem);
125 }
126 return _difference;
127}
128
129export function requiredPlaceholders<T extends SourcesArray>(
130 sources: T,
131 participants: Participant[],
132): Map<Participant['identity'], Track.Source[]> {
133 const placeholderMap = new Map<Participant['identity'], Track.Source[]>();
134 if (isSourcesWithOptions(sources)) {
135 const sourcesThatNeedPlaceholder = sources
136 .filter((sourceWithOption) => sourceWithOption.withPlaceholder)
137 .map((sourceWithOption) => sourceWithOption.source);
138
139 participants.forEach((participant) => {
140 const sourcesOfSubscribedTracks = participant
141 .getTrackPublications()
142 .map((pub) => pub.track?.source)
143 .filter((trackSource): trackSource is Track.Source => trackSource !== undefined);
144 const placeholderNeededForThisParticipant = Array.from(
145 difference(new Set(sourcesThatNeedPlaceholder), new Set(sourcesOfSubscribedTracks)),
146 );
147 // If the participant needs placeholder add it to the placeholder map.
148 if (placeholderNeededForThisParticipant.length > 0) {
149 placeholderMap.set(participant.identity, placeholderNeededForThisParticipant);
150 }
151 });
152 }
153 return placeholderMap;
154}
Added web/src/shared/livekit/react/hooks/useTrackSyncTime.ts web/src/shared/livekit/react/hooks/useTrackSyncTime.ts +18 −0 View web/src/shared/livekit/react/hooks/useTrackSyncTime.ts
@@ -0,0 +1,18 @@
1import * as React from 'react';
2import { type TrackReferenceOrPlaceholder, trackSyncTimeObserver } from '@livekit/components-core';
3import { useObservableState } from './internal';
4
5/**
6 * @internal
7 * @deprecated this is an internal hook that's only used by deprecated APIs
8 */
9export function useTrackSyncTime(ref: TrackReferenceOrPlaceholder | undefined) {
10 const observable = React.useMemo(
11 () => (ref?.publication?.track ? trackSyncTimeObserver(ref?.publication.track) : undefined),
12 [ref?.publication?.track],
13 );
14 return useObservableState(observable, {
15 timestamp: Date.now(),
16 rtpTimestamp: ref?.publication?.track?.rtpTimestamp,
17 });
18}
Added web/src/shared/livekit/react/hooks/useTrackToggle.ts web/src/shared/livekit/react/hooks/useTrackToggle.ts +95 −0 View web/src/shared/livekit/react/hooks/useTrackToggle.ts
@@ -0,0 +1,95 @@
1import type { ToggleSource } from '@livekit/components-core';
2import { setupMediaToggle, setupManualToggle, log } from '@livekit/components-core';
3import * as React from 'react';
4import { type Room } from 'livekit-client';
5import type { TrackToggleProps } from '../components';
6import { useMaybeRoomContext } from '../context';
7import { mergeProps } from '../mergeProps';
8import { useObservableState } from './internal';
9
10/** @public */
11export interface UseTrackToggleProps<T extends ToggleSource> extends Omit<
12 TrackToggleProps<T>,
13 'showIcon'
14> {
15 room?: Room;
16}
17
18/**
19 * The `useTrackToggle` hook is used to implement the `TrackToggle` component and returns state
20 * and functionality of the given track.
21 *
22 * @example
23 * ```tsx
24 * const { buttonProps, enabled } = useTrackToggle(trackRef);
25 * return <button {...buttonProps}>{enabled ? 'disable' : 'enable'}</button>;
26 * ```
27 * @public
28 */
29export function useTrackToggle<T extends ToggleSource>({
30 source,
31 onChange,
32 initialState,
33 captureOptions,
34 publishOptions,
35 onDeviceError,
36 room,
37 ...rest
38}: UseTrackToggleProps<T>) {
39 const roomFromContext = useMaybeRoomContext();
40 const roomFallback = React.useMemo(() => room ?? roomFromContext, [room, roomFromContext]);
41 const track = roomFallback?.localParticipant?.getTrackPublication(source);
42 /** `true` if a user interaction such as a click on the TrackToggle button has occurred. */
43 const userInteractionRef = React.useRef(false);
44
45 const { toggle, className, pendingObserver, enabledObserver } = React.useMemo(
46 () =>
47 roomFallback
48 ? setupMediaToggle<T>(source, roomFallback, captureOptions, publishOptions, onDeviceError)
49 : setupManualToggle(),
50 [roomFallback, source, JSON.stringify(captureOptions), publishOptions],
51 );
52
53 const pending = useObservableState(pendingObserver, false);
54 const enabled = useObservableState(enabledObserver, initialState ?? !!track?.isEnabled);
55
56 React.useEffect(() => {
57 onChange?.(enabled, userInteractionRef.current);
58 userInteractionRef.current = false;
59 }, [enabled, onChange]);
60
61 React.useEffect(() => {
62 if (initialState !== undefined) {
63 log.debug('forcing initial toggle state', source, initialState);
64 toggle(initialState);
65 }
66 // only execute once at the beginning
67 // eslint-disable-next-line react-hooks/exhaustive-deps
68 }, []);
69
70 const newProps = React.useMemo(() => mergeProps(rest, { className }), [rest, className]);
71
72 const clickHandler: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(
73 (evt) => {
74 userInteractionRef.current = true;
75 toggle().catch(() => (userInteractionRef.current = false));
76 rest.onClick?.(evt);
77 },
78 [rest, toggle],
79 );
80
81 return {
82 toggle,
83 enabled,
84 pending,
85 track,
86 buttonProps: {
87 ...newProps,
88 'aria-pressed': enabled,
89 'data-lk-source': source,
90 'data-lk-enabled': enabled,
91 disabled: pending,
92 onClick: clickHandler,
93 } as React.ButtonHTMLAttributes<HTMLButtonElement>,
94 };
95}
Added web/src/shared/livekit/react/hooks/useTrackTranscription.ts web/src/shared/livekit/react/hooks/useTrackTranscription.ts +75 −0 View web/src/shared/livekit/react/hooks/useTrackTranscription.ts
@@ -0,0 +1,75 @@
1import {
2 type ReceivedTranscriptionSegment,
3 addMediaTimestampToTranscription as addTimestampsToTranscription,
4 dedupeSegments,
5 // getActiveTranscriptionSegments,
6 getTrackReferenceId,
7 trackTranscriptionObserver,
8 type TrackReferenceOrPlaceholder,
9 // didActiveSegmentsChange,
10} from '@livekit/components-core';
11import type { TranscriptionSegment } from 'livekit-client';
12import * as React from 'react';
13import { useTrackSyncTime } from './useTrackSyncTime';
14
15/**
16 * @alpha
17 * @deprecated Use useTranscription instead
18 */
19export interface TrackTranscriptionOptions {
20 /**
21 * how many transcription segments should be buffered in state
22 * @defaultValue 100
23 */
24 bufferSize?: number;
25 /**
26 * optional callback for retrieving newly incoming transcriptions only
27 */
28 onTranscription?: (newSegments: TranscriptionSegment[]) => void;
29 /** amount of time (in ms) that the segment is considered `active` past its original segment duration, defaults to 2_000 */
30 // maxAge?: number;
31}
32
33const TRACK_TRANSCRIPTION_DEFAULTS = {
34 bufferSize: 100,
35 // maxAge: 2_000,
36} as const satisfies TrackTranscriptionOptions;
37
38/**
39 * @returns An object consisting of `segments` with maximum length of opts.bufferSize
40 * @alpha
41 * @deprecated Use useTranscription instead
42 */
43export function useTrackTranscription(
44 trackRef: TrackReferenceOrPlaceholder | undefined,
45 options?: TrackTranscriptionOptions,
46) {
47 const opts = { ...TRACK_TRANSCRIPTION_DEFAULTS, ...options };
48 const [segments, setSegments] = React.useState<Array<ReceivedTranscriptionSegment>>([]);
49
50 const syncTimestamps = useTrackSyncTime(trackRef);
51 const handleSegmentMessage = (newSegments: TranscriptionSegment[]) => {
52 opts.onTranscription?.(newSegments);
53 setSegments((prevSegments) =>
54 dedupeSegments(
55 prevSegments,
56 // when first receiving a segment, add the current media timestamp to it
57 newSegments.map((s) => addTimestampsToTranscription(s, syncTimestamps)),
58 opts.bufferSize,
59 ),
60 );
61 };
62 React.useEffect(() => {
63 if (!trackRef?.publication) {
64 return;
65 }
66 const subscription = trackTranscriptionObserver(trackRef.publication).subscribe((evt) => {
67 handleSegmentMessage(...evt);
68 });
69 return () => {
70 subscription.unsubscribe();
71 };
72 }, [trackRef && getTrackReferenceId(trackRef), handleSegmentMessage]);
73
74 return { segments };
75}
Added web/src/shared/livekit/react/hooks/useTrackVolume.ts web/src/shared/livekit/react/hooks/useTrackVolume.ts +283 −0 View web/src/shared/livekit/react/hooks/useTrackVolume.ts
@@ -0,0 +1,283 @@
1import * as React from 'react';
2import type { LocalAudioTrack, RemoteAudioTrack, AudioAnalyserOptions } from 'livekit-client';
3import { Track, createAudioAnalyser } from 'livekit-client';
4import {
5 type TrackReference,
6 isTrackReference,
7 type TrackReferenceOrPlaceholder,
8} from '@livekit/components-core';
9
10/**
11 * @alpha
12 * Hook for tracking the volume of an audio track using the Web Audio API.
13 */
14export function useTrackVolume(
15 trackOrTrackReference?: LocalAudioTrack | RemoteAudioTrack | TrackReference,
16 options: AudioAnalyserOptions = { fftSize: 32, smoothingTimeConstant: 0 },
17) {
18 const track = isTrackReference(trackOrTrackReference)
19 ? (trackOrTrackReference.publication.track as LocalAudioTrack | RemoteAudioTrack | undefined)
20 : trackOrTrackReference;
21 const [volume, setVolume] = React.useState(0);
22 React.useEffect(() => {
23 if (!track || !track.mediaStream) {
24 return;
25 }
26
27 const { cleanup, analyser } = createAudioAnalyser(track, options);
28
29 const bufferLength = analyser.frequencyBinCount;
30 const dataArray = new Uint8Array(bufferLength);
31
32 const updateVolume = () => {
33 analyser.getByteFrequencyData(dataArray);
34 let sum = 0;
35 for (let i = 0; i < dataArray.length; i++) {
36 const a = dataArray[i];
37 sum += a * a;
38 }
39 setVolume(Math.sqrt(sum / dataArray.length) / 255);
40 };
41
42 const interval = setInterval(updateVolume, 1000 / 30);
43
44 return () => {
45 cleanup();
46 clearInterval(interval);
47 };
48 }, [track, track?.mediaStream, JSON.stringify(options)]);
49
50 return volume;
51}
52
53const normalizeFrequencies = (frequencies: Float32Array) => {
54 const normalizeDb = (value: number) => {
55 const minDb = -100;
56 const maxDb = -10;
57 let db = 1 - (Math.max(minDb, Math.min(maxDb, value)) * -1) / 100;
58 db = Math.sqrt(db);
59
60 return db;
61 };
62
63 // Normalize all frequency values
64 return frequencies.map((value) => {
65 if (value === -Infinity) {
66 return 0;
67 }
68 return normalizeDb(value);
69 });
70};
71
72/**
73 * Interface for configuring options for the useMultibandTrackVolume hook.
74 * @alpha
75 */
76export interface MultiBandTrackVolumeOptions {
77 bands?: number;
78 /**
79 * cut off of frequency bins on the lower end
80 * Note: this is not a frequency measure, but in relation to analyserOptions.fftSize,
81 */
82 loPass?: number;
83 /**
84 * cut off of frequency bins on the higher end
85 * Note: this is not a frequency measure, but in relation to analyserOptions.fftSize,
86 */
87 hiPass?: number;
88 /**
89 * update should run every x ms
90 */
91 updateInterval?: number;
92 analyserOptions?: AnalyserOptions;
93}
94
95const multibandDefaults = {
96 bands: 5,
97 loPass: 100,
98 hiPass: 600,
99 updateInterval: 32,
100 analyserOptions: { fftSize: 2048 },
101} as const satisfies MultiBandTrackVolumeOptions;
102
103/**
104 * Hook for tracking the volume of an audio track across multiple frequency bands using the Web Audio API.
105 * @alpha
106 */
107export function useMultibandTrackVolume(
108 trackOrTrackReference?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder,
109 options: MultiBandTrackVolumeOptions = {},
110) {
111 const track =
112 trackOrTrackReference instanceof Track
113 ? trackOrTrackReference
114 : (trackOrTrackReference?.publication?.track as LocalAudioTrack | RemoteAudioTrack | undefined);
115 const opts = { ...multibandDefaults, ...options };
116 const [frequencyBands, setFrequencyBands] = React.useState<Array<number>>(
117 new Array(opts.bands).fill(0),
118 );
119
120 React.useEffect(() => {
121 if (!track || !track?.mediaStream) {
122 setFrequencyBands((val) => val.slice().fill(0));
123 return;
124 }
125 const { analyser, cleanup } = createAudioAnalyser(track, opts.analyserOptions);
126
127 const bufferLength = analyser.frequencyBinCount;
128 const dataArray = new Float32Array(bufferLength);
129
130 const updateVolume = () => {
131 analyser.getFloatFrequencyData(dataArray);
132 let frequencies: Float32Array = new Float32Array(dataArray.length);
133 for (let i = 0; i < dataArray.length; i++) {
134 frequencies[i] = dataArray[i];
135 }
136 frequencies = frequencies.slice(opts.loPass, opts.hiPass);
137
138 const normalizedFrequencies = normalizeFrequencies(frequencies); // is this needed ?
139 const totalBins = normalizedFrequencies.length;
140 const chunks: Array<number> = [];
141 for (let i = 0; i < opts.bands; i++) {
142 // Use proportional distribution to evenly divide bins across bands
143 const startIndex = Math.floor((i * totalBins) / opts.bands);
144 const endIndex = Math.floor(((i + 1) * totalBins) / opts.bands);
145 const chunk = normalizedFrequencies.slice(startIndex, endIndex);
146 const chunkLength = chunk.length;
147 if (chunkLength === 0) {
148 chunks.push(0);
149 } else {
150 const summedVolumes = chunk.reduce((acc, val) => (acc += val), 0);
151 chunks.push(summedVolumes / chunkLength);
152 }
153 }
154
155 setFrequencyBands(chunks);
156 };
157
158 const interval = setInterval(updateVolume, opts.updateInterval);
159
160 return () => {
161 cleanup();
162 clearInterval(interval);
163 };
164 }, [track, track?.mediaStream, JSON.stringify(options)]);
165
166 return frequencyBands;
167}
168
169/**
170 * @alpha
171 */
172export interface AudioWaveformOptions {
173 barCount?: number;
174 volMultiplier?: number;
175 updateInterval?: number;
176}
177
178const waveformDefaults = {
179 barCount: 120,
180 volMultiplier: 5,
181 updateInterval: 20,
182} as const satisfies AudioWaveformOptions;
183
184/**
185 * @alpha
186 */
187export function useAudioWaveform(
188 trackOrTrackReference?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder,
189 options: AudioWaveformOptions = {},
190) {
191 const track =
192 trackOrTrackReference instanceof Track
193 ? trackOrTrackReference
194 : (trackOrTrackReference?.publication?.track as LocalAudioTrack | RemoteAudioTrack | undefined);
195 const opts = { ...waveformDefaults, ...options };
196
197 const aggregateWave = React.useRef(new Float32Array());
198 const timeRef = React.useRef(performance.now());
199 const updates = React.useRef(0);
200 const [bars, setBars] = React.useState<number[]>([]);
201
202 const onUpdate = React.useCallback((wave: Float32Array) => {
203 setBars(
204 Array.from(
205 filterData(wave, opts.barCount).map((v) => Math.sqrt(v) * opts.volMultiplier),
206 // wave.slice(0, opts.barCount).map((v) => sigmoid(v * opts.volMultiplier, 0.08, 0.2)),
207 ),
208 );
209 }, []);
210
211 React.useEffect(() => {
212 if (!track || !track?.mediaStream) {
213 return;
214 }
215 const { analyser, cleanup } = createAudioAnalyser(track, {
216 fftSize: getFFTSizeValue(opts.barCount),
217 });
218
219 const bufferLength = getFFTSizeValue(opts.barCount);
220 const dataArray = new Float32Array(bufferLength);
221
222 const update = () => {
223 updateWaveform = requestAnimationFrame(update);
224 analyser.getFloatTimeDomainData(dataArray);
225 aggregateWave.current.map((v, i) => v + dataArray[i]);
226 updates.current += 1;
227
228 if (performance.now() - timeRef.current >= opts.updateInterval) {
229 const newData = dataArray.map((v) => v / updates.current);
230 onUpdate(newData);
231 timeRef.current = performance.now();
232 updates.current = 0;
233 }
234 };
235
236 let updateWaveform = requestAnimationFrame(update);
237
238 return () => {
239 cleanup();
240 cancelAnimationFrame(updateWaveform);
241 };
242 }, [track, track?.mediaStream, JSON.stringify(options), onUpdate]);
243
244 return {
245 bars,
246 };
247}
248
249function getFFTSizeValue(x: number) {
250 if (x < 32) return 32;
251 else return pow2ceil(x);
252}
253
254// function sigmoid(x: number, k = 2, s = 0) {
255// return 1 / (1 + Math.exp(-(x - s) / k));
256// }
257
258function pow2ceil(v: number) {
259 let p = 2;
260 while ((v >>= 1)) {
261 p <<= 1;
262 }
263 return p;
264}
265
266function filterData(audioData: Float32Array, numSamples: number) {
267 const blockSize = Math.floor(audioData.length / numSamples); // the number of samples in each subdivision
268 const filteredData = new Float32Array(numSamples);
269 for (let i = 0; i < numSamples; i++) {
270 const blockStart = blockSize * i; // the location of the first sample in the block
271 let sum = 0;
272 for (let j = 0; j < blockSize; j++) {
273 sum = sum + Math.abs(audioData[blockStart + j]); // find the sum of all the samples in the block
274 }
275 filteredData[i] = sum / blockSize; // divide the sum by the block size to get the average
276 }
277 return filteredData;
278}
279
280// function normalizeData(audioData: Float32Array) {
281// const multiplier = Math.pow(Math.max(...audioData), -1);
282// return audioData.map((n) => n * multiplier);
283// }
Added web/src/shared/livekit/react/hooks/useTranscriptions.ts web/src/shared/livekit/react/hooks/useTranscriptions.ts +48 −0 View web/src/shared/livekit/react/hooks/useTranscriptions.ts
@@ -0,0 +1,48 @@
1import * as React from 'react';
2import { useTextStream } from './useTextStream';
3import { DataTopic, ParticipantAgentAttributes } from '@livekit/components-core';
4import { Room } from 'livekit-client';
5
6/**
7 * @beta
8 */
9export interface UseTranscriptionsOptions {
10 room?: Room;
11 participantIdentities?: string[];
12 trackSids?: string[];
13}
14
15/**
16 * @beta
17 * useTranscriptions is a hook that returns the transcriptions for the given participant identities and track sids,
18 * if no options are provided, it will return all transcriptions
19 * @example
20 * ```tsx
21 * const transcriptions = useTranscriptions();
22 * return <div>{transcriptions.map((transcription) => transcription.text)}</div>;
23 * ```
24 */
25export function useTranscriptions(opts?: UseTranscriptionsOptions) {
26 const { participantIdentities, trackSids } = opts ?? {};
27 const { textStreams } = useTextStream(DataTopic.TRANSCRIPTION, { room: opts?.room });
28
29 const filteredMessages = React.useMemo(
30 () =>
31 textStreams
32 .filter((stream) =>
33 participantIdentities
34 ? participantIdentities.includes(stream.participantInfo.identity)
35 : true,
36 )
37 .filter((stream) =>
38 trackSids
39 ? trackSids.includes(
40 stream.streamInfo.attributes?.[ParticipantAgentAttributes.TranscribedTrackId] ?? '',
41 )
42 : true,
43 ),
44 [textStreams, participantIdentities, trackSids],
45 );
46
47 return filteredMessages;
48}
Added web/src/shared/livekit/react/hooks/useVisualStableUpdate.ts web/src/shared/livekit/react/hooks/useVisualStableUpdate.ts +63 −0 View web/src/shared/livekit/react/hooks/useVisualStableUpdate.ts
@@ -0,0 +1,63 @@
1import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
2import { log, sortTrackReferences, updatePages } from '@livekit/components-core';
3import * as React from 'react';
4
5/** @public */
6export interface UseVisualStableUpdateOptions {
7 /** Overwrites the default sort function. */
8 customSortFunction?: (
9 trackReferences: TrackReferenceOrPlaceholder[],
10 ) => TrackReferenceOrPlaceholder[];
11}
12
13/**
14 * The `useVisualStableUpdate` hook is used to prevent visually jarring jumps and shifts of elements
15 * in an array. The algorithm only starts to update when there are more items than visually fit
16 * on a page. If this is the case, it will make sure that speaking participants move to the first
17 * page and are always visible.
18 * @remarks
19 * Updating the array can occur because attendees leave or join a room, or because they mute/unmute
20 * or start speaking.
21 * The hook is used for the `GridLayout` and `CarouselLayout` components.
22 *
23 * @example
24 * ```tsx
25 * const trackRefs = useTracks();
26 * const updatedTrackRefs = useVisualStableUpdate(trackRefs, itemPerPage);
27 * ```
28 * @public
29 */
30export function useVisualStableUpdate(
31 /** `TrackReference`s to display in the grid. */
32 trackReferences: TrackReferenceOrPlaceholder[],
33 maxItemsOnPage: number,
34 options: UseVisualStableUpdateOptions = {},
35): TrackReferenceOrPlaceholder[] {
36 const lastTrackRefs = React.useRef<TrackReferenceOrPlaceholder[]>([]);
37 const lastMaxItemsOnPage = React.useRef<number>(-1);
38 const layoutChanged = maxItemsOnPage !== lastMaxItemsOnPage.current;
39
40 const sortedTrackRefs =
41 typeof options.customSortFunction === 'function'
42 ? options.customSortFunction(trackReferences)
43 : sortTrackReferences(trackReferences);
44
45 let updatedTrackRefs: TrackReferenceOrPlaceholder[] = [...sortedTrackRefs];
46 if (layoutChanged === false) {
47 try {
48 updatedTrackRefs = updatePages(lastTrackRefs.current, sortedTrackRefs, maxItemsOnPage);
49 } catch (error) {
50 log.error('Error while running updatePages(): ', error);
51 }
52 }
53
54 // Save info for to compare against in the next update cycle.
55 if (layoutChanged) {
56 lastTrackRefs.current = sortedTrackRefs;
57 } else {
58 lastTrackRefs.current = updatedTrackRefs;
59 }
60 lastMaxItemsOnPage.current = maxItemsOnPage;
61
62 return updatedTrackRefs;
63}
Added web/src/shared/livekit/react/hooks/useVoiceAssistant.ts web/src/shared/livekit/react/hooks/useVoiceAssistant.ts +109 −0 View web/src/shared/livekit/react/hooks/useVoiceAssistant.ts
@@ -0,0 +1,109 @@
1import * as React from 'react';
2import { ConnectionState, ParticipantKind, Track } from 'livekit-client';
3import type { RemoteParticipant } from 'livekit-client';
4import {
5 ParticipantAgentAttributes,
6 type ReceivedTranscriptionSegment,
7 type TrackReference,
8} from '@livekit/components-core';
9import { useRemoteParticipants } from './useRemoteParticipants';
10import { useParticipantTracks } from './useParticipantTracks';
11import { useTrackTranscription } from './useTrackTranscription';
12import { useConnectionState } from './useConnectionStatus';
13import { useParticipantAttributes } from './useParticipantAttributes';
14import { type AgentState } from './useAgent';
15
16/**
17 * @beta
18 */
19export interface VoiceAssistant {
20 /**
21 * The agent participant.
22 */
23 agent: RemoteParticipant | undefined;
24 /**
25 * The current state of the agent.
26 */
27 state: AgentState;
28 /**
29 * The microphone track published by the agent or associated avatar worker (if any).
30 */
31 audioTrack: TrackReference | undefined;
32 /**
33 * The camera track published by the agent or associated avatar worker (if any).
34 */
35 videoTrack: TrackReference | undefined;
36 /**
37 * The transcriptions of the agent's microphone track (if any).
38 */
39 agentTranscriptions: ReceivedTranscriptionSegment[];
40 /**
41 * The agent's participant attributes.
42 */
43 agentAttributes: RemoteParticipant['attributes'] | undefined;
44}
45
46const state_attribute = ParticipantAgentAttributes.AgentState;
47
48/**
49 * This hook looks for the first agent-participant in the room.
50 * @remarks This hook requires an agent running with livekit-agents \>= 0.9.0
51 * @example
52 * ```tsx
53 * const { state, audioTrack, agentTranscriptions, agentAttributes } = useVoiceAssistant();
54 * ```
55 * @beta
56 */
57export function useVoiceAssistant(): VoiceAssistant {
58 const remoteParticipants = useRemoteParticipants();
59 const agent = remoteParticipants.find(
60 (p) =>
61 p.kind === ParticipantKind.AGENT &&
62 !(ParticipantAgentAttributes.PublishOnBehalf in p.attributes),
63 );
64 const worker = remoteParticipants.find(
65 (p) =>
66 p.kind === ParticipantKind.AGENT &&
67 p.attributes[ParticipantAgentAttributes.PublishOnBehalf] === agent?.identity,
68 );
69 const agentTracks = useParticipantTracks(
70 [Track.Source.Microphone, Track.Source.Camera],
71 agent?.identity,
72 );
73 const workerTracks = useParticipantTracks(
74 [Track.Source.Microphone, Track.Source.Camera],
75 worker?.identity,
76 );
77 const audioTrack =
78 agentTracks.find((t) => t.source === Track.Source.Microphone) ??
79 workerTracks.find((t) => t.source === Track.Source.Microphone);
80 const videoTrack =
81 agentTracks.find((t) => t.source === Track.Source.Camera) ??
82 workerTracks.find((t) => t.source === Track.Source.Camera);
83 const { segments: agentTranscriptions } = useTrackTranscription(audioTrack);
84 const connectionState = useConnectionState();
85 const { attributes } = useParticipantAttributes({ participant: agent });
86
87 const state: AgentState = React.useMemo(() => {
88 if (connectionState === ConnectionState.Disconnected) {
89 return 'disconnected';
90 } else if (
91 connectionState === ConnectionState.Connecting ||
92 !agent ||
93 !attributes?.[state_attribute]
94 ) {
95 return 'connecting';
96 } else {
97 return attributes[state_attribute] as AgentState;
98 }
99 }, [attributes, agent, connectionState]);
100
101 return {
102 agent,
103 state,
104 audioTrack,
105 videoTrack,
106 agentTranscriptions,
107 agentAttributes: attributes,
108 };
109}
Added web/src/shared/livekit/react/hooks/useWarnAboutMissingStyles.ts web/src/shared/livekit/react/hooks/useWarnAboutMissingStyles.ts +11 −0 View web/src/shared/livekit/react/hooks/useWarnAboutMissingStyles.ts
@@ -0,0 +1,11 @@
1import * as React from 'react';
2import { warnAboutMissingStyles } from '../utils';
3
4/**
5 * @internal
6 */
7export function useWarnAboutMissingStyles() {
8 React.useEffect(() => {
9 warnAboutMissingStyles();
10 }, []);
11}
Added web/src/shared/livekit/react/prefabs/AudioConference.tsx web/src/shared/livekit/react/prefabs/AudioConference.tsx +57 −0 View web/src/shared/livekit/react/prefabs/AudioConference.tsx
@@ -0,0 +1,57 @@
1import * as React from 'react';
2import { ControlBar } from './ControlBar';
3
4import { ParticipantAudioTile } from '../components/participant/ParticipantAudioTile';
5import { LayoutContextProvider } from '../components/layout/LayoutContextProvider';
6import type { WidgetState } from '@livekit/components-core';
7import { Chat } from './Chat';
8import { TrackLoop } from '../components';
9import { useTracks } from '../hooks';
10import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles';
11import { Track } from 'livekit-client';
12
13/** @public */
14export interface AudioConferenceProps extends React.HTMLAttributes<HTMLDivElement> {}
15
16/**
17 * This component is the default setup of a classic LiveKit audio conferencing app.
18 * It provides functionality like switching between participant grid view and focus view.
19 *
20 * @remarks
21 * The component is implemented with other LiveKit components like `FocusContextProvider`,
22 * `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
23 *
24 * @example
25 * ```tsx
26 * <LiveKitRoom>
27 * <AudioConference />
28 * <LiveKitRoom>
29 * ```
30 * @public
31 */
32export function AudioConference({ ...props }: AudioConferenceProps) {
33 const [widgetState, setWidgetState] = React.useState<WidgetState>({
34 showChat: false,
35 unreadMessages: 0,
36 });
37
38 const audioTracks = useTracks([Track.Source.Microphone]);
39
40 useWarnAboutMissingStyles();
41
42 return (
43 <LayoutContextProvider onWidgetChange={setWidgetState}>
44 <div className="lk-audio-conference" {...props}>
45 <div className="lk-audio-conference-stage">
46 <TrackLoop tracks={audioTracks}>
47 <ParticipantAudioTile />
48 </TrackLoop>
49 </div>
50 <ControlBar
51 controls={{ microphone: true, screenShare: false, camera: false, chat: true }}
52 />
53 {widgetState.showChat && <Chat />}
54 </div>
55 </LayoutContextProvider>
56 );
57}
Added web/src/shared/livekit/react/prefabs/Chat.tsx web/src/shared/livekit/react/prefabs/Chat.tsx +154 −0 View web/src/shared/livekit/react/prefabs/Chat.tsx
@@ -0,0 +1,154 @@
1import { type ChatMessage, type ChatOptions } from '@livekit/components-core';
2import * as React from 'react';
3import { useMaybeLayoutContext } from '../context';
4import { cloneSingleChild } from '../utils';
5import type { MessageFormatter } from '../components/ChatEntry';
6import { ChatEntry } from '../components/ChatEntry';
7import { useChat } from '../hooks/useChat';
8import { ChatToggle } from '../components';
9import ChatCloseIcon from '../assets/icons/ChatCloseIcon';
10import { lkT } from '@/shared/livekit/lk-i18n';
11
12/** @public */
13export interface ChatProps extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {
14 messageFormatter?: MessageFormatter;
15}
16
17/**
18 * The Chat component provides ready-to-use chat functionality in a LiveKit room.
19 * Messages are distributed to all participants in the room in real-time.
20 *
21 * @remarks
22 * - Only users who are in the room at the time of dispatch will receive messages
23 * - Message history is not persisted between sessions
24 * - Requires `@livekit/components-styles` to be imported for styling
25 *
26 * @example
27 * ```tsx
28 * import '@livekit/components-styles';
29 *
30 * function Room() {
31 * return (
32 * <LiveKitRoom data-lk-theme="default">
33 * <Chat />
34 * </LiveKitRoom>
35 * );
36 * }
37 * ```
38 *
39 * For custom styling, refer to: https://docs.livekit.io/reference/components/react/concepts/style-components/
40 *
41 * @public
42 */
43export function Chat({
44 messageFormatter,
45 messageDecoder,
46 messageEncoder,
47 channelTopic,
48 ...props
49}: ChatProps) {
50 const ulRef = React.useRef<HTMLUListElement>(null);
51 const inputRef = React.useRef<HTMLInputElement>(null);
52
53 const chatOptions: ChatOptions = React.useMemo(() => {
54 return { messageDecoder, messageEncoder, channelTopic };
55 }, [messageDecoder, messageEncoder, channelTopic]);
56
57 const { chatMessages, send, isSending } = useChat(chatOptions);
58
59 const layoutContext = useMaybeLayoutContext();
60 const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0);
61
62 async function handleSubmit(event: React.FormEvent) {
63 event.preventDefault();
64 if (inputRef.current && inputRef.current.value.trim() !== '') {
65 await send(inputRef.current.value);
66 inputRef.current.value = '';
67 inputRef.current.focus();
68 }
69 }
70
71 React.useEffect(() => {
72 if (ulRef) {
73 ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight });
74 }
75 }, [ulRef, chatMessages]);
76
77 React.useEffect(() => {
78 if (!layoutContext || chatMessages.length === 0) {
79 return;
80 }
81
82 if (
83 layoutContext.widget.state?.showChat &&
84 chatMessages.length > 0 &&
85 lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
86 ) {
87 lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp;
88 return;
89 }
90
91 const unreadMessageCount = chatMessages.filter(
92 (msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current,
93 ).length;
94
95 const { widget } = layoutContext;
96 if (unreadMessageCount > 0 && widget.state?.unreadMessages !== unreadMessageCount) {
97 widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount });
98 }
99 }, [chatMessages, layoutContext?.widget]);
100
101 return (
102 <div {...props} className="lk-chat">
103 <div className="lk-chat-header">
104 Messages
105 {layoutContext && (
106 <ChatToggle className="lk-close-button">
107 <ChatCloseIcon />
108 </ChatToggle>
109 )}
110 </div>
111
112 <ul className="lk-list lk-chat-messages" ref={ulRef}>
113 {props.children
114 ? chatMessages.map((msg, idx) =>
115 cloneSingleChild(props.children, {
116 entry: msg,
117 key: msg.id ?? idx,
118 messageFormatter,
119 }),
120 )
121 : chatMessages.map((msg, idx, allMsg) => {
122 const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
123 // If the time delta between two messages is bigger than 60s show timestamp.
124 const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
125
126 return (
127 <ChatEntry
128 key={msg.id ?? idx}
129 hideName={hideName}
130 hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well.
131 entry={msg}
132 messageFormatter={messageFormatter}
133 />
134 );
135 })}
136 </ul>
137 <form className="lk-chat-form" onSubmit={handleSubmit}>
138 <input
139 className="lk-form-control lk-chat-form-input"
140 disabled={isSending}
141 ref={inputRef}
142 type="text"
143 placeholder={lkT("livekit.chatPlaceholder")}
144 onInput={(ev) => ev.stopPropagation()}
145 onKeyDown={(ev) => ev.stopPropagation()}
146 onKeyUp={(ev) => ev.stopPropagation()}
147 />
148 <button type="submit" className="lk-button lk-chat-form-button" disabled={isSending}>
149 Send
150 </button>
151 </form>
152 </div>
153 );
154}
Added web/src/shared/livekit/react/prefabs/ControlBar.tsx web/src/shared/livekit/react/prefabs/ControlBar.tsx +228 −0 View web/src/shared/livekit/react/prefabs/ControlBar.tsx
@@ -0,0 +1,228 @@
1import { Track } from 'livekit-client';
2import * as React from 'react';
3import { MediaDeviceMenu } from './MediaDeviceMenu';
4import { DisconnectButton } from '../components/controls/DisconnectButton';
5import { TrackToggle } from '../components/controls/TrackToggle';
6import { ChatIcon, GearIcon, LeaveIcon } from '../assets/icons';
7import { ChatToggle } from '../components/controls/ChatToggle';
8import { useLocalParticipantPermissions, usePersistentUserChoices } from '../hooks';
9import { useMediaQuery } from '../hooks/internal';
10import { useMaybeLayoutContext } from '../context';
11import { supportsScreenSharing } from '@livekit/components-core';
12import { mergeProps } from '../utils';
13import { StartMediaButton } from '../components/controls/StartMediaButton';
14import { SettingsMenuToggle } from '../components/controls/SettingsMenuToggle';
15import { lkT } from '@/shared/livekit/lk-i18n';
16
17/** @public */
18export type ControlBarControls = {
19 microphone?: boolean;
20 camera?: boolean;
21 chat?: boolean;
22 screenShare?: boolean;
23 leave?: boolean;
24 settings?: boolean;
25};
26
27const trackSourceToProtocol = (source: Track.Source) => {
28 // NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase
29 switch (source) {
30 case Track.Source.Camera:
31 return 1;
32 case Track.Source.Microphone:
33 return 2;
34 case Track.Source.ScreenShare:
35 return 3;
36 default:
37 return 0;
38 }
39};
40
41/** @public */
42export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
43 onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
44 variation?: 'minimal' | 'verbose' | 'textOnly';
45 controls?: ControlBarControls;
46 /**
47 * If `true`, the user's device choices will be persisted.
48 * This will enable the user to have the same device choices when they rejoin the room.
49 * @defaultValue true
50 * @alpha
51 */
52 saveUserChoices?: boolean;
53}
54
55/**
56 * The `ControlBar` prefab gives the user the basic user interface to control their
57 * media devices (camera, microphone and screen share), open the `Chat` and leave the room.
58 *
59 * @remarks
60 * This component is built with other LiveKit components like `TrackToggle`,
61 * `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
62 *
63 * @example
64 * ```tsx
65 * <LiveKitRoom>
66 * <ControlBar />
67 * </LiveKitRoom>
68 * ```
69 * @public
70 */
71export function ControlBar({
72 variation,
73 controls,
74 saveUserChoices = true,
75 onDeviceError,
76 ...props
77}: ControlBarProps) {
78 const [isChatOpen, setIsChatOpen] = React.useState(false);
79 const layoutContext = useMaybeLayoutContext();
80 React.useEffect(() => {
81 if (layoutContext?.widget.state?.showChat !== undefined) {
82 setIsChatOpen(layoutContext?.widget.state?.showChat);
83 }
84 }, [layoutContext?.widget.state?.showChat]);
85 const isTooLittleSpace = useMediaQuery(`(max-width: ${isChatOpen ? 1000 : 760}px)`);
86
87 const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose';
88 variation ??= defaultVariation;
89
90 const visibleControls = { leave: true, ...controls };
91
92 const localPermissions = useLocalParticipantPermissions();
93
94 if (!localPermissions) {
95 visibleControls.camera = false;
96 visibleControls.chat = false;
97 visibleControls.microphone = false;
98 visibleControls.screenShare = false;
99 } else {
100 const canPublishSource = (source: Track.Source) => {
101 return (
102 localPermissions.canPublish &&
103 (localPermissions.canPublishSources.length === 0 ||
104 localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))
105 );
106 };
107 visibleControls.camera ??= canPublishSource(Track.Source.Camera);
108 visibleControls.microphone ??= canPublishSource(Track.Source.Microphone);
109 visibleControls.screenShare ??= canPublishSource(Track.Source.ScreenShare);
110 visibleControls.chat ??= localPermissions.canPublishData && controls?.chat;
111 }
112
113 const showIcon = React.useMemo(
114 () => variation === 'minimal' || variation === 'verbose',
115 [variation],
116 );
117 const showText = React.useMemo(
118 () => variation === 'textOnly' || variation === 'verbose',
119 [variation],
120 );
121
122 const browserSupportsScreenSharing = supportsScreenSharing();
123
124 const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false);
125
126 const onScreenShareChange = React.useCallback(
127 (enabled: boolean) => {
128 setIsScreenShareEnabled(enabled);
129 },
130 [setIsScreenShareEnabled],
131 );
132
133 const htmlProps = mergeProps({ className: 'lk-control-bar' }, props);
134
135 const {
136 saveAudioInputEnabled,
137 saveVideoInputEnabled,
138 saveAudioInputDeviceId,
139 saveVideoInputDeviceId,
140 } = usePersistentUserChoices({ preventSave: !saveUserChoices });
141
142 const microphoneOnChange = React.useCallback(
143 (enabled: boolean, isUserInitiated: boolean) =>
144 isUserInitiated ? saveAudioInputEnabled(enabled) : null,
145 [saveAudioInputEnabled],
146 );
147
148 const cameraOnChange = React.useCallback(
149 (enabled: boolean, isUserInitiated: boolean) =>
150 isUserInitiated ? saveVideoInputEnabled(enabled) : null,
151 [saveVideoInputEnabled],
152 );
153
154 return (
155 <div {...htmlProps}>
156 {visibleControls.microphone && (
157 <div className="lk-button-group">
158 <TrackToggle
159 source={Track.Source.Microphone}
160 showIcon={showIcon}
161 onChange={microphoneOnChange}
162 onDeviceError={(error) => onDeviceError?.({ source: Track.Source.Microphone, error })}
163 >
164 {showText && lkT("livekit.microphone")}
165 </TrackToggle>
166 <div className="lk-button-group-menu">
167 <MediaDeviceMenu
168 kind="audioinput"
169 onActiveDeviceChange={(_kind, deviceId) =>
170 saveAudioInputDeviceId(deviceId ?? 'default')
171 }
172 />
173 </div>
174 </div>
175 )}
176 {visibleControls.camera && (
177 <div className="lk-button-group">
178 <TrackToggle
179 source={Track.Source.Camera}
180 showIcon={showIcon}
181 onChange={cameraOnChange}
182 onDeviceError={(error) => onDeviceError?.({ source: Track.Source.Camera, error })}
183 >
184 {showText && lkT("livekit.camera")}
185 </TrackToggle>
186 <div className="lk-button-group-menu">
187 <MediaDeviceMenu
188 kind="videoinput"
189 onActiveDeviceChange={(_kind, deviceId) =>
190 saveVideoInputDeviceId(deviceId ?? 'default')
191 }
192 />
193 </div>
194 </div>
195 )}
196 {visibleControls.screenShare && browserSupportsScreenSharing && (
197 <TrackToggle
198 source={Track.Source.ScreenShare}
199 captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
200 showIcon={showIcon}
201 onChange={onScreenShareChange}
202 onDeviceError={(error) => onDeviceError?.({ source: Track.Source.ScreenShare, error })}
203 >
204 {showText && lkT(isScreenShareEnabled ? "livekit.stopScreenShare" : "livekit.shareScreen")}
205 </TrackToggle>
206 )}
207 {visibleControls.chat && (
208 <ChatToggle>
209 {showIcon && <ChatIcon />}
210 {showText && lkT("livekit.chat")}
211 </ChatToggle>
212 )}
213 {visibleControls.settings && (
214 <SettingsMenuToggle>
215 {showIcon && <GearIcon />}
216 {showText && lkT("livekit.settings")}
217 </SettingsMenuToggle>
218 )}
219 {visibleControls.leave && (
220 <DisconnectButton>
221 {showIcon && <LeaveIcon />}
222 {showText && lkT("livekit.leave")}
223 </DisconnectButton>
224 )}
225 <StartMediaButton />
226 </div>
227 );
228}
Added web/src/shared/livekit/react/prefabs/index.ts web/src/shared/livekit/react/prefabs/index.ts +11 −0 View web/src/shared/livekit/react/prefabs/index.ts
@@ -0,0 +1,11 @@
1export { Chat, type ChatProps } from './Chat';
2export { PreJoin, type PreJoinProps, usePreviewDevice, usePreviewTracks } from './PreJoin';
3export { VideoConference, type VideoConferenceProps } from './VideoConference';
4export { ControlBar, type ControlBarProps, type ControlBarControls } from './ControlBar';
5export { MediaDeviceMenu, type MediaDeviceMenuProps } from './MediaDeviceMenu';
6export { AudioConference, type AudioConferenceProps } from './AudioConference';
7export {
8 VoiceAssistantControlBar,
9 type VoiceAssistantControlBarProps,
10 type VoiceAssistantControlBarControls,
11} from './VoiceAssistantControlBar';
Added web/src/shared/livekit/react/prefabs/MediaDeviceMenu.tsx web/src/shared/livekit/react/prefabs/MediaDeviceMenu.tsx +160 −0 View web/src/shared/livekit/react/prefabs/MediaDeviceMenu.tsx
@@ -0,0 +1,160 @@
1import { computeMenuPosition, wasClickOutside, log } from '@livekit/components-core';
2import * as React from 'react';
3import { MediaDeviceSelect } from '../components/controls/MediaDeviceSelect';
4import type { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
5import { lkT } from '@/shared/livekit/lk-i18n';
6
7/** @public */
8export interface MediaDeviceMenuProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9 kind?: MediaDeviceKind;
10 initialSelection?: string;
11 onActiveDeviceChange?: (kind: MediaDeviceKind, deviceId: string) => void;
12 tracks?: Partial<Record<MediaDeviceKind, LocalAudioTrack | LocalVideoTrack | undefined>>;
13 /**
14 * this will call getUserMedia if the permissions are not yet given to enumerate the devices with device labels.
15 * in some browsers multiple calls to getUserMedia result in multiple permission prompts.
16 * It's generally advised only flip this to true, once a (preview) track has been acquired successfully with the
17 * appropriate permissions.
18 *
19 * @see {@link PreJoin}
20 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices | MDN enumerateDevices}
21 */
22 requestPermissions?: boolean;
23}
24
25/**
26 * The `MediaDeviceMenu` component is a button that opens a menu that lists
27 * all media devices and allows the user to select them.
28 *
29 * @remarks
30 * This component is implemented with the `MediaDeviceSelect` LiveKit components.
31 *
32 * @example
33 * ```tsx
34 * <LiveKitRoom>
35 * <MediaDeviceMenu />
36 * </LiveKitRoom>
37 * ```
38 * @public
39 */
40export function MediaDeviceMenu({
41 kind,
42 initialSelection,
43 onActiveDeviceChange,
44 tracks,
45 requestPermissions = false,
46 ...props
47}: MediaDeviceMenuProps) {
48 const [isOpen, setIsOpen] = React.useState(false);
49 const [devices, setDevices] = React.useState<MediaDeviceInfo[]>([]);
50 const [updateRequired, setUpdateRequired] = React.useState<boolean>(true);
51 const [needPermissions, setNeedPermissions] = React.useState(requestPermissions);
52
53 const handleActiveDeviceChange = (kind: MediaDeviceKind, deviceId: string) => {
54 log.debug('handle device change');
55 setIsOpen(false);
56 onActiveDeviceChange?.(kind, deviceId);
57 };
58
59 const button = React.useRef<HTMLButtonElement>(null);
60 const tooltip = React.useRef<HTMLDivElement>(null);
61
62 React.useLayoutEffect(() => {
63 if (isOpen) {
64 setNeedPermissions(true);
65 }
66 }, [isOpen]);
67
68 React.useLayoutEffect(() => {
69 let cleanup: ReturnType<typeof computeMenuPosition> | undefined;
70 if (button.current && tooltip.current && (devices || updateRequired)) {
71 cleanup = computeMenuPosition(button.current, tooltip.current, (x, y) => {
72 if (tooltip.current) {
73 Object.assign(tooltip.current.style, { left: `${x}px`, top: `${y}px` });
74 }
75 });
76 }
77 setUpdateRequired(false);
78 return () => {
79 cleanup?.();
80 };
81 }, [button, tooltip, devices, updateRequired]);
82
83 const handleClickOutside = React.useCallback(
84 (event: MouseEvent) => {
85 if (!tooltip.current) {
86 return;
87 }
88 if (event.target === button.current) {
89 return;
90 }
91 if (isOpen && wasClickOutside(tooltip.current, event)) {
92 setIsOpen(false);
93 }
94 },
95 [isOpen, tooltip, button],
96 );
97
98 React.useEffect(() => {
99 document.addEventListener<'click'>('click', handleClickOutside);
100 return () => {
101 document.removeEventListener<'click'>('click', handleClickOutside);
102 };
103 }, [handleClickOutside]);
104
105 return (
106 <>
107 <button
108 className="lk-button lk-button-menu"
109 aria-pressed={isOpen}
110 {...props}
111 onClick={() => setIsOpen(!isOpen)}
112 ref={button}
113 >
114 {props.children}
115 </button>
116 {/** only render when enabled in order to make sure that the permissions are requested only if the menu is enabled */}
117 {!props.disabled && (
118 <div
119 className="lk-device-menu"
120 ref={tooltip}
121 style={{ visibility: isOpen ? 'visible' : 'hidden' }}
122 >
123 {kind ? (
124 <MediaDeviceSelect
125 initialSelection={initialSelection}
126 onActiveDeviceChange={(deviceId) => handleActiveDeviceChange(kind, deviceId)}
127 onDeviceListChange={setDevices}
128 kind={kind}
129 track={tracks?.[kind]}
130 requestPermissions={needPermissions}
131 />
132 ) : (
133 <>
134 <div className="lk-device-menu-heading">{lkT("livekit.audioInputs")}</div>
135 <MediaDeviceSelect
136 kind="audioinput"
137 onActiveDeviceChange={(deviceId) =>
138 handleActiveDeviceChange('audioinput', deviceId)
139 }
140 onDeviceListChange={setDevices}
141 track={tracks?.audioinput}
142 requestPermissions={needPermissions}
143 />
144 <div className="lk-device-menu-heading">{lkT("livekit.videoInputs")}</div>
145 <MediaDeviceSelect
146 kind="videoinput"
147 onActiveDeviceChange={(deviceId) =>
148 handleActiveDeviceChange('videoinput', deviceId)
149 }
150 onDeviceListChange={setDevices}
151 track={tracks?.videoinput}
152 requestPermissions={needPermissions}
153 />
154 </>
155 )}
156 </div>
157 )}
158 </>
159 );
160}
Added web/src/shared/livekit/react/prefabs/PreJoin.tsx web/src/shared/livekit/react/prefabs/PreJoin.tsx +441 −0 View web/src/shared/livekit/react/prefabs/PreJoin.tsx
@@ -0,0 +1,441 @@
1import type {
2 CreateLocalTracksOptions,
3 LocalAudioTrack,
4 LocalVideoTrack,
5 TrackProcessor,
6} from 'livekit-client';
7import {
8 createLocalAudioTrack,
9 createLocalTracks,
10 createLocalVideoTrack,
11 facingModeFromLocalTrack,
12 Track,
13 VideoPresets,
14 Mutex,
15} from 'livekit-client';
16import * as React from 'react';
17import { MediaDeviceMenu } from './MediaDeviceMenu';
18import { TrackToggle } from '../components/controls/TrackToggle';
19import type { LocalUserChoices } from '@livekit/components-core';
20import { log } from '@livekit/components-core';
21import { ParticipantPlaceholder } from '../assets/images';
22import { useMediaDevices, usePersistentUserChoices } from '../hooks';
23import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles';
24import { roomOptionsStringifyReplacer } from '../utils';
25
26/**
27 * Props for the PreJoin component.
28 * @public
29 */
30export interface PreJoinProps extends Omit<
31 React.HTMLAttributes<HTMLDivElement>,
32 'onSubmit' | 'onError'
33> {
34 /** This function is called with the `LocalUserChoices` if validation is passed. */
35 onSubmit?: (values: LocalUserChoices) => void;
36 /**
37 * Provide your custom validation function. Only if validation is successful the user choices are passed to the onSubmit callback.
38 */
39 onValidate?: (values: LocalUserChoices) => boolean;
40 onError?: (error: Error) => void;
41 /** Prefill the input form with initial values. */
42 defaults?: Partial<LocalUserChoices>;
43 /** Display a debug window for your convenience. */
44 debug?: boolean;
45 joinLabel?: string;
46 micLabel?: string;
47 camLabel?: string;
48 userLabel?: string;
49 /**
50 * If true, user choices are persisted across sessions.
51 * @defaultValue true
52 * @alpha
53 */
54 persistUserChoices?: boolean;
55 videoProcessor?: TrackProcessor<Track.Kind.Video>;
56}
57
58/** @public */
59export function usePreviewTracks(
60 options: CreateLocalTracksOptions,
61 onError?: (err: Error) => void,
62) {
63 const [tracks, setTracks] = React.useState<Array<LocalAudioTrack | LocalVideoTrack>>();
64
65 const trackLock = React.useMemo(() => new Mutex(), []);
66
67 React.useEffect(() => {
68 let needsCleanup = false;
69 let localTracks: Array<LocalAudioTrack | LocalVideoTrack> = [];
70 trackLock.lock().then(async (unlock) => {
71 try {
72 if (options.audio || options.video) {
73 localTracks = (await createLocalTracks(options)) as Array<
74 LocalAudioTrack | LocalVideoTrack
75 >;
76
77 if (needsCleanup) {
78 localTracks.forEach((tr) => tr.stop());
79 } else {
80 setTracks(localTracks);
81 }
82 }
83 } catch (e: unknown) {
84 if (onError && e instanceof Error) {
85 onError(e);
86 } else {
87 log.error(e);
88 }
89 } finally {
90 unlock();
91 }
92 });
93
94 return () => {
95 needsCleanup = true;
96 localTracks.forEach((track) => {
97 track.stop();
98 });
99 };
100 }, [JSON.stringify(options, roomOptionsStringifyReplacer), onError, trackLock]);
101
102 return tracks;
103}
104
105/**
106 * @public
107 * @deprecated use `usePreviewTracks` instead
108 */
109export function usePreviewDevice<T extends LocalVideoTrack | LocalAudioTrack>(
110 enabled: boolean,
111 deviceId: string,
112 kind: 'videoinput' | 'audioinput',
113) {
114 const [deviceError, setDeviceError] = React.useState<Error | null>(null);
115 const [isCreatingTrack, setIsCreatingTrack] = React.useState<boolean>(false);
116
117 const devices = useMediaDevices({ kind });
118 const [selectedDevice, setSelectedDevice] = React.useState<MediaDeviceInfo | undefined>(
119 undefined,
120 );
121
122 const [localTrack, setLocalTrack] = React.useState<T>();
123 const [localDeviceId, setLocalDeviceId] = React.useState<string>(deviceId);
124
125 React.useEffect(() => {
126 setLocalDeviceId(deviceId);
127 }, [deviceId]);
128
129 const createTrack = async (deviceId: string, kind: 'videoinput' | 'audioinput') => {
130 try {
131 const track =
132 kind === 'videoinput'
133 ? await createLocalVideoTrack({
134 deviceId,
135 resolution: VideoPresets.h720.resolution,
136 })
137 : await createLocalAudioTrack({ deviceId });
138
139 const newDeviceId = await track.getDeviceId(false);
140 if (newDeviceId && deviceId !== newDeviceId) {
141 prevDeviceId.current = newDeviceId;
142 setLocalDeviceId(newDeviceId);
143 }
144 setLocalTrack(track as T);
145 } catch (e) {
146 if (e instanceof Error) {
147 setDeviceError(e);
148 }
149 }
150 };
151
152 const switchDevice = async (track: LocalVideoTrack | LocalAudioTrack, id: string) => {
153 await track.setDeviceId(id);
154 prevDeviceId.current = id;
155 };
156
157 const prevDeviceId = React.useRef(localDeviceId);
158
159 React.useEffect(() => {
160 if (enabled && !localTrack && !deviceError && !isCreatingTrack) {
161 log.debug('creating track', kind);
162 setIsCreatingTrack(true);
163 createTrack(localDeviceId, kind).finally(() => {
164 setIsCreatingTrack(false);
165 });
166 }
167 }, [enabled, localTrack, deviceError, isCreatingTrack]);
168
169 // switch camera device
170 React.useEffect(() => {
171 if (!localTrack) {
172 return;
173 }
174 if (!enabled) {
175 log.debug(`muting ${kind} track`);
176 localTrack.mute().then(() => log.debug(localTrack.mediaStreamTrack));
177 } else if (selectedDevice?.deviceId && prevDeviceId.current !== selectedDevice?.deviceId) {
178 log.debug(`switching ${kind} device from`, prevDeviceId.current, selectedDevice.deviceId);
179 switchDevice(localTrack, selectedDevice.deviceId);
180 } else {
181 log.debug(`unmuting local ${kind} track`);
182 localTrack.unmute();
183 }
184 }, [localTrack, selectedDevice, enabled, kind]);
185
186 React.useEffect(() => {
187 return () => {
188 if (localTrack) {
189 log.debug(`stopping local ${kind} track`);
190 localTrack.stop();
191 localTrack.mute();
192 }
193 };
194 }, []);
195
196 React.useEffect(() => {
197 setSelectedDevice(devices?.find((dev) => dev.deviceId === localDeviceId));
198 }, [localDeviceId, devices]);
199
200 return {
201 selectedDevice,
202 localTrack,
203 deviceError,
204 };
205}
206
207/**
208 * The `PreJoin` prefab component is normally presented to the user before he enters a room.
209 * This component allows the user to check and select the preferred media device (camera and microphone).
210 * On submit the user decisions are returned, which can then be passed on to the `LiveKitRoom` so that the user enters the room with the correct media devices.
211 *
212 * @remarks
213 * This component is independent of the `LiveKitRoom` component and should not be nested within it.
214 * Because it only accesses the local media tracks this component is self-contained and works without connection to the LiveKit server.
215 *
216 * @example
217 * ```tsx
218 * <PreJoin />
219 * ```
220 * @public
221 */
222export function PreJoin({
223 defaults = {},
224 onValidate,
225 onSubmit,
226 onError,
227 debug,
228 joinLabel = 'Join Room',
229 micLabel = 'Microphone',
230 camLabel = 'Camera',
231 userLabel = 'Username',
232 persistUserChoices = true,
233 videoProcessor,
234 ...htmlProps
235}: PreJoinProps) {
236 const {
237 userChoices: initialUserChoices,
238 saveAudioInputDeviceId,
239 saveAudioInputEnabled,
240 saveVideoInputDeviceId,
241 saveVideoInputEnabled,
242 saveUsername,
243 } = usePersistentUserChoices({
244 defaults,
245 preventSave: !persistUserChoices,
246 preventLoad: !persistUserChoices,
247 });
248
249 const [userChoices, setUserChoices] = React.useState(initialUserChoices);
250
251 // Initialize device settings
252 const [audioEnabled, setAudioEnabled] = React.useState<boolean>(userChoices.audioEnabled);
253 const [videoEnabled, setVideoEnabled] = React.useState<boolean>(userChoices.videoEnabled);
254 const [audioDeviceId, setAudioDeviceId] = React.useState<string>(userChoices.audioDeviceId);
255 const [videoDeviceId, setVideoDeviceId] = React.useState<string>(userChoices.videoDeviceId);
256 const [username, setUsername] = React.useState(userChoices.username);
257
258 // Save user choices to persistent storage.
259 React.useEffect(() => {
260 saveAudioInputEnabled(audioEnabled);
261 }, [audioEnabled, saveAudioInputEnabled]);
262 React.useEffect(() => {
263 saveVideoInputEnabled(videoEnabled);
264 }, [videoEnabled, saveVideoInputEnabled]);
265 React.useEffect(() => {
266 saveAudioInputDeviceId(audioDeviceId);
267 }, [audioDeviceId, saveAudioInputDeviceId]);
268 React.useEffect(() => {
269 saveVideoInputDeviceId(videoDeviceId);
270 }, [videoDeviceId, saveVideoInputDeviceId]);
271 React.useEffect(() => {
272 saveUsername(username);
273 }, [username, saveUsername]);
274
275 const tracks = usePreviewTracks(
276 {
277 audio: audioEnabled ? { deviceId: initialUserChoices.audioDeviceId } : false,
278 video: videoEnabled
279 ? { deviceId: initialUserChoices.videoDeviceId, processor: videoProcessor }
280 : false,
281 },
282 onError,
283 );
284
285 const videoEl = React.useRef(null);
286
287 const videoTrack = React.useMemo(
288 () => tracks?.filter((track) => track.kind === Track.Kind.Video)[0] as LocalVideoTrack,
289 [tracks],
290 );
291
292 const facingMode = React.useMemo(() => {
293 if (videoTrack) {
294 const { facingMode } = facingModeFromLocalTrack(videoTrack);
295 return facingMode;
296 } else {
297 return 'undefined';
298 }
299 }, [videoTrack]);
300
301 const audioTrack = React.useMemo(
302 () => tracks?.filter((track) => track.kind === Track.Kind.Audio)[0] as LocalAudioTrack,
303 [tracks],
304 );
305
306 React.useEffect(() => {
307 if (videoEl.current && videoTrack) {
308 videoTrack.unmute();
309 videoTrack.attach(videoEl.current);
310 }
311
312 return () => {
313 videoTrack?.detach();
314 };
315 }, [videoTrack]);
316
317 const [isValid, setIsValid] = React.useState<boolean>();
318
319 const handleValidation = React.useCallback(
320 (values: LocalUserChoices) => {
321 if (typeof onValidate === 'function') {
322 return onValidate(values);
323 } else {
324 return values.username !== '';
325 }
326 },
327 [onValidate],
328 );
329
330 React.useEffect(() => {
331 const newUserChoices = {
332 username,
333 videoEnabled,
334 videoDeviceId,
335 audioEnabled,
336 audioDeviceId,
337 };
338 setUserChoices(newUserChoices);
339 setIsValid(handleValidation(newUserChoices));
340 }, [username, videoEnabled, handleValidation, audioEnabled, audioDeviceId, videoDeviceId]);
341
342 function handleSubmit(event: React.FormEvent) {
343 event.preventDefault();
344 if (handleValidation(userChoices)) {
345 if (typeof onSubmit === 'function') {
346 onSubmit(userChoices);
347 }
348 } else {
349 log.warn('Validation failed with: ', userChoices);
350 }
351 }
352
353 useWarnAboutMissingStyles();
354
355 return (
356 <div className="lk-prejoin" {...htmlProps}>
357 <div className="lk-video-container">
358 {videoTrack && (
359 <video ref={videoEl} width="1280" height="720" data-lk-facing-mode={facingMode} />
360 )}
361 {(!videoTrack || !videoEnabled) && (
362 <div className="lk-camera-off-note">
363 <ParticipantPlaceholder />
364 </div>
365 )}
366 </div>
367 <div className="lk-button-group-container">
368 <div className="lk-button-group audio">
369 <TrackToggle
370 initialState={audioEnabled}
371 source={Track.Source.Microphone}
372 onChange={(enabled) => setAudioEnabled(enabled)}
373 >
374 {micLabel}
375 </TrackToggle>
376 <div className="lk-button-group-menu">
377 <MediaDeviceMenu
378 initialSelection={audioDeviceId}
379 kind="audioinput"
380 disabled={!audioTrack}
381 tracks={{ audioinput: audioTrack }}
382 onActiveDeviceChange={(_, id) => setAudioDeviceId(id)}
383 />
384 </div>
385 </div>
386 <div className="lk-button-group video">
387 <TrackToggle
388 initialState={videoEnabled}
389 source={Track.Source.Camera}
390 onChange={(enabled) => setVideoEnabled(enabled)}
391 >
392 {camLabel}
393 </TrackToggle>
394 <div className="lk-button-group-menu">
395 <MediaDeviceMenu
396 initialSelection={videoDeviceId}
397 kind="videoinput"
398 disabled={!videoTrack}
399 tracks={{ videoinput: videoTrack }}
400 onActiveDeviceChange={(_, id) => setVideoDeviceId(id)}
401 />
402 </div>
403 </div>
404 </div>
405
406 <form className="lk-username-container">
407 <input
408 className="lk-form-control"
409 id="username"
410 name="username"
411 type="text"
412 defaultValue={username}
413 placeholder={userLabel}
414 onChange={(inputEl) => setUsername(inputEl.target.value)}
415 autoComplete="off"
416 />
417 <button
418 className="lk-button lk-join-button"
419 type="submit"
420 onClick={handleSubmit}
421 disabled={!isValid}
422 >
423 {joinLabel}
424 </button>
425 </form>
426
427 {debug && (
428 <>
429 <strong>User Choices:</strong>
430 <ul className="lk-list" style={{ overflow: 'hidden', maxWidth: '15rem' }}>
431 <li>Username: {`${userChoices.username}`}</li>
432 <li>Video Enabled: {`${userChoices.videoEnabled}`}</li>
433 <li>Audio Enabled: {`${userChoices.audioEnabled}`}</li>
434 <li>Video Device: {`${userChoices.videoDeviceId}`}</li>
435 <li>Audio Device: {`${userChoices.audioDeviceId}`}</li>
436 </ul>
437 </>
438 )}
439 </div>
440 );
441}
Added web/src/shared/livekit/react/prefabs/VideoConference.tsx web/src/shared/livekit/react/prefabs/VideoConference.tsx +180 −0 View web/src/shared/livekit/react/prefabs/VideoConference.tsx
@@ -0,0 +1,180 @@
1import type {
2 MessageDecoder,
3 MessageEncoder,
4 TrackReferenceOrPlaceholder,
5 WidgetState,
6} from '@livekit/components-core';
7import { isEqualTrackRef, isTrackReference, isWeb, log } from '@livekit/components-core';
8import { RoomEvent, Track } from 'livekit-client';
9import * as React from 'react';
10import type { MessageFormatter } from '../components';
11import {
12 CarouselLayout,
13 ConnectionStateToast,
14 FocusLayout,
15 FocusLayoutContainer,
16 GridLayout,
17 LayoutContextProvider,
18 ParticipantTile,
19 RoomAudioRenderer,
20} from '../components';
21import { useCreateLayoutContext } from '../context';
22import { usePinnedTracks, useTracks } from '../hooks';
23import { Chat } from './Chat';
24import { ControlBar } from './ControlBar';
25import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles';
26
27/**
28 * @public
29 */
30export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
31 chatMessageFormatter?: MessageFormatter;
32 chatMessageEncoder?: MessageEncoder;
33 chatMessageDecoder?: MessageDecoder;
34 /** @alpha */
35 SettingsComponent?: React.ComponentType;
36}
37
38/**
39 * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
40 * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
41 * of participants, basic non-persistent chat, screen sharing, and more.
42 *
43 * @remarks
44 * The component is implemented with other LiveKit components like `FocusContextProvider`,
45 * `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
46 * You can use these components as a starting point for your own custom video conferencing application.
47 *
48 * @example
49 * ```tsx
50 * <LiveKitRoom>
51 * <VideoConference />
52 * <LiveKitRoom>
53 * ```
54 * @public
55 */
56export function VideoConference({
57 chatMessageFormatter,
58 chatMessageDecoder,
59 chatMessageEncoder,
60 SettingsComponent,
61 ...props
62}: VideoConferenceProps) {
63 const [widgetState, setWidgetState] = React.useState<WidgetState>({
64 showChat: false,
65 unreadMessages: 0,
66 showSettings: false,
67 });
68 const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null);
69
70 const tracks = useTracks(
71 [
72 { source: Track.Source.Camera, withPlaceholder: true },
73 { source: Track.Source.ScreenShare, withPlaceholder: false },
74 ],
75 { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
76 );
77
78 const widgetUpdate = (state: WidgetState) => {
79 log.debug('updating widget state', state);
80 setWidgetState(state);
81 };
82
83 const layoutContext = useCreateLayoutContext();
84
85 const screenShareTracks = tracks
86 .filter(isTrackReference)
87 .filter((track) => track.publication.source === Track.Source.ScreenShare);
88
89 const focusTrack = usePinnedTracks(layoutContext)?.[0];
90 const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
91
92 React.useEffect(() => {
93 // If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
94 if (
95 screenShareTracks.some((track) => track.publication.isSubscribed) &&
96 lastAutoFocusedScreenShareTrack.current === null
97 ) {
98 log.debug('Auto set screen share focus:', { newScreenShareTrack: screenShareTracks[0] });
99 layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
100 lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
101 } else if (
102 lastAutoFocusedScreenShareTrack.current &&
103 !screenShareTracks.some(
104 (track) =>
105 track.publication.trackSid ===
106 lastAutoFocusedScreenShareTrack.current?.publication?.trackSid,
107 )
108 ) {
109 log.debug('Auto clearing screen share focus.');
110 layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
111 lastAutoFocusedScreenShareTrack.current = null;
112 }
113 if (focusTrack && !isTrackReference(focusTrack)) {
114 const updatedFocusTrack = tracks.find(
115 (tr) =>
116 tr.participant.identity === focusTrack.participant.identity &&
117 tr.source === focusTrack.source,
118 );
119 if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
120 layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
121 }
122 }
123 }, [
124 screenShareTracks
125 .map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`)
126 .join(),
127 focusTrack?.publication?.trackSid,
128 tracks,
129 ]);
130
131 useWarnAboutMissingStyles();
132
133 return (
134 <div className="lk-video-conference" {...props}>
135 {isWeb() && (
136 <LayoutContextProvider
137 value={layoutContext}
138 // onPinChange={handleFocusStateChange}
139 onWidgetChange={widgetUpdate}
140 >
141 <div className="lk-video-conference-inner">
142 {!focusTrack ? (
143 <div className="lk-grid-layout-wrapper">
144 <GridLayout tracks={tracks}>
145 <ParticipantTile />
146 </GridLayout>
147 </div>
148 ) : (
149 <div className="lk-focus-layout-wrapper">
150 <FocusLayoutContainer>
151 <CarouselLayout tracks={carouselTracks}>
152 <ParticipantTile />
153 </CarouselLayout>
154 {focusTrack && <FocusLayout trackRef={focusTrack} />}
155 </FocusLayoutContainer>
156 </div>
157 )}
158 <ControlBar controls={{ chat: true, settings: !!SettingsComponent }} />
159 </div>
160 <Chat
161 style={{ display: widgetState.showChat ? 'grid' : 'none' }}
162 messageFormatter={chatMessageFormatter}
163 messageEncoder={chatMessageEncoder}
164 messageDecoder={chatMessageDecoder}
165 />
166 {SettingsComponent && (
167 <div
168 className="lk-settings-menu-modal"
169 style={{ display: widgetState.showSettings ? 'block' : 'none' }}
170 >
171 <SettingsComponent />
172 </div>
173 )}
174 </LayoutContextProvider>
175 )}
176 <RoomAudioRenderer />
177 <ConnectionStateToast />
178 </div>
179 );
180}
Added web/src/shared/livekit/react/prefabs/VoiceAssistantControlBar.tsx web/src/shared/livekit/react/prefabs/VoiceAssistantControlBar.tsx +109 −0 View web/src/shared/livekit/react/prefabs/VoiceAssistantControlBar.tsx
@@ -0,0 +1,109 @@
1import { Track } from 'livekit-client';
2import * as React from 'react';
3import { MediaDeviceMenu } from './MediaDeviceMenu';
4import { TrackToggle } from '../components/controls/TrackToggle';
5import {
6 useLocalParticipant,
7 useLocalParticipantPermissions,
8 usePersistentUserChoices,
9} from '../hooks';
10import { mergeProps } from '../utils';
11import { StartMediaButton } from '../components/controls/StartMediaButton';
12import { BarVisualizer, DisconnectButton } from '../components';
13import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
14
15/** @beta */
16export type VoiceAssistantControlBarControls = {
17 microphone?: boolean;
18 leave?: boolean;
19};
20
21/** @beta */
22export interface VoiceAssistantControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
23 onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
24 controls?: VoiceAssistantControlBarControls;
25 /**
26 * If `true`, the user's device choices will be persisted.
27 * This will enables the user to have the same device choices when they rejoin the room.
28 * @defaultValue true
29 */
30 saveUserChoices?: boolean;
31}
32
33/**
34 * @example
35 * ```tsx
36 * <LiveKitRoom ... >
37 * <VoiceAssistantControlBar />
38 * </LiveKitRoom>
39 * ```
40 * @beta
41 */
42export function VoiceAssistantControlBar({
43 controls,
44 saveUserChoices = true,
45 onDeviceError,
46 ...props
47}: VoiceAssistantControlBarProps) {
48 const visibleControls = { leave: true, microphone: true, ...controls };
49
50 const localPermissions = useLocalParticipantPermissions();
51 const { microphoneTrack, localParticipant } = useLocalParticipant();
52
53 const micTrackRef: TrackReferenceOrPlaceholder = React.useMemo(() => {
54 return {
55 participant: localParticipant,
56 source: Track.Source.Microphone,
57 publication: microphoneTrack,
58 };
59 }, [localParticipant, microphoneTrack]);
60
61 if (!localPermissions) {
62 visibleControls.microphone = false;
63 } else {
64 visibleControls.microphone ??= localPermissions.canPublish;
65 }
66
67 const htmlProps = mergeProps({ className: 'lk-agent-control-bar' }, props);
68
69 const { saveAudioInputEnabled, saveAudioInputDeviceId } = usePersistentUserChoices({
70 preventSave: !saveUserChoices,
71 });
72
73 const microphoneOnChange = React.useCallback(
74 (enabled: boolean, isUserInitiated: boolean) => {
75 if (isUserInitiated) {
76 saveAudioInputEnabled(enabled);
77 }
78 },
79 [saveAudioInputEnabled],
80 );
81
82 return (
83 <div {...htmlProps}>
84 {visibleControls.microphone && (
85 <div className="lk-button-group">
86 <TrackToggle
87 source={Track.Source.Microphone}
88 showIcon={true}
89 onChange={microphoneOnChange}
90 onDeviceError={(error) => onDeviceError?.({ source: Track.Source.Microphone, error })}
91 >
92 <BarVisualizer trackRef={micTrackRef} barCount={7} options={{ minHeight: 5 }} />
93 </TrackToggle>
94 <div className="lk-button-group-menu">
95 <MediaDeviceMenu
96 kind="audioinput"
97 onActiveDeviceChange={(_kind, deviceId) =>
98 saveAudioInputDeviceId(deviceId ?? 'default')
99 }
100 />
101 </div>
102 </div>
103 )}
104
105 {visibleControls.leave && <DisconnectButton>{'Disconnect'}</DisconnectButton>}
106 <StartMediaButton />
107 </div>
108 );
109}
Added web/src/shared/livekit/react/types/events.d.ts web/src/shared/livekit/react/types/events.d.ts +10 −0 View web/src/shared/livekit/react/types/events.d.ts
@@ -0,0 +1,10 @@
1declare module 'events' {
2 export class EventEmitter {
3 on(event: string | symbol, listener: (...args: any[]) => void): this;
4 once(event: string | symbol, listener: (...args: any[]) => void): this;
5 off(event: string | symbol, listener: (...args: any[]) => void): this;
6 emit(event: string | symbol, ...args: any[]): boolean;
7 removeListener(event: string | symbol, listener: (...args: any[]) => void): this;
8 removeAllListeners(event?: string | symbol): this;
9 }
10}
Added web/src/shared/livekit/react/index.ts web/src/shared/livekit/react/index.ts +32 −0 View web/src/shared/livekit/react/index.ts
@@ -0,0 +1,32 @@
1export * from './components';
2
3export * from './hooks';
4
5export * from './prefabs';
6
7export * from './context';
8
9export * from './assets/icons';
10
11export * from './assets/images';
12
13// Re-exports from core
14export { setLogLevel, setLogExtension, isTrackReference } from '@livekit/components-core';
15export type {
16 ChatMessage,
17 ReceivedMessage,
18 ReceivedChatMessage,
19 ReceivedUserTranscriptionMessage,
20 ReceivedAgentTranscriptionMessage,
21 MessageDecoder,
22 MessageEncoder,
23 LocalUserChoices,
24 TrackReference,
25 TrackReferenceOrPlaceholder,
26 ParticipantClickEvent,
27 ParticipantIdentifier,
28 PinState,
29 WidgetState,
30 GridLayoutDefinition,
31 TextStreamData,
32} from '@livekit/components-core';
Added web/src/shared/livekit/react/mergeProps.ts web/src/shared/livekit/react/mergeProps.ts +87 −0 View web/src/shared/livekit/react/mergeProps.ts
@@ -0,0 +1,87 @@
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import clsx from 'clsx';
14
15/**
16 * Calls all functions in the order they were chained with the same arguments.
17 * @internal
18 */
19export function chain(...callbacks: any[]): (...args: any[]) => void {
20 return (...args: any[]) => {
21 for (const callback of callbacks) {
22 if (typeof callback === 'function') {
23 try {
24 callback(...args);
25 } catch (e) {
26 console.error(e);
27 }
28 }
29 }
30 };
31}
32
33interface Props {
34 [key: string]: any;
35}
36
37// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
38type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? V : never;
39type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
40 ? I
41 : never;
42
43/**
44 * Merges multiple props objects together. Event handlers are chained,
45 * classNames are combined, and ids are deduplicated - different ids
46 * will trigger a side-effect and re-render components hooked up with `useId`.
47 * For all other props, the last prop object overrides all previous ones.
48 * @param args - Multiple sets of props to merge together.
49 * @internal
50 */
51export function mergeProps<T extends Props[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
52 // Start with a base clone of the first argument. This is a lot faster than starting
53 // with an empty object and adding properties as we go.
54 const result: Props = { ...args[0] };
55 for (let i = 1; i < args.length; i++) {
56 const props = args[i];
57 for (const key in props) {
58 const a = result[key];
59 const b = props[key];
60
61 // Chain events
62 if (
63 typeof a === 'function' &&
64 typeof b === 'function' &&
65 // This is a lot faster than a regex.
66 key[0] === 'o' &&
67 key[1] === 'n' &&
68 key.charCodeAt(2) >= /* 'A' */ 65 &&
69 key.charCodeAt(2) <= /* 'Z' */ 90
70 ) {
71 result[key] = chain(a, b);
72
73 // Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
74 } else if (
75 (key === 'className' || key === 'UNSAFE_className') &&
76 typeof a === 'string' &&
77 typeof b === 'string'
78 ) {
79 result[key] = clsx(a, b);
80 } else {
81 result[key] = b !== undefined ? b : a;
82 }
83 }
84 }
85
86 return result as UnionToIntersection<TupleTypes<T>>;
87}
Added web/src/shared/livekit/react/utils.ts web/src/shared/livekit/react/utils.ts +75 −0 View web/src/shared/livekit/react/utils.ts
@@ -0,0 +1,75 @@
1import * as React from 'react';
2import { mergeProps as mergePropsReactAria } from './mergeProps';
3import { log } from '@livekit/components-core';
4import clsx from 'clsx';
5
6/** @internal */
7export function isProp<U extends HTMLElement, T extends React.HTMLAttributes<U>>(
8 prop: T | undefined,
9): prop is T {
10 return prop !== undefined;
11}
12
13/** @internal */
14export function mergeProps<
15 U extends HTMLElement,
16 T extends Array<React.HTMLAttributes<U> | undefined>,
17>(...props: T) {
18 return mergePropsReactAria(...props.filter(isProp));
19}
20
21/** @internal */
22export function cloneSingleChild(
23 children: React.ReactNode | React.ReactNode[],
24 props?: Record<string, any>,
25 key?: any,
26) {
27 return React.Children.map(children, (child) => {
28 // Checking isValidElement is the safe way and avoids a typescript
29 // error too.
30 if (React.isValidElement(child) && React.Children.only(children)) {
31 const childProps = child.props as { className?: string; style?: React.CSSProperties };
32 if (childProps.className) {
33 // make sure we retain classnames of both passed props and child
34 props ??= {};
35 props.className = clsx(childProps.className, props.className);
36 props.style = { ...childProps.style, ...props.style };
37 }
38 return React.cloneElement(child, { ...props, key });
39 }
40 return child;
41 });
42}
43
44/**
45 * @internal
46 */
47export function warnAboutMissingStyles(el?: HTMLElement) {
48 if (
49 typeof window !== 'undefined' &&
50 import.meta.env.DEV
51 ) {
52 const target = el ?? document.querySelector('.lk-room-container');
53 if (target && !getComputedStyle(target).getPropertyValue('--lk-has-imported-styles')) {
54 log.warn(
55 "It looks like you're not using the `@livekit/components-styles package`. To render the UI with the default styling, please import it in your layout or page.",
56 );
57 }
58 }
59}
60
61/**
62 *
63 * @internal
64 * used to stringify room options to detect dependency changes for react hooks.
65 * Replaces processors and e2ee/encryption options with strings.
66 */
67export function roomOptionsStringifyReplacer(key: string, val: unknown) {
68 if (key === 'processor' && val && typeof val === 'object' && 'name' in val) {
69 return val.name;
70 }
71 if ((key === 'e2ee' || key === 'encryption') && val) {
72 return 'e2ee-enabled';
73 }
74 return val;
75}
Added web/src/shared/livekit/index.ts web/src/shared/livekit/index.ts +14 −0 View web/src/shared/livekit/index.ts
@@ -0,0 +1,14 @@
1/**
2 * Public API of the vendored LiveKit component tree (`react/`, `core/`,
3 * `styles/` are byte-copied from the published npm packages).
4 *
5 * Consumers import from `@/shared/livekit` and never from deeper files, so
6 * replaying a newer official tarball only has to keep this surface intact.
7 */
8export * from './react'
9
10// A few helpers the official barrel keeps internal, but a hand-built room view
11// needs in order to key and compare track references.
12export { getTrackReferenceId, isEqualTrackRef } from '@livekit/components-core'
13
14export { lkT } from './lk-i18n'
Added web/src/shared/livekit/LICENSE-components-core web/src/shared/livekit/LICENSE-components-core +201 −0 View web/src/shared/livekit/LICENSE-components-core
@@ -0,0 +1,201 @@
1 Apache License
2 Version 2.0, January 2004
3 http://www.apache.org/licenses/
4
5 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
7 1. Definitions.
8
9 "License" shall mean the terms and conditions for use, reproduction,
10 and distribution as defined by Sections 1 through 9 of this document.
11
12 "Licensor" shall mean the copyright owner or entity authorized by
13 the copyright owner that is granting the License.
14
15 "Legal Entity" shall mean the union of the acting entity and all
16 other entities that control, are controlled by, or are under common
17 control with that entity. For the purposes of this definition,
18 "control" means (i) the power, direct or indirect, to cause the
19 direction or management of such entity, whether by contract or
20 otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 outstanding shares, or (iii) beneficial ownership of such entity.
22
23 "You" (or "Your") shall mean an individual or Legal Entity
24 exercising permissions granted by this License.
25
26 "Source" form shall mean the preferred form for making modifications,
27 including but not limited to software source code, documentation
28 source, and configuration files.
29
30 "Object" form shall mean any form resulting from mechanical
31 transformation or translation of a Source form, including but
32 not limited to compiled object code, generated documentation,
33 and conversions to other media types.
34
35 "Work" shall mean the work of authorship, whether in Source or
36 Object form, made available under the License, as indicated by a
37 copyright notice that is included in or attached to the work
38 (an example is provided in the Appendix below).
39
40 "Derivative Works" shall mean any work, whether in Source or Object
41 form, that is based on (or derived from) the Work and for which the
42 editorial revisions, annotations, elaborations, or other modifications
43 represent, as a whole, an original work of authorship. For the purposes
44 of this License, Derivative Works shall not include works that remain
45 separable from, or merely link (or bind by name) to the interfaces of,
46 the Work and Derivative Works thereof.
47
48 "Contribution" shall mean any work of authorship, including
49 the original version of the Work and any modifications or additions
50 to that Work or Derivative Works thereof, that is intentionally
51 submitted to Licensor for inclusion in the Work by the copyright owner
52 or by an individual or Legal Entity authorized to submit on behalf of
53 the copyright owner. For the purposes of this definition, "submitted"
54 means any form of electronic, verbal, or written communication sent
55 to the Licensor or its representatives, including but not limited to
56 communication on electronic mailing lists, source code control systems,
57 and issue tracking systems that are managed by, or on behalf of, the
58 Licensor for the purpose of discussing and improving the Work, but
59 excluding communication that is conspicuously marked or otherwise
60 designated in writing by the copyright owner as "Not a Contribution."
61
62 "Contributor" shall mean Licensor and any individual or Legal Entity
63 on behalf of whom a Contribution has been received by Licensor and
64 subsequently incorporated within the Work.
65
66 2. Grant of Copyright License. Subject to the terms and conditions of
67 this License, each Contributor hereby grants to You a perpetual,
68 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 copyright license to reproduce, prepare Derivative Works of,
70 publicly display, publicly perform, sublicense, and distribute the
71 Work and such Derivative Works in Source or Object form.
72
73 3. Grant of Patent License. Subject to the terms and conditions of
74 this License, each Contributor hereby grants to You a perpetual,
75 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 (except as stated in this section) patent license to make, have made,
77 use, offer to sell, sell, import, and otherwise transfer the Work,
78 where such license applies only to those patent claims licensable
79 by such Contributor that are necessarily infringed by their
80 Contribution(s) alone or by combination of their Contribution(s)
81 with the Work to which such Contribution(s) was submitted. If You
82 institute patent litigation against any entity (including a
83 cross-claim or counterclaim in a lawsuit) alleging that the Work
84 or a Contribution incorporated within the Work constitutes direct
85 or contributory patent infringement, then any patent licenses
86 granted to You under this License for that Work shall terminate
87 as of the date such litigation is filed.
88
89 4. Redistribution. You may reproduce and distribute copies of the
90 Work or Derivative Works thereof in any medium, with or without
91 modifications, and in Source or Object form, provided that You
92 meet the following conditions:
93
94 (a) You must give any other recipients of the Work or
95 Derivative Works a copy of this License; and
96
97 (b) You must cause any modified files to carry prominent notices
98 stating that You changed the files; and
99
100 (c) You must retain, in the Source form of any Derivative Works
101 that You distribute, all copyright, patent, trademark, and
102 attribution notices from the Source form of the Work,
103 excluding those notices that do not pertain to any part of
104 the Derivative Works; and
105
106 (d) If the Work includes a "NOTICE" text file as part of its
107 distribution, then any Derivative Works that You distribute must
108 include a readable copy of the attribution notices contained
109 within such NOTICE file, excluding those notices that do not
110 pertain to any part of the Derivative Works, in at least one
111 of the following places: within a NOTICE text file distributed
112 as part of the Derivative Works; within the Source form or
113 documentation, if provided along with the Derivative Works; or,
114 within a display generated by the Derivative Works, if and
115 wherever such third-party notices normally appear. The contents
116 of the NOTICE file are for informational purposes only and
117 do not modify the License. You may add Your own attribution
118 notices within Derivative Works that You distribute, alongside
119 or as an addendum to the NOTICE text from the Work, provided
120 that such additional attribution notices cannot be construed
121 as modifying the License.
122
123 You may add Your own copyright statement to Your modifications and
124 may provide additional or different license terms and conditions
125 for use, reproduction, or distribution of Your modifications, or
126 for any such Derivative Works as a whole, provided Your use,
127 reproduction, and distribution of the Work otherwise complies with
128 the conditions stated in this License.
129
130 5. Submission of Contributions. Unless You explicitly state otherwise,
131 any Contribution intentionally submitted for inclusion in the Work
132 by You to the Licensor shall be under the terms and conditions of
133 this License, without any additional terms or conditions.
134 Notwithstanding the above, nothing herein shall supersede or modify
135 the terms of any separate license agreement you may have executed
136 with Licensor regarding such Contributions.
137
138 6. Trademarks. This License does not grant permission to use the trade
139 names, trademarks, service marks, or product names of the Licensor,
140 except as required for reasonable and customary use in describing the
141 origin of the Work and reproducing the content of the NOTICE file.
142
143 7. Disclaimer of Warranty. Unless required by applicable law or
144 agreed to in writing, Licensor provides the Work (and each
145 Contributor provides its Contributions) on an "AS IS" BASIS,
146 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 implied, including, without limitation, any warranties or conditions
148 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 PARTICULAR PURPOSE. You are solely responsible for determining the
150 appropriateness of using or redistributing the Work and assume any
151 risks associated with Your exercise of permissions under this License.
152
153 8. Limitation of Liability. In no event and under no legal theory,
154 whether in tort (including negligence), contract, or otherwise,
155 unless required by applicable law (such as deliberate and grossly
156 negligent acts) or agreed to in writing, shall any Contributor be
157 liable to You for damages, including any direct, indirect, special,
158 incidental, or consequential damages of any character arising as a
159 result of this License or out of the use or inability to use the
160 Work (including but not limited to damages for loss of goodwill,
161 work stoppage, computer failure or malfunction, or any and all
162 other commercial damages or losses), even if such Contributor
163 has been advised of the possibility of such damages.
164
165 9. Accepting Warranty or Additional Liability. While redistributing
166 the Work or Derivative Works thereof, You may choose to offer,
167 and charge a fee for, acceptance of support, warranty, indemnity,
168 or other liability obligations and/or rights consistent with this
169 License. However, in accepting such obligations, You may act only
170 on Your own behalf and on Your sole responsibility, not on behalf
171 of any other Contributor, and only if You agree to indemnify,
172 defend, and hold each Contributor harmless for any liability
173 incurred by, or claims asserted against, such Contributor by reason
174 of your accepting any such warranty or additional liability.
175
176 END OF TERMS AND CONDITIONS
177
178 APPENDIX: How to apply the Apache License to your work.
179
180 To apply the Apache License to your work, attach the following
181 boilerplate notice, with the fields enclosed by brackets "[]"
182 replaced with your own identifying information. (Don't include
183 the brackets!) The text should be enclosed in the appropriate
184 comment syntax for the file format. We also recommend that a
185 file or class name and description of purpose be included on the
186 same "printed page" as the copyright notice for easier
187 identification within third-party archives.
188
189 Copyright [yyyy] [name of copyright owner]
190
191 Licensed under the Apache License, Version 2.0 (the "License");
192 you may not use this file except in compliance with the License.
193 You may obtain a copy of the License at
194
195 http://www.apache.org/licenses/LICENSE-2.0
196
197 Unless required by applicable law or agreed to in writing, software
198 distributed under the License is distributed on an "AS IS" BASIS,
199 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 See the License for the specific language governing permissions and
201 limitations under the License.
Added web/src/shared/livekit/LICENSE-components-react web/src/shared/livekit/LICENSE-components-react +201 −0 View web/src/shared/livekit/LICENSE-components-react
@@ -0,0 +1,201 @@
1 Apache License
2 Version 2.0, January 2004
3 http://www.apache.org/licenses/
4
5 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
7 1. Definitions.
8
9 "License" shall mean the terms and conditions for use, reproduction,
10 and distribution as defined by Sections 1 through 9 of this document.
11
12 "Licensor" shall mean the copyright owner or entity authorized by
13 the copyright owner that is granting the License.
14
15 "Legal Entity" shall mean the union of the acting entity and all
16 other entities that control, are controlled by, or are under common
17 control with that entity. For the purposes of this definition,
18 "control" means (i) the power, direct or indirect, to cause the
19 direction or management of such entity, whether by contract or
20 otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 outstanding shares, or (iii) beneficial ownership of such entity.
22
23 "You" (or "Your") shall mean an individual or Legal Entity
24 exercising permissions granted by this License.
25
26 "Source" form shall mean the preferred form for making modifications,
27 including but not limited to software source code, documentation
28 source, and configuration files.
29
30 "Object" form shall mean any form resulting from mechanical
31 transformation or translation of a Source form, including but
32 not limited to compiled object code, generated documentation,
33 and conversions to other media types.
34
35 "Work" shall mean the work of authorship, whether in Source or
36 Object form, made available under the License, as indicated by a
37 copyright notice that is included in or attached to the work
38 (an example is provided in the Appendix below).
39
40 "Derivative Works" shall mean any work, whether in Source or Object
41 form, that is based on (or derived from) the Work and for which the
42 editorial revisions, annotations, elaborations, or other modifications
43 represent, as a whole, an original work of authorship. For the purposes
44 of this License, Derivative Works shall not include works that remain
45 separable from, or merely link (or bind by name) to the interfaces of,
46 the Work and Derivative Works thereof.
47
48 "Contribution" shall mean any work of authorship, including
49 the original version of the Work and any modifications or additions
50 to that Work or Derivative Works thereof, that is intentionally
51 submitted to Licensor for inclusion in the Work by the copyright owner
52 or by an individual or Legal Entity authorized to submit on behalf of
53 the copyright owner. For the purposes of this definition, "submitted"
54 means any form of electronic, verbal, or written communication sent
55 to the Licensor or its representatives, including but not limited to
56 communication on electronic mailing lists, source code control systems,
57 and issue tracking systems that are managed by, or on behalf of, the
58 Licensor for the purpose of discussing and improving the Work, but
59 excluding communication that is conspicuously marked or otherwise
60 designated in writing by the copyright owner as "Not a Contribution."
61
62 "Contributor" shall mean Licensor and any individual or Legal Entity
63 on behalf of whom a Contribution has been received by Licensor and
64 subsequently incorporated within the Work.
65
66 2. Grant of Copyright License. Subject to the terms and conditions of
67 this License, each Contributor hereby grants to You a perpetual,
68 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 copyright license to reproduce, prepare Derivative Works of,
70 publicly display, publicly perform, sublicense, and distribute the
71 Work and such Derivative Works in Source or Object form.
72
73 3. Grant of Patent License. Subject to the terms and conditions of
74 this License, each Contributor hereby grants to You a perpetual,
75 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 (except as stated in this section) patent license to make, have made,
77 use, offer to sell, sell, import, and otherwise transfer the Work,
78 where such license applies only to those patent claims licensable
79 by such Contributor that are necessarily infringed by their
80 Contribution(s) alone or by combination of their Contribution(s)
81 with the Work to which such Contribution(s) was submitted. If You
82 institute patent litigation against any entity (including a
83 cross-claim or counterclaim in a lawsuit) alleging that the Work
84 or a Contribution incorporated within the Work constitutes direct
85 or contributory patent infringement, then any patent licenses
86 granted to You under this License for that Work shall terminate
87 as of the date such litigation is filed.
88
89 4. Redistribution. You may reproduce and distribute copies of the
90 Work or Derivative Works thereof in any medium, with or without
91 modifications, and in Source or Object form, provided that You
92 meet the following conditions:
93
94 (a) You must give any other recipients of the Work or
95 Derivative Works a copy of this License; and
96
97 (b) You must cause any modified files to carry prominent notices
98 stating that You changed the files; and
99
100 (c) You must retain, in the Source form of any Derivative Works
101 that You distribute, all copyright, patent, trademark, and
102 attribution notices from the Source form of the Work,
103 excluding those notices that do not pertain to any part of
104 the Derivative Works; and
105
106 (d) If the Work includes a "NOTICE" text file as part of its
107 distribution, then any Derivative Works that You distribute must
108 include a readable copy of the attribution notices contained
109 within such NOTICE file, excluding those notices that do not
110 pertain to any part of the Derivative Works, in at least one
111 of the following places: within a NOTICE text file distributed
112 as part of the Derivative Works; within the Source form or
113 documentation, if provided along with the Derivative Works; or,
114 within a display generated by the Derivative Works, if and
115 wherever such third-party notices normally appear. The contents
116 of the NOTICE file are for informational purposes only and
117 do not modify the License. You may add Your own attribution
118 notices within Derivative Works that You distribute, alongside
119 or as an addendum to the NOTICE text from the Work, provided
120 that such additional attribution notices cannot be construed
121 as modifying the License.
122
123 You may add Your own copyright statement to Your modifications and
124 may provide additional or different license terms and conditions
125 for use, reproduction, or distribution of Your modifications, or
126 for any such Derivative Works as a whole, provided Your use,
127 reproduction, and distribution of the Work otherwise complies with
128 the conditions stated in this License.
129
130 5. Submission of Contributions. Unless You explicitly state otherwise,
131 any Contribution intentionally submitted for inclusion in the Work
132 by You to the Licensor shall be under the terms and conditions of
133 this License, without any additional terms or conditions.
134 Notwithstanding the above, nothing herein shall supersede or modify
135 the terms of any separate license agreement you may have executed
136 with Licensor regarding such Contributions.
137
138 6. Trademarks. This License does not grant permission to use the trade
139 names, trademarks, service marks, or product names of the Licensor,
140 except as required for reasonable and customary use in describing the
141 origin of the Work and reproducing the content of the NOTICE file.
142
143 7. Disclaimer of Warranty. Unless required by applicable law or
144 agreed to in writing, Licensor provides the Work (and each
145 Contributor provides its Contributions) on an "AS IS" BASIS,
146 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 implied, including, without limitation, any warranties or conditions
148 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 PARTICULAR PURPOSE. You are solely responsible for determining the
150 appropriateness of using or redistributing the Work and assume any
151 risks associated with Your exercise of permissions under this License.
152
153 8. Limitation of Liability. In no event and under no legal theory,
154 whether in tort (including negligence), contract, or otherwise,
155 unless required by applicable law (such as deliberate and grossly
156 negligent acts) or agreed to in writing, shall any Contributor be
157 liable to You for damages, including any direct, indirect, special,
158 incidental, or consequential damages of any character arising as a
159 result of this License or out of the use or inability to use the
160 Work (including but not limited to damages for loss of goodwill,
161 work stoppage, computer failure or malfunction, or any and all
162 other commercial damages or losses), even if such Contributor
163 has been advised of the possibility of such damages.
164
165 9. Accepting Warranty or Additional Liability. While redistributing
166 the Work or Derivative Works thereof, You may choose to offer,
167 and charge a fee for, acceptance of support, warranty, indemnity,
168 or other liability obligations and/or rights consistent with this
169 License. However, in accepting such obligations, You may act only
170 on Your own behalf and on Your sole responsibility, not on behalf
171 of any other Contributor, and only if You agree to indemnify,
172 defend, and hold each Contributor harmless for any liability
173 incurred by, or claims asserted against, such Contributor by reason
174 of your accepting any such warranty or additional liability.
175
176 END OF TERMS AND CONDITIONS
177
178 APPENDIX: How to apply the Apache License to your work.
179
180 To apply the Apache License to your work, attach the following
181 boilerplate notice, with the fields enclosed by brackets "[]"
182 replaced with your own identifying information. (Don't include
183 the brackets!) The text should be enclosed in the appropriate
184 comment syntax for the file format. We also recommend that a
185 file or class name and description of purpose be included on the
186 same "printed page" as the copyright notice for easier
187 identification within third-party archives.
188
189 Copyright [yyyy] [name of copyright owner]
190
191 Licensed under the Apache License, Version 2.0 (the "License");
192 you may not use this file except in compliance with the License.
193 You may obtain a copy of the License at
194
195 http://www.apache.org/licenses/LICENSE-2.0
196
197 Unless required by applicable law or agreed to in writing, software
198 distributed under the License is distributed on an "AS IS" BASIS,
199 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 See the License for the specific language governing permissions and
201 limitations under the License.
Added web/src/shared/livekit/LICENSE-components-styles web/src/shared/livekit/LICENSE-components-styles +201 −0 View web/src/shared/livekit/LICENSE-components-styles
@@ -0,0 +1,201 @@
1 Apache License
2 Version 2.0, January 2004
3 http://www.apache.org/licenses/
4
5 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
7 1. Definitions.
8
9 "License" shall mean the terms and conditions for use, reproduction,
10 and distribution as defined by Sections 1 through 9 of this document.
11
12 "Licensor" shall mean the copyright owner or entity authorized by
13 the copyright owner that is granting the License.
14
15 "Legal Entity" shall mean the union of the acting entity and all
16 other entities that control, are controlled by, or are under common
17 control with that entity. For the purposes of this definition,
18 "control" means (i) the power, direct or indirect, to cause the
19 direction or management of such entity, whether by contract or
20 otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 outstanding shares, or (iii) beneficial ownership of such entity.
22
23 "You" (or "Your") shall mean an individual or Legal Entity
24 exercising permissions granted by this License.
25
26 "Source" form shall mean the preferred form for making modifications,
27 including but not limited to software source code, documentation
28 source, and configuration files.
29
30 "Object" form shall mean any form resulting from mechanical
31 transformation or translation of a Source form, including but
32 not limited to compiled object code, generated documentation,
33 and conversions to other media types.
34
35 "Work" shall mean the work of authorship, whether in Source or
36 Object form, made available under the License, as indicated by a
37 copyright notice that is included in or attached to the work
38 (an example is provided in the Appendix below).
39
40 "Derivative Works" shall mean any work, whether in Source or Object
41 form, that is based on (or derived from) the Work and for which the
42 editorial revisions, annotations, elaborations, or other modifications
43 represent, as a whole, an original work of authorship. For the purposes
44 of this License, Derivative Works shall not include works that remain
45 separable from, or merely link (or bind by name) to the interfaces of,
46 the Work and Derivative Works thereof.
47
48 "Contribution" shall mean any work of authorship, including
49 the original version of the Work and any modifications or additions
50 to that Work or Derivative Works thereof, that is intentionally
51 submitted to Licensor for inclusion in the Work by the copyright owner
52 or by an individual or Legal Entity authorized to submit on behalf of
53 the copyright owner. For the purposes of this definition, "submitted"
54 means any form of electronic, verbal, or written communication sent
55 to the Licensor or its representatives, including but not limited to
56 communication on electronic mailing lists, source code control systems,
57 and issue tracking systems that are managed by, or on behalf of, the
58 Licensor for the purpose of discussing and improving the Work, but
59 excluding communication that is conspicuously marked or otherwise
60 designated in writing by the copyright owner as "Not a Contribution."
61
62 "Contributor" shall mean Licensor and any individual or Legal Entity
63 on behalf of whom a Contribution has been received by Licensor and
64 subsequently incorporated within the Work.
65
66 2. Grant of Copyright License. Subject to the terms and conditions of
67 this License, each Contributor hereby grants to You a perpetual,
68 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 copyright license to reproduce, prepare Derivative Works of,
70 publicly display, publicly perform, sublicense, and distribute the
71 Work and such Derivative Works in Source or Object form.
72
73 3. Grant of Patent License. Subject to the terms and conditions of
74 this License, each Contributor hereby grants to You a perpetual,
75 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 (except as stated in this section) patent license to make, have made,
77 use, offer to sell, sell, import, and otherwise transfer the Work,
78 where such license applies only to those patent claims licensable
79 by such Contributor that are necessarily infringed by their
80 Contribution(s) alone or by combination of their Contribution(s)
81 with the Work to which such Contribution(s) was submitted. If You
82 institute patent litigation against any entity (including a
83 cross-claim or counterclaim in a lawsuit) alleging that the Work
84 or a Contribution incorporated within the Work constitutes direct
85 or contributory patent infringement, then any patent licenses
86 granted to You under this License for that Work shall terminate
87 as of the date such litigation is filed.
88
89 4. Redistribution. You may reproduce and distribute copies of the
90 Work or Derivative Works thereof in any medium, with or without
91 modifications, and in Source or Object form, provided that You
92 meet the following conditions:
93
94 (a) You must give any other recipients of the Work or
95 Derivative Works a copy of this License; and
96
97 (b) You must cause any modified files to carry prominent notices
98 stating that You changed the files; and
99
100 (c) You must retain, in the Source form of any Derivative Works
101 that You distribute, all copyright, patent, trademark, and
102 attribution notices from the Source form of the Work,
103 excluding those notices that do not pertain to any part of
104 the Derivative Works; and
105
106 (d) If the Work includes a "NOTICE" text file as part of its
107 distribution, then any Derivative Works that You distribute must
108 include a readable copy of the attribution notices contained
109 within such NOTICE file, excluding those notices that do not
110 pertain to any part of the Derivative Works, in at least one
111 of the following places: within a NOTICE text file distributed
112 as part of the Derivative Works; within the Source form or
113 documentation, if provided along with the Derivative Works; or,
114 within a display generated by the Derivative Works, if and
115 wherever such third-party notices normally appear. The contents
116 of the NOTICE file are for informational purposes only and
117 do not modify the License. You may add Your own attribution
118 notices within Derivative Works that You distribute, alongside
119 or as an addendum to the NOTICE text from the Work, provided
120 that such additional attribution notices cannot be construed
121 as modifying the License.
122
123 You may add Your own copyright statement to Your modifications and
124 may provide additional or different license terms and conditions
125 for use, reproduction, or distribution of Your modifications, or
126 for any such Derivative Works as a whole, provided Your use,
127 reproduction, and distribution of the Work otherwise complies with
128 the conditions stated in this License.
129
130 5. Submission of Contributions. Unless You explicitly state otherwise,
131 any Contribution intentionally submitted for inclusion in the Work
132 by You to the Licensor shall be under the terms and conditions of
133 this License, without any additional terms or conditions.
134 Notwithstanding the above, nothing herein shall supersede or modify
135 the terms of any separate license agreement you may have executed
136 with Licensor regarding such Contributions.
137
138 6. Trademarks. This License does not grant permission to use the trade
139 names, trademarks, service marks, or product names of the Licensor,
140 except as required for reasonable and customary use in describing the
141 origin of the Work and reproducing the content of the NOTICE file.
142
143 7. Disclaimer of Warranty. Unless required by applicable law or
144 agreed to in writing, Licensor provides the Work (and each
145 Contributor provides its Contributions) on an "AS IS" BASIS,
146 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 implied, including, without limitation, any warranties or conditions
148 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 PARTICULAR PURPOSE. You are solely responsible for determining the
150 appropriateness of using or redistributing the Work and assume any
151 risks associated with Your exercise of permissions under this License.
152
153 8. Limitation of Liability. In no event and under no legal theory,
154 whether in tort (including negligence), contract, or otherwise,
155 unless required by applicable law (such as deliberate and grossly
156 negligent acts) or agreed to in writing, shall any Contributor be
157 liable to You for damages, including any direct, indirect, special,
158 incidental, or consequential damages of any character arising as a
159 result of this License or out of the use or inability to use the
160 Work (including but not limited to damages for loss of goodwill,
161 work stoppage, computer failure or malfunction, or any and all
162 other commercial damages or losses), even if such Contributor
163 has been advised of the possibility of such damages.
164
165 9. Accepting Warranty or Additional Liability. While redistributing
166 the Work or Derivative Works thereof, You may choose to offer,
167 and charge a fee for, acceptance of support, warranty, indemnity,
168 or other liability obligations and/or rights consistent with this
169 License. However, in accepting such obligations, You may act only
170 on Your own behalf and on Your sole responsibility, not on behalf
171 of any other Contributor, and only if You agree to indemnify,
172 defend, and hold each Contributor harmless for any liability
173 incurred by, or claims asserted against, such Contributor by reason
174 of your accepting any such warranty or additional liability.
175
176 END OF TERMS AND CONDITIONS
177
178 APPENDIX: How to apply the Apache License to your work.
179
180 To apply the Apache License to your work, attach the following
181 boilerplate notice, with the fields enclosed by brackets "[]"
182 replaced with your own identifying information. (Don't include
183 the brackets!) The text should be enclosed in the appropriate
184 comment syntax for the file format. We also recommend that a
185 file or class name and description of purpose be included on the
186 same "printed page" as the copyright notice for easier
187 identification within third-party archives.
188
189 Copyright [yyyy] [name of copyright owner]
190
191 Licensed under the Apache License, Version 2.0 (the "License");
192 you may not use this file except in compliance with the License.
193 You may obtain a copy of the License at
194
195 http://www.apache.org/licenses/LICENSE-2.0
196
197 Unless required by applicable law or agreed to in writing, software
198 distributed under the License is distributed on an "AS IS" BASIS,
199 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 See the License for the specific language governing permissions and
201 limitations under the License.
Added web/src/shared/livekit/lk-i18n.ts web/src/shared/livekit/lk-i18n.ts +13 −0 View web/src/shared/livekit/lk-i18n.ts
@@ -0,0 +1,13 @@
1/**
2 * Copy for the vendored `@livekit/components-*` sources.
3 *
4 * The official sources ship English literals; every one that can reach the
5 * screen was replaced with an `lkT(key)` call so the dictionaries stay the only
6 * place user-visible copy lives (and so the i18n guard keeps failing on new
7 * hardcoded copy). Keys live in the `livekit` module of the shared dictionaries.
8 */
9import { t, type MessageKey, type MessageParams } from '@/shared/i18n'
10
11export function lkT(key: MessageKey, params?: MessageParams): string {
12 return t(key, params)
13}
Added web/src/shared/livekit/MANIFEST.json web/src/shared/livekit/MANIFEST.json +1700 −0 View web/src/shared/livekit/MANIFEST.json
@@ -0,0 +1,1700 @@
1{
2 "source": "https://github.com/livekit/components-js",
3 "reason": "Vendored deliberately: the official package names keep working through tsconfig/vite aliases, so this tree can be replaced with a fresh npm tarball and the localModifications below replayed one at a time.",
4 "packages": {
5 "@livekit/components-react": {
6 "version": "2.9.24",
7 "license": "Apache-2.0"
8 },
9 "@livekit/components-core": {
10 "version": "0.12.15",
11 "license": "Apache-2.0"
12 },
13 "@livekit/components-styles": {
14 "version": "1.2.0",
15 "license": "Apache-2.0",
16 "note": "only dist/general (compiled CSS) and dist/types_unprefixed (class-name .scss.d.ts) are vendored"
17 }
18 },
19 "localModifications": [
20 "react/assets/icons/CameraDisabledIcon.tsx",
21 "react/assets/icons/CameraIcon.tsx",
22 "react/assets/icons/ChatCloseIcon.tsx",
23 "react/assets/icons/ChatIcon.tsx",
24 "react/assets/icons/Chevron.tsx",
25 "react/assets/icons/FocusToggleIcon.tsx",
26 "react/assets/icons/GearIcon.tsx",
27 "react/assets/icons/LeaveIcon.tsx",
28 "react/assets/icons/LockLockedIcon.tsx",
29 "react/assets/icons/MicDisabledIcon.tsx",
30 "react/assets/icons/MicIcon.tsx",
31 "react/assets/icons/QualityExcellentIcon.tsx",
32 "react/assets/icons/QualityGoodIcon.tsx",
33 "react/assets/icons/QualityPoorIcon.tsx",
34 "react/assets/icons/QualityUnknownIcon.tsx",
35 "react/assets/icons/ScreenShareIcon.tsx",
36 "react/assets/icons/ScreenShareStopIcon.tsx",
37 "react/assets/icons/SpinnerIcon.tsx",
38 "react/assets/icons/UnfocusToggleIcon.tsx",
39 "react/assets/icons/util.tsx",
40 "react/assets/images/ParticipantPlaceholder.tsx",
41 "react/components/ConnectionStateToast.tsx",
42 "react/components/controls/PaginationControl.tsx",
43 "react/components/controls/StartAudio.tsx",
44 "react/components/controls/StartMediaButton.tsx",
45 "react/components/controls/TrackToggle.tsx",
46 "react/components/participant/ConnectionQualityIndicator.tsx",
47 "react/components/participant/ParticipantTile.tsx",
48 "react/components/RoomAudioRenderer.tsx",
49 "react/components/SessionProvider.tsx",
50 "react/context/session-context.ts",
51 "react/hooks/internal/useResizeObserver.ts",
52 "react/hooks/useAgent.ts",
53 "react/hooks/useEvents.ts",
54 "react/hooks/useGridLayout.ts",
55 "react/hooks/useMediaTrackBySourceOrName.ts",
56 "react/hooks/useParticipantAttributes.ts",
57 "react/hooks/useSession.ts",
58 "react/hooks/useSessionMessages.ts",
59 "react/hooks/useSwipe.ts",
60 "react/hooks/useTrackVolume.ts",
61 "react/hooks/useVoiceAssistant.ts",
62 "react/prefabs/Chat.tsx",
63 "react/prefabs/ControlBar.tsx",
64 "react/prefabs/MediaDeviceMenu.tsx",
65 "react/utils.ts",
66 "core/components/chat.ts",
67 "core/helper/participant-attributes.ts",
68 "core/observables/dataChannel.ts",
69 "core/observables/track.ts"
70 ],
71 "localModificationReasons": {
72 "erasableSyntaxOnly + verbatimModuleSyntax + noUnusedLocals (this repo is stricter than upstream)": [
73 "enums -> as-const objects",
74 "angle-bracket assertions -> as expressions",
75 "type-only imports",
76 "unused React namespace imports removed (jsx: react-jsx)"
77 ],
78 "React 19 / TS 6 / livekit-client 2.22 type drift": [
79 "RefObject<T | null> widening",
80 "useRef initial value",
81 "Uint8Array -> BlobPart cast",
82 "trackSyncTimeObserver media-time payload cast",
83 "process.env guard -> import.meta.env.DEV",
84 "ambient 'events' module declaration (react/types/events.d.ts)"
85 ]
86 },
87 "addedLocally": [
88 "react/types/events.d.ts"
89 ],
90 "removedFromUpstream": [
91 "react/assets/template.js",
92 "react/hooks/cloud/krisp/useKrispNoiseFilter.ts",
93 "react/hooks/useAgentExpression.test.ts",
94 "react/hooks/useMediaDeviceSelect.test.ts",
95 "react/hooks/usePagination.test.ts",
96 "react/hooks/useTracks.test.ts",
97 "react/index.docs.ts",
98 "core/helper/emailRegex.test.ts",
99 "core/helper/grid-layouts.test.ts",
100 "core/helper/url-regex.test.ts",
101 "core/sorting/base-sort-functions.test.ts",
102 "core/sorting/sort-track-bundles.test.ts",
103 "core/sorting/tile-array-update.test.ts",
104 "core/track-reference/test-utils.test.ts",
105 "core/track-reference/track-reference.utils.test.ts",
106 "core/utilis.test.ts",
107 "styles/dist/types/general/components/controls/index.css.d.ts",
108 "styles/dist/types/general/components/index.css.d.ts",
109 "styles/dist/types/general/components/layout/index.css.d.ts",
110 "styles/dist/types/general/components/participant/index.css.d.ts",
111 "styles/dist/types/general/index.css.d.ts",
112 "styles/dist/types/general/prefabs/audio-conference.css.d.ts",
113 "styles/dist/types/general/prefabs/chat.css.d.ts",
114 "styles/dist/types/general/prefabs/control-bar.css.d.ts",
115 "styles/dist/types/general/prefabs/index.css.d.ts",
116 "styles/dist/types/general/prefabs/prejoin.css.d.ts",
117 "styles/dist/types/general/prefabs/video-conference.css.d.ts",
118 "styles/dist/types/general/themes/huddle.css.d.ts"
119 ],
120 "files": [
121 {
122 "path": "react/assets/icons/CameraDisabledIcon.tsx",
123 "package": "react",
124 "sha256": "c039bf27027a85bf5ba5677d16fe2362133ac3bb1c6a180269c18b500a8470e8",
125 "upstreamSha256": "6df862d79b0bdd70866667212562af3e187fae6c13a1f989636ba3361cfab66c"
126 },
127 {
128 "path": "react/assets/icons/CameraIcon.tsx",
129 "package": "react",
130 "sha256": "25a0a6654982d09a700d6ab4c71d98eb353be74567aef2d7cad8437946b2da4d",
131 "upstreamSha256": "1558194d31ca53d6c21f3e1b4a7425859d9b7f39b560299baff0b9864e4de6fa"
132 },
133 {
134 "path": "react/assets/icons/ChatCloseIcon.tsx",
135 "package": "react",
136 "sha256": "d759011a2f416fbae58541eec7d72f28008730942f0b896806baacb7893b0209",
137 "upstreamSha256": "3379d0f223ac8868561bb14ffcb72efd7ad3eb7bf5b640f0cb4181ce6c897a33"
138 },
139 {
140 "path": "react/assets/icons/ChatIcon.tsx",
141 "package": "react",
142 "sha256": "af79e2b4f006511c834c4202443b5a003b178571f76677a3e177eab88cdb0fce",
143 "upstreamSha256": "5f328af7402379fcd5968bda677544cb9c1e16d4606c36ed5b2b1baf04e14f41"
144 },
145 {
146 "path": "react/assets/icons/Chevron.tsx",
147 "package": "react",
148 "sha256": "2ce05679f570734b88be39364dfd163ab3a5269493f4023c66b8df54ee096674",
149 "upstreamSha256": "3be28e41c65165daeeaaea8555673a0a2a1335a305439637b51b1988447a87ea"
150 },
151 {
152 "path": "react/assets/icons/FocusToggleIcon.tsx",
153 "package": "react",
154 "sha256": "5213be1136ae3d11f5c0821e5897ebe3d68fe81be8ec7ead8b0bbd5d0df71a78",
155 "upstreamSha256": "6668539bfecfca11356dfb5024b24a08e595b1cda59ded5f500e9aadda0ff813"
156 },
157 {
158 "path": "react/assets/icons/GearIcon.tsx",
159 "package": "react",
160 "sha256": "28eadd62d6a046bf1a4fa5a22325443aca60bd37528747c6dd22c6995f29f5e2",
161 "upstreamSha256": "b5504f65c8e1a7f361ba2d2abb7e2a20a2797e5a65cb2285519ff4406d0544f6"
162 },
163 {
164 "path": "react/assets/icons/index.ts",
165 "package": "react",
166 "sha256": "ec1e3c5f0d648767551eb68af7104f3413c3a9f693891dbd271fc4acb229c579",
167 "upstreamSha256": "ec1e3c5f0d648767551eb68af7104f3413c3a9f693891dbd271fc4acb229c579"
168 },
169 {
170 "path": "react/assets/icons/LeaveIcon.tsx",
171 "package": "react",
172 "sha256": "d754f922f6fa6f4d18e2bca0ff493c382eef772ba0e966e5c94c437cb892d5db",
173 "upstreamSha256": "d561fcbde561aada1047fa85b7ada93112d3bd396a2c89b343fbb0c17964a728"
174 },
175 {
176 "path": "react/assets/icons/LockLockedIcon.tsx",
177 "package": "react",
178 "sha256": "9b8a3a66f6e43a7f6557035778cce731beaf2b625febc6c4f145f5fb84a0ae35",
179 "upstreamSha256": "851ce4371ad582d1671713130e92b4ff30594cea0cf191fbbd086ebd1accceab"
180 },
181 {
182 "path": "react/assets/icons/MicDisabledIcon.tsx",
183 "package": "react",
184 "sha256": "5d89976e706efce7aca906f3d965be5a14e9c9e3c6d9a233c243bfed0f42245d",
185 "upstreamSha256": "4a77e4ce816476ef38490b1ae5d678c573a28ab37ee3272fb4c68fb05e5466d9"
186 },
187 {
188 "path": "react/assets/icons/MicIcon.tsx",
189 "package": "react",
190 "sha256": "ae85bd6bb4c7a3a9adfa3430ff1fdbeff89a402fe03b5e1fd73e1b355edcc1ed",
191 "upstreamSha256": "2bce39313fc511358728be02fba33475a396672db7b19416dd2c8f1eeeea00f0"
192 },
193 {
194 "path": "react/assets/icons/QualityExcellentIcon.tsx",
195 "package": "react",
196 "sha256": "821f869d93257b95e23947a6b7fd5ae8459c3969f92bf35baef2a45a0df638b5",
197 "upstreamSha256": "5f899a60465a3128d906f33b53da01f84d91c88bf195b05b91d222fb2ddb44e6"
198 },
199 {
200 "path": "react/assets/icons/QualityGoodIcon.tsx",
201 "package": "react",
202 "sha256": "fb784e11af065a39cddfabf7cdf9d023287142507dc1853d2ba5fac026ebcbe5",
203 "upstreamSha256": "c07b02baca1e2094672cd12363f4830c7236f883822fd15efbdb417893e701fe"
204 },
205 {
206 "path": "react/assets/icons/QualityPoorIcon.tsx",
207 "package": "react",
208 "sha256": "039eaeee8c80b991491627fde0ee2b52cc8974a86feac25416232448d7d89786",
209 "upstreamSha256": "9873f559d03ef9ea7434edb3b51bb16684e928edad8a44e5ee438ff52ef91e26"
210 },
211 {
212 "path": "react/assets/icons/QualityUnknownIcon.tsx",
213 "package": "react",
214 "sha256": "a2668b149f0875dba291f8879704c85489ed492e1bfc8228109ad773ce4b45e1",
215 "upstreamSha256": "9aac81fd8adac7956cdeb9e433ad55ff4994e04ec6e201bf869739fc75597357"
216 },
217 {
218 "path": "react/assets/icons/ScreenShareIcon.tsx",
219 "package": "react",
220 "sha256": "3f0f7a79eb4b3176a525c78587fc3fed75257e15acffd15974652610eeda016f",
221 "upstreamSha256": "50ceab6f3f8ec905aff5e1ebb8a2c6e09a2c16e7011f937c82e2369843e8c02b"
222 },
223 {
224 "path": "react/assets/icons/ScreenShareStopIcon.tsx",
225 "package": "react",
226 "sha256": "cf1e4948855c297b0fac7190538069ffb4fae17d2976f2ca3ce2d385ac6c822d",
227 "upstreamSha256": "d0b895ad71aa751244247f097dedb46799533d87d82d67e6b4ff9d6b6ea9407d"
228 },
229 {
230 "path": "react/assets/icons/SpinnerIcon.tsx",
231 "package": "react",
232 "sha256": "dd189b6f6a4bde8db0ccea8ed7db273b6756d0a82fe0fbda602318f89340c92a",
233 "upstreamSha256": "1be7288eb1e0331db2ec1dbc77de584375e401841a629720678fc56e18d6cc7d"
234 },
235 {
236 "path": "react/assets/icons/UnfocusToggleIcon.tsx",
237 "package": "react",
238 "sha256": "6f0bd932ab4bfda092902a5b1bf690b609d0fbf4d1220ec19f4c0d714b075d00",
239 "upstreamSha256": "00d94585858042d5219b1cd8af1435b054f9fc9a54f3972ae07a775793108476"
240 },
241 {
242 "path": "react/assets/icons/util.tsx",
243 "package": "react",
244 "sha256": "55fa895f70109258d0005dcead9f65a39813252f31b8aefc56f5a9109e6b1c83",
245 "upstreamSha256": "57eed846c8f39ff360df9ebd7e01bde7fb03b49c42f0cd595dcd46584d0624f4"
246 },
247 {
248 "path": "react/assets/images/index.ts",
249 "package": "react",
250 "sha256": "d8cd5b3855a83255827feba961816678e633f0348dafa6108195fe8d038ada7c",
251 "upstreamSha256": "d8cd5b3855a83255827feba961816678e633f0348dafa6108195fe8d038ada7c"
252 },
253 {
254 "path": "react/assets/images/ParticipantPlaceholder.tsx",
255 "package": "react",
256 "sha256": "a29b7c5464933b6092daecbfe74c1cc35ea6d340b49f31d61b61ad8eb200c9d6",
257 "upstreamSha256": "c1d621fbc53c654099344d4e1a0ea97b43f9a69dc679506503c44aaec9198806"
258 },
259 {
260 "path": "react/components/ChatEntry.tsx",
261 "package": "react",
262 "sha256": "8900973b0a30444489749a35381f77b58df6c7ebea47f963e6e31f21fc3ec427",
263 "upstreamSha256": "8900973b0a30444489749a35381f77b58df6c7ebea47f963e6e31f21fc3ec427"
264 },
265 {
266 "path": "react/components/ConnectionState.tsx",
267 "package": "react",
268 "sha256": "d865948957b59d359867e9fc07f2f10456efa0238927a1e75193b69c6f99573c",
269 "upstreamSha256": "d865948957b59d359867e9fc07f2f10456efa0238927a1e75193b69c6f99573c"
270 },
271 {
272 "path": "react/components/ConnectionStateToast.tsx",
273 "package": "react",
274 "sha256": "0a231ea23684a5813e468e146e259f9fb22578d0477cf76d84f17c5da7ab01fe",
275 "upstreamSha256": "ece68ef47403e6258d83a5049a60162f96777c5004bd5d6841f58e86ce153777"
276 },
277 {
278 "path": "react/components/controls/ChatToggle.tsx",
279 "package": "react",
280 "sha256": "46add22c36793e1df57cabf24e718461c4dccd078924aa73cec22c9e6cb26682",
281 "upstreamSha256": "46add22c36793e1df57cabf24e718461c4dccd078924aa73cec22c9e6cb26682"
282 },
283 {
284 "path": "react/components/controls/ClearPinButton.tsx",
285 "package": "react",
286 "sha256": "78223108ac702c408de74d9e7ab5f8c69f2bbb055fcefb38d69afde370a7f6aa",
287 "upstreamSha256": "78223108ac702c408de74d9e7ab5f8c69f2bbb055fcefb38d69afde370a7f6aa"
288 },
289 {
290 "path": "react/components/controls/DisconnectButton.tsx",
291 "package": "react",
292 "sha256": "3e606eb3dc6f82d686675591a5af972d9bf1f6ec21562602906a8439ccff5b4a",
293 "upstreamSha256": "3e606eb3dc6f82d686675591a5af972d9bf1f6ec21562602906a8439ccff5b4a"
294 },
295 {
296 "path": "react/components/controls/FocusToggle.tsx",
297 "package": "react",
298 "sha256": "6932348d883b7116c568ebe52f682abe485d32efe69eb9a6b2e833da519d7fbd",
299 "upstreamSha256": "6932348d883b7116c568ebe52f682abe485d32efe69eb9a6b2e833da519d7fbd"
300 },
301 {
302 "path": "react/components/controls/MediaDeviceSelect.tsx",
303 "package": "react",
304 "sha256": "c7f1ac4972eab877f313f6b74b3ba47cf2bee7a45df5b3423e085c32f471270c",
305 "upstreamSha256": "c7f1ac4972eab877f313f6b74b3ba47cf2bee7a45df5b3423e085c32f471270c"
306 },
307 {
308 "path": "react/components/controls/PaginationControl.tsx",
309 "package": "react",
310 "sha256": "efce49bb434b846a1f42eaab4bd8ecd986f34e0ca27a4fca601467d788641cb1",
311 "upstreamSha256": "5042baefa132f7a308033a1a4c1a31a029ea15bf82e51792d70acc73e60de8ef"
312 },
313 {
314 "path": "react/components/controls/PaginationIndicator.tsx",
315 "package": "react",
316 "sha256": "32ffc1dc4da0d58deaf93c96d4c8446975e08d8f801ca0c60fed35f1438f443e",
317 "upstreamSha256": "32ffc1dc4da0d58deaf93c96d4c8446975e08d8f801ca0c60fed35f1438f443e"
318 },
319 {
320 "path": "react/components/controls/SettingsMenuToggle.tsx",
321 "package": "react",
322 "sha256": "f183161ae4e170f0276c7470bcb32ac62d37d3753d368da8f15142179f9a49e2",
323 "upstreamSha256": "f183161ae4e170f0276c7470bcb32ac62d37d3753d368da8f15142179f9a49e2"
324 },
325 {
326 "path": "react/components/controls/StartAudio.tsx",
327 "package": "react",
328 "sha256": "c6abbeae18ab3707fabfde64a4bcc8324badbf91c724e9b5a2204bace093f2a7",
329 "upstreamSha256": "f93845a26b16cc2863f9a1f50b6ad4dea5b606a06779fdae89f1590496c52bab"
330 },
331 {
332 "path": "react/components/controls/StartMediaButton.tsx",
333 "package": "react",
334 "sha256": "e0705441706f385a4ab97640ea55a4cb87d08f4d63bccf2b63595a9250c1b298",
335 "upstreamSha256": "f2735fdce1a907908d6dbfe964ce51a5759c69a7f2e913bbd0c195911710642f"
336 },
337 {
338 "path": "react/components/controls/TrackToggle.tsx",
339 "package": "react",
340 "sha256": "afc6f84d27235563f4518e90fcc8a14f2e61e5a6e1d94a44a30a8c72c5255d37",
341 "upstreamSha256": "4c306e53b3708a4f9e5d916746ca50754ec7af745b174e6e9002f9c1b77317d3"
342 },
343 {
344 "path": "react/components/index.ts",
345 "package": "react",
346 "sha256": "5b4c7be254d37b5ce2f993ccf843fc3e6a5298a4560ed940332fb3773f09a26e",
347 "upstreamSha256": "5b4c7be254d37b5ce2f993ccf843fc3e6a5298a4560ed940332fb3773f09a26e"
348 },
349 {
350 "path": "react/components/layout/CarouselLayout.tsx",
351 "package": "react",
352 "sha256": "b8141c5958e14c003ab7bf735f42cda21f341fc65ee94c043da2f902554460e5",
353 "upstreamSha256": "b8141c5958e14c003ab7bf735f42cda21f341fc65ee94c043da2f902554460e5"
354 },
355 {
356 "path": "react/components/layout/FocusLayout.tsx",
357 "package": "react",
358 "sha256": "9bb4724173c3d976af7f9bcc36ea52629ca09e912e00fac9765b5b4d14f0bf3a",
359 "upstreamSha256": "9bb4724173c3d976af7f9bcc36ea52629ca09e912e00fac9765b5b4d14f0bf3a"
360 },
361 {
362 "path": "react/components/layout/GridLayout.tsx",
363 "package": "react",
364 "sha256": "7b2f0169b00d373f5004bb0de46909cb123657be427225faccfcd770bd66f069",
365 "upstreamSha256": "7b2f0169b00d373f5004bb0de46909cb123657be427225faccfcd770bd66f069"
366 },
367 {
368 "path": "react/components/layout/index.ts",
369 "package": "react",
370 "sha256": "a2b2ce92709ed92a8e3475f4ae19804efaf9a832d4bc0f5bc32867a8122ae5ce",
371 "upstreamSha256": "a2b2ce92709ed92a8e3475f4ae19804efaf9a832d4bc0f5bc32867a8122ae5ce"
372 },
373 {
374 "path": "react/components/layout/LayoutContextProvider.tsx",
375 "package": "react",
376 "sha256": "9e699d6454c8803d06c5080216184bbe4085c89ae7a01d96c61fe5032cc1a44a",
377 "upstreamSha256": "9e699d6454c8803d06c5080216184bbe4085c89ae7a01d96c61fe5032cc1a44a"
378 },
379 {
380 "path": "react/components/LiveKitRoom.tsx",
381 "package": "react",
382 "sha256": "1cfe5af7b41d26934b02cf842e2fe98ea5529a07cdef49961c874ca3aec3fa75",
383 "upstreamSha256": "1cfe5af7b41d26934b02cf842e2fe98ea5529a07cdef49961c874ca3aec3fa75"
384 },
385 {
386 "path": "react/components/participant/animationSequences/connectingSequence.ts",
387 "package": "react",
388 "sha256": "f6909ad81bd9f6f02e394c80705a3704bf304ac3ac052250e7b9f9d95859d973",
389 "upstreamSha256": "f6909ad81bd9f6f02e394c80705a3704bf304ac3ac052250e7b9f9d95859d973"
390 },
391 {
392 "path": "react/components/participant/animationSequences/listeningSequence.ts",
393 "package": "react",
394 "sha256": "a9715388daf98b9ef0bf60a67a9100088219deab3c57055cce5475b5fb544394",
395 "upstreamSha256": "a9715388daf98b9ef0bf60a67a9100088219deab3c57055cce5475b5fb544394"
396 },
397 {
398 "path": "react/components/participant/animationSequences/thinkingSequence.ts",
399 "package": "react",
400 "sha256": "1aaafee4c164f46c3ddde1086b5e9399264d2421efcd55179d567c1458c16a01",
401 "upstreamSha256": "1aaafee4c164f46c3ddde1086b5e9399264d2421efcd55179d567c1458c16a01"
402 },
403 {
404 "path": "react/components/participant/animators/useBarAnimator.ts",
405 "package": "react",
406 "sha256": "1172ce1df1e6dc2638a67b71e9af86a8ec4181ce75b092f33b30aa53292abc90",
407 "upstreamSha256": "1172ce1df1e6dc2638a67b71e9af86a8ec4181ce75b092f33b30aa53292abc90"
408 },
409 {
410 "path": "react/components/participant/AudioTrack.tsx",
411 "package": "react",
412 "sha256": "765fc0bf3e0de5cfd19ecbf3ad97960632ff8afab2f762231773b1a2c334d40d",
413 "upstreamSha256": "765fc0bf3e0de5cfd19ecbf3ad97960632ff8afab2f762231773b1a2c334d40d"
414 },
415 {
416 "path": "react/components/participant/AudioVisualizer.tsx",
417 "package": "react",
418 "sha256": "cc144cdb683ca7f17b85321ab7adb2b8298390a016f36d7c0d8f6b94f76e0cba",
419 "upstreamSha256": "cc144cdb683ca7f17b85321ab7adb2b8298390a016f36d7c0d8f6b94f76e0cba"
420 },
421 {
422 "path": "react/components/participant/BarVisualizer.tsx",
423 "package": "react",
424 "sha256": "8b0f04b6889607acdefd3727e4681455fb27e8af68f1130db45d4d9847e89df6",
425 "upstreamSha256": "8b0f04b6889607acdefd3727e4681455fb27e8af68f1130db45d4d9847e89df6"
426 },
427 {
428 "path": "react/components/participant/ConnectionQualityIndicator.tsx",
429 "package": "react",
430 "sha256": "5ddb8359376bf1025f9d63c5cabb63a0b4a3645a9f858b8a896a5bcef97b6ea9",
431 "upstreamSha256": "2a2b0fabce9923b41f5f1ee996b2c9c9d53ea476e0705859e7877bd30c8a92ac"
432 },
433 {
434 "path": "react/components/participant/ParticipantAudioTile.tsx",
435 "package": "react",
436 "sha256": "284ae473b480ec75ed76bfbbc07f86d757a76b24abc81bc73c26d3b837c710cd",
437 "upstreamSha256": "284ae473b480ec75ed76bfbbc07f86d757a76b24abc81bc73c26d3b837c710cd"
438 },
439 {
440 "path": "react/components/participant/ParticipantName.tsx",
441 "package": "react",
442 "sha256": "6570dfc4e6019de067860a0750276f121182a8e77b6b99650c27d423b60778e0",
443 "upstreamSha256": "6570dfc4e6019de067860a0750276f121182a8e77b6b99650c27d423b60778e0"
444 },
445 {
446 "path": "react/components/participant/ParticipantTile.tsx",
447 "package": "react",
448 "sha256": "f386abfa08607d2dc4e3d522b254685f5c51100d2ae45af985c110628c2aff58",
449 "upstreamSha256": "00209b0eee509769c1575cc63b7b70f6e286974e571ccff7982920dc8368953e"
450 },
451 {
452 "path": "react/components/participant/TrackMutedIndicator.tsx",
453 "package": "react",
454 "sha256": "8a2a55c0b6c002ed082ae1077058bcaa33a60d62eb57b62bd60904233798e401",
455 "upstreamSha256": "8a2a55c0b6c002ed082ae1077058bcaa33a60d62eb57b62bd60904233798e401"
456 },
457 {
458 "path": "react/components/participant/VideoTrack.tsx",
459 "package": "react",
460 "sha256": "526f8893fde547bf48c91b71a43cf4e16b7d59ed1a99646a77ead0d9671abb54",
461 "upstreamSha256": "526f8893fde547bf48c91b71a43cf4e16b7d59ed1a99646a77ead0d9671abb54"
462 },
463 {
464 "path": "react/components/ParticipantLoop.tsx",
465 "package": "react",
466 "sha256": "43315c19c73d4c939b549ea165fcdf85d4f282a6a5f9dfb573ca293ebe382d5d",
467 "upstreamSha256": "43315c19c73d4c939b549ea165fcdf85d4f282a6a5f9dfb573ca293ebe382d5d"
468 },
469 {
470 "path": "react/components/RoomAudioRenderer.tsx",
471 "package": "react",
472 "sha256": "8b1b3e54c68f8bde6bde6af6094f9ade3ad877000a6ac0c2e34c90866a5dda13",
473 "upstreamSha256": "69023c2dcecbea5b6b432d54f55d6472ae1cd9eda321d3f91bcd645cd8068308"
474 },
475 {
476 "path": "react/components/RoomName.tsx",
477 "package": "react",
478 "sha256": "c866799f0b3d2313c31dbac833f114ff5aac8d5db06ef51e47794fe2a388b279",
479 "upstreamSha256": "c866799f0b3d2313c31dbac833f114ff5aac8d5db06ef51e47794fe2a388b279"
480 },
481 {
482 "path": "react/components/SessionProvider.tsx",
483 "package": "react",
484 "sha256": "1b3af2db254a9411987a4227f17a5d405782154c6a815870bb7e747b451998fc",
485 "upstreamSha256": "d8494e4dc228ddfa0e7b5a5bbed2764ac306a601e94d80c8db6eec4efc0bafb7"
486 },
487 {
488 "path": "react/components/Toast.tsx",
489 "package": "react",
490 "sha256": "f8407b7d222225b2c3ab31dbb0be0a16e21c0e82b20784bebaba31bf1e1622f5",
491 "upstreamSha256": "f8407b7d222225b2c3ab31dbb0be0a16e21c0e82b20784bebaba31bf1e1622f5"
492 },
493 {
494 "path": "react/components/TrackLoop.tsx",
495 "package": "react",
496 "sha256": "06843d6be4f6645c149022f3cdd2e95a7f115d2d77d16f5a8b0da683e87c1876",
497 "upstreamSha256": "06843d6be4f6645c149022f3cdd2e95a7f115d2d77d16f5a8b0da683e87c1876"
498 },
499 {
500 "path": "react/context/chat-context.ts",
501 "package": "react",
502 "sha256": "df104b33b1d0f5d91c9c2d2b4bc893ed4c17eeedc8b225098bd92872d058fab3",
503 "upstreamSha256": "df104b33b1d0f5d91c9c2d2b4bc893ed4c17eeedc8b225098bd92872d058fab3"
504 },
505 {
506 "path": "react/context/feature-context.ts",
507 "package": "react",
508 "sha256": "0e60c34ddb7f2f2021e5f164e22596733d546456cf9a1fa19e991cef14dbb23a",
509 "upstreamSha256": "0e60c34ddb7f2f2021e5f164e22596733d546456cf9a1fa19e991cef14dbb23a"
510 },
511 {
512 "path": "react/context/index.ts",
513 "package": "react",
514 "sha256": "99a3dafc369ba35f72500ddcbc284c05674102b0fcd17768654d50e09db0d701",
515 "upstreamSha256": "99a3dafc369ba35f72500ddcbc284c05674102b0fcd17768654d50e09db0d701"
516 },
517 {
518 "path": "react/context/layout-context.ts",
519 "package": "react",
520 "sha256": "baa46009c70f116e3c99822d5e5e39a069be725b66395ff151487d5f860c801a",
521 "upstreamSha256": "baa46009c70f116e3c99822d5e5e39a069be725b66395ff151487d5f860c801a"
522 },
523 {
524 "path": "react/context/participant-context.ts",
525 "package": "react",
526 "sha256": "4b9b98c04c73948a24d2c93c19cf0f9169763de4f854c49423cae6e9cde126e5",
527 "upstreamSha256": "4b9b98c04c73948a24d2c93c19cf0f9169763de4f854c49423cae6e9cde126e5"
528 },
529 {
530 "path": "react/context/pin-context.ts",
531 "package": "react",
532 "sha256": "bae29726b275b2c5fea49ad6a09c225e6a02396b9644d098cd99f35467ab9e28",
533 "upstreamSha256": "bae29726b275b2c5fea49ad6a09c225e6a02396b9644d098cd99f35467ab9e28"
534 },
535 {
536 "path": "react/context/room-context.ts",
537 "package": "react",
538 "sha256": "c45b4a30513058f79a89db31d5940464194ebd96491c83090aa8e5d0c4dea7a7",
539 "upstreamSha256": "c45b4a30513058f79a89db31d5940464194ebd96491c83090aa8e5d0c4dea7a7"
540 },
541 {
542 "path": "react/context/session-context.ts",
543 "package": "react",
544 "sha256": "998e174f2e2024b4e98d7e9a88ecee41e5bcad43bb1d7b033fdba1aafa37d98b",
545 "upstreamSha256": "de7f1a3f2c2f139074728527c791868b26c9a25928e820d6d9ef61c60e27db49"
546 },
547 {
548 "path": "react/context/track-reference-context.ts",
549 "package": "react",
550 "sha256": "e1f9eb8a73f92b7cef2d8e45ccbfff5f0b094b05c16ac0d400d13da55128684a",
551 "upstreamSha256": "e1f9eb8a73f92b7cef2d8e45ccbfff5f0b094b05c16ac0d400d13da55128684a"
552 },
553 {
554 "path": "react/hooks/index.ts",
555 "package": "react",
556 "sha256": "0b522f92911ba46db6d08d73bb3b8af1b25f8441b064fba121f9f113af33f5ae",
557 "upstreamSha256": "0b522f92911ba46db6d08d73bb3b8af1b25f8441b064fba121f9f113af33f5ae"
558 },
559 {
560 "path": "react/hooks/internal/index.ts",
561 "package": "react",
562 "sha256": "7163f755210cdd2c1fc051f8d2af8fa08a600b919ae6a59b41dac4116786eb3d",
563 "upstreamSha256": "7163f755210cdd2c1fc051f8d2af8fa08a600b919ae6a59b41dac4116786eb3d"
564 },
565 {
566 "path": "react/hooks/internal/useMediaQuery.ts",
567 "package": "react",
568 "sha256": "1c6691f33194f3156b36bf2bb81583d92edf42ddcf2fdb1a67aa25c5c3921dcd",
569 "upstreamSha256": "1c6691f33194f3156b36bf2bb81583d92edf42ddcf2fdb1a67aa25c5c3921dcd"
570 },
571 {
572 "path": "react/hooks/internal/useObservableState.ts",
573 "package": "react",
574 "sha256": "8caab14ec413944f36ed78e0b01ef056ab8cb4882e39bcca0376969ceee0b86c",
575 "upstreamSha256": "8caab14ec413944f36ed78e0b01ef056ab8cb4882e39bcca0376969ceee0b86c"
576 },
577 {
578 "path": "react/hooks/internal/useResizeObserver.ts",
579 "package": "react",
580 "sha256": "2290b6857672e56945463eb7dd121f11df2985d9dad85efbd03ae4962da7b79f",
581 "upstreamSha256": "79f4254013def84c913e3abad96b1f2ea70f26ec9e19cb81147064c42a01a0a7"
582 },
583 {
584 "path": "react/hooks/useAgent.ts",
585 "package": "react",
586 "sha256": "ccb06915e7c4feabd9295e9b438b898f08c1224fa6ef40523ae1e712b5fe6c62",
587 "upstreamSha256": "7a394d9923bf1d447b9dba239b2c2237fd50246b39a49b5ee5a18a4de85ed7b9"
588 },
589 {
590 "path": "react/hooks/useAgentExpression.ts",
591 "package": "react",
592 "sha256": "eea3752e144ada7bc0d7cd085a71eebb99dc89ad49388d989e80da434aba3c0a",
593 "upstreamSha256": "eea3752e144ada7bc0d7cd085a71eebb99dc89ad49388d989e80da434aba3c0a"
594 },
595 {
596 "path": "react/hooks/useAudioPlayback.ts",
597 "package": "react",
598 "sha256": "9069f52cbf4372a1a1704609d2e3eecbd11ddd677994a997635529b139a2da4a",
599 "upstreamSha256": "9069f52cbf4372a1a1704609d2e3eecbd11ddd677994a997635529b139a2da4a"
600 },
601 {
602 "path": "react/hooks/useChat.ts",
603 "package": "react",
604 "sha256": "f14c4c61480b78f430ba365582717a212cc38c06b2a34eac724f3175ce0b2f51",
605 "upstreamSha256": "f14c4c61480b78f430ba365582717a212cc38c06b2a34eac724f3175ce0b2f51"
606 },
607 {
608 "path": "react/hooks/useChatToggle.ts",
609 "package": "react",
610 "sha256": "4dd405b9dc11746d83126087322d0c1483c607f56c9c797cb6bb704ab22bf28e",
611 "upstreamSha256": "4dd405b9dc11746d83126087322d0c1483c607f56c9c797cb6bb704ab22bf28e"
612 },
613 {
614 "path": "react/hooks/useClearPinButton.ts",
615 "package": "react",
616 "sha256": "683c5e9e47253d6ad6bf54e66ecf6a19b122aa3dcef0f5f2598e1c839a03859f",
617 "upstreamSha256": "683c5e9e47253d6ad6bf54e66ecf6a19b122aa3dcef0f5f2598e1c839a03859f"
618 },
619 {
620 "path": "react/hooks/useConnectionQualityIndicator.ts",
621 "package": "react",
622 "sha256": "102e8fa23f666b8d7e49dcfdf580ee0055b68e4cf246e7d18907b5fb89003f74",
623 "upstreamSha256": "102e8fa23f666b8d7e49dcfdf580ee0055b68e4cf246e7d18907b5fb89003f74"
624 },
625 {
626 "path": "react/hooks/useConnectionStatus.ts",
627 "package": "react",
628 "sha256": "8b78a34a662bc129919e0a77ed845ff5d66aa5faafb6f8a448b4c66c1c6ca1b5",
629 "upstreamSha256": "8b78a34a662bc129919e0a77ed845ff5d66aa5faafb6f8a448b4c66c1c6ca1b5"
630 },
631 {
632 "path": "react/hooks/useDataChannel.ts",
633 "package": "react",
634 "sha256": "28e88ed5e971634726c202e0397aa8156e1da8d7445c579a10ef4be6f6c57365",
635 "upstreamSha256": "28e88ed5e971634726c202e0397aa8156e1da8d7445c579a10ef4be6f6c57365"
636 },
637 {
638 "path": "react/hooks/useDisconnectButton.ts",
639 "package": "react",
640 "sha256": "0969fe0327ce99f405e5f76a6326dcf0c3ddf3bfc0385cad05ff275160ab0da3",
641 "upstreamSha256": "0969fe0327ce99f405e5f76a6326dcf0c3ddf3bfc0385cad05ff275160ab0da3"
642 },
643 {
644 "path": "react/hooks/useEvents.ts",
645 "package": "react",
646 "sha256": "6c8b9e956b0d46671167d2ff76e8a4e5c1fbff0dad7dec6781ceaef127be6f8a",
647 "upstreamSha256": "6ec1a9703231b1b39061c81435f890d1ab93776c8d241c1387fb72f600c384ec"
648 },
649 {
650 "path": "react/hooks/useFacingMode.ts",
651 "package": "react",
652 "sha256": "11c4670b0846570153490808244d627955737b08b75474d49ce2d5824ab48548",
653 "upstreamSha256": "11c4670b0846570153490808244d627955737b08b75474d49ce2d5824ab48548"
654 },
655 {
656 "path": "react/hooks/useFocusToggle.ts",
657 "package": "react",
658 "sha256": "0c52a4ae7c2f6b0e5ac769fa33c4b206b2863bb90419214c7e7fb5d5013b2bf1",
659 "upstreamSha256": "0c52a4ae7c2f6b0e5ac769fa33c4b206b2863bb90419214c7e7fb5d5013b2bf1"
660 },
661 {
662 "path": "react/hooks/useGridLayout.ts",
663 "package": "react",
664 "sha256": "414f9b9b5b3d66c5bf92707845808d6aa927f7356ea6e101e80f479fb738e0e6",
665 "upstreamSha256": "41fe485e1e2ba912f03be117cd21c7e873aa1a3de9999a0e862e09c3e573d523"
666 },
667 {
668 "path": "react/hooks/useIsEncrypted.ts",
669 "package": "react",
670 "sha256": "c240b54598770a218d0eb106926d75b73cf87e864621f73305969e3286d9841b",
671 "upstreamSha256": "c240b54598770a218d0eb106926d75b73cf87e864621f73305969e3286d9841b"
672 },
673 {
674 "path": "react/hooks/useIsMuted.ts",
675 "package": "react",
676 "sha256": "a8fa40d0a13a954d0e0066c98ce451dafee51adb30b27ffae7cc29d8157832f8",
677 "upstreamSha256": "a8fa40d0a13a954d0e0066c98ce451dafee51adb30b27ffae7cc29d8157832f8"
678 },
679 {
680 "path": "react/hooks/useIsRecording.ts",
681 "package": "react",
682 "sha256": "8ff5016d5b52fea2afaa028c82a2a4417c923aac5b31f4a7cb2bf900c176e560",
683 "upstreamSha256": "8ff5016d5b52fea2afaa028c82a2a4417c923aac5b31f4a7cb2bf900c176e560"
684 },
685 {
686 "path": "react/hooks/useIsSpeaking.ts",
687 "package": "react",
688 "sha256": "550ea0352b8147419f32c00aaf66d0fb99d4c67a3a6b85f49a252500542fc425",
689 "upstreamSha256": "550ea0352b8147419f32c00aaf66d0fb99d4c67a3a6b85f49a252500542fc425"
690 },
691 {
692 "path": "react/hooks/useLiveKitRoom.ts",
693 "package": "react",
694 "sha256": "ed157ab34cb8dfaa25870ec24c116967a7d77e085ab4e5800ae815ff72a361b0",
695 "upstreamSha256": "ed157ab34cb8dfaa25870ec24c116967a7d77e085ab4e5800ae815ff72a361b0"
696 },
697 {
698 "path": "react/hooks/useLocalParticipant.ts",
699 "package": "react",
700 "sha256": "74225abfdf0db4e092a44c72861152c4431aa761fb7c6b35217d0d1ded94da1c",
701 "upstreamSha256": "74225abfdf0db4e092a44c72861152c4431aa761fb7c6b35217d0d1ded94da1c"
702 },
703 {
704 "path": "react/hooks/useLocalParticipantPermissions.ts",
705 "package": "react",
706 "sha256": "597ef376c536ff17cee41b4a10407bfdae3f880e721c917ef445ab410757b266",
707 "upstreamSha256": "597ef376c536ff17cee41b4a10407bfdae3f880e721c917ef445ab410757b266"
708 },
709 {
710 "path": "react/hooks/useMediaDevices.ts",
711 "package": "react",
712 "sha256": "5cd9079c102d75eaf8d77fc6151fcb565db5d9831abe5a7992c37d1e90ba0cb6",
713 "upstreamSha256": "5cd9079c102d75eaf8d77fc6151fcb565db5d9831abe5a7992c37d1e90ba0cb6"
714 },
715 {
716 "path": "react/hooks/useMediaDeviceSelect.ts",
717 "package": "react",
718 "sha256": "5d3c0dd6215de2b53197defb6c8e10130e13aa22477611b702aa274c5ba8ed57",
719 "upstreamSha256": "5d3c0dd6215de2b53197defb6c8e10130e13aa22477611b702aa274c5ba8ed57"
720 },
721 {
722 "path": "react/hooks/useMediaTrackBySourceOrName.ts",
723 "package": "react",
724 "sha256": "dba42075d2d3fe785fe190bb49c2359f5379d609d03e198ac7337acb34ae09b3",
725 "upstreamSha256": "be8f17daa60881a5bfac801c61a562d49875f3f1107aa986b899fb079805adbf"
726 },
727 {
728 "path": "react/hooks/usePagination.ts",
729 "package": "react",
730 "sha256": "0ce86759feda9f52aad9d17a8ea2990b72a242ca6d4aead91a5ca5f7b464ccae",
731 "upstreamSha256": "0ce86759feda9f52aad9d17a8ea2990b72a242ca6d4aead91a5ca5f7b464ccae"
732 },
733 {
734 "path": "react/hooks/useParticipantAttributes.ts",
735 "package": "react",
736 "sha256": "f64914fd699ec1685b647072a9835fd94c6fec88d69fc20472481c2052217e51",
737 "upstreamSha256": "86e45c01e1a42b7ab2620ec013555c1044929e09f6925cb343fa0940292936d9"
738 },
739 {
740 "path": "react/hooks/useParticipantInfo.ts",
741 "package": "react",
742 "sha256": "92d97513ba360bf9c03731a6976703fecc66648fbb082f8ffbd4e5f8e16beef7",
743 "upstreamSha256": "92d97513ba360bf9c03731a6976703fecc66648fbb082f8ffbd4e5f8e16beef7"
744 },
745 {
746 "path": "react/hooks/useParticipantPermissions.ts",
747 "package": "react",
748 "sha256": "851c26bf3c4884a57fd76f9b7b5780ec694713bb31bc49af929dd4319402f894",
749 "upstreamSha256": "851c26bf3c4884a57fd76f9b7b5780ec694713bb31bc49af929dd4319402f894"
750 },
751 {
752 "path": "react/hooks/useParticipants.ts",
753 "package": "react",
754 "sha256": "dfac27889529af2284029be29c35efa569553ed3502675b2df9a4db58ec11cc6",
755 "upstreamSha256": "dfac27889529af2284029be29c35efa569553ed3502675b2df9a4db58ec11cc6"
756 },
757 {
758 "path": "react/hooks/useParticipantTile.ts",
759 "package": "react",
760 "sha256": "f54ede76afbc1d66d8034172b59b74885efa878aa0ab1e76253bf1347e25dbf4",
761 "upstreamSha256": "f54ede76afbc1d66d8034172b59b74885efa878aa0ab1e76253bf1347e25dbf4"
762 },
763 {
764 "path": "react/hooks/useParticipantTracks.ts",
765 "package": "react",
766 "sha256": "e472baf277fd2c1f87c3b808ba69b163d9026e93f691eb7469c95f519d9b8aa2",
767 "upstreamSha256": "e472baf277fd2c1f87c3b808ba69b163d9026e93f691eb7469c95f519d9b8aa2"
768 },
769 {
770 "path": "react/hooks/usePersistentUserChoices.ts",
771 "package": "react",
772 "sha256": "0569507430e555efd0e4b7028ed87cc3777b5d7c48f8d603793227caa8e9b592",
773 "upstreamSha256": "0569507430e555efd0e4b7028ed87cc3777b5d7c48f8d603793227caa8e9b592"
774 },
775 {
776 "path": "react/hooks/usePinnedTracks.ts",
777 "package": "react",
778 "sha256": "2cdd16d17e42af7d718af332c316674cacef2568293dd4e80e36f9b8e6fc0638",
779 "upstreamSha256": "2cdd16d17e42af7d718af332c316674cacef2568293dd4e80e36f9b8e6fc0638"
780 },
781 {
782 "path": "react/hooks/useRemoteParticipant.ts",
783 "package": "react",
784 "sha256": "f95dab7f055a9c4699f4d9c52c610f9a7fd21cf4204ebf92a7eb249930816420",
785 "upstreamSha256": "f95dab7f055a9c4699f4d9c52c610f9a7fd21cf4204ebf92a7eb249930816420"
786 },
787 {
788 "path": "react/hooks/useRemoteParticipants.ts",
789 "package": "react",
790 "sha256": "2b9abea6779097cf12c4266ce09d001e3fd3cc55f3fa8e16351c1fa6e2e228e1",
791 "upstreamSha256": "2b9abea6779097cf12c4266ce09d001e3fd3cc55f3fa8e16351c1fa6e2e228e1"
792 },
793 {
794 "path": "react/hooks/useRoomInfo.ts",
795 "package": "react",
796 "sha256": "060f01f9151f8f3c25269df32c21c693a33bd2159e94eb108c92d576f7dec706",
797 "upstreamSha256": "060f01f9151f8f3c25269df32c21c693a33bd2159e94eb108c92d576f7dec706"
798 },
799 {
800 "path": "react/hooks/useRpc.ts",
801 "package": "react",
802 "sha256": "bf79c7763b41e1b9f4c7a99bec7ac8411806e95326932e2c2a364183811ca657",
803 "upstreamSha256": "bf79c7763b41e1b9f4c7a99bec7ac8411806e95326932e2c2a364183811ca657"
804 },
805 {
806 "path": "react/hooks/useSequentialRoomConnectDisconnect.ts",
807 "package": "react",
808 "sha256": "55c03635b237507bbe9fd2647905f25815792b158ea3d3db7475bb29d31a6c53",
809 "upstreamSha256": "55c03635b237507bbe9fd2647905f25815792b158ea3d3db7475bb29d31a6c53"
810 },
811 {
812 "path": "react/hooks/useSession.ts",
813 "package": "react",
814 "sha256": "b677c02ede184826201c7e08046ffdb3e0e37fdcf670748c4db564df673bb922",
815 "upstreamSha256": "a692925f2461ef69d1632e4d7b68404cdbc2293a562bceb2ef187fdcd7840e0c"
816 },
817 {
818 "path": "react/hooks/useSessionMessages.ts",
819 "package": "react",
820 "sha256": "298551c38a24829ccf747172d6a4f75bf12a418b532312764e4b08ca24008557",
821 "upstreamSha256": "f0023b878fe38a7db9c50b811ab1881983b7682b01743f5204060b636f50fe7b"
822 },
823 {
824 "path": "react/hooks/useSettingsToggle.ts",
825 "package": "react",
826 "sha256": "6076fb5b92a17acdf16577547e10c7d46238e3573a9c752a8bfd13d9f373d019",
827 "upstreamSha256": "6076fb5b92a17acdf16577547e10c7d46238e3573a9c752a8bfd13d9f373d019"
828 },
829 {
830 "path": "react/hooks/useSortedParticipants.ts",
831 "package": "react",
832 "sha256": "6fad566ab9cf41b55629cfaa8581cdf2c65af200069993e04c2d9c6bf8fa078e",
833 "upstreamSha256": "6fad566ab9cf41b55629cfaa8581cdf2c65af200069993e04c2d9c6bf8fa078e"
834 },
835 {
836 "path": "react/hooks/useSpeakingParticipants.ts",
837 "package": "react",
838 "sha256": "0666364d3d12ed5631f5f6a9fc40f56a648c37d53bb42206407d3a71c579dc49",
839 "upstreamSha256": "0666364d3d12ed5631f5f6a9fc40f56a648c37d53bb42206407d3a71c579dc49"
840 },
841 {
842 "path": "react/hooks/useStartAudio.ts",
843 "package": "react",
844 "sha256": "405b1cc72d8fd0d8b9b4009a64ff93100a223788fabdf258c983c5d12c52c2c7",
845 "upstreamSha256": "405b1cc72d8fd0d8b9b4009a64ff93100a223788fabdf258c983c5d12c52c2c7"
846 },
847 {
848 "path": "react/hooks/useStartVideo.ts",
849 "package": "react",
850 "sha256": "b60f0c43a1be3168852c893b4c1ab10863dbf2cf152dbc3d91ddc3c1f85b1086",
851 "upstreamSha256": "b60f0c43a1be3168852c893b4c1ab10863dbf2cf152dbc3d91ddc3c1f85b1086"
852 },
853 {
854 "path": "react/hooks/useSwipe.ts",
855 "package": "react",
856 "sha256": "dcc3589a4e3f6e702261d71406fc183fb996cc63701f5c3664e902adc0cc0596",
857 "upstreamSha256": "bcf480340bbfaa7d79a4fb4b1924c25bb63c21b2de3ccf6d2ea4579f5cd6aeb7"
858 },
859 {
860 "path": "react/hooks/useTextStream.ts",
861 "package": "react",
862 "sha256": "e9cb326f2b1ab52b0422914adf618d2095409cb3636b3bf49f6a3872e87b602c",
863 "upstreamSha256": "e9cb326f2b1ab52b0422914adf618d2095409cb3636b3bf49f6a3872e87b602c"
864 },
865 {
866 "path": "react/hooks/useToken.ts",
867 "package": "react",
868 "sha256": "2cf8ec4cea58c46ed81c9fd0474970e11a342fbbc4799bff3436a0fd954165a8",
869 "upstreamSha256": "2cf8ec4cea58c46ed81c9fd0474970e11a342fbbc4799bff3436a0fd954165a8"
870 },
871 {
872 "path": "react/hooks/useTrack.ts",
873 "package": "react",
874 "sha256": "8d3185eecf923091350c4d3d9a72152c77f03f6f697630dd3c6b29b8a710ab8e",
875 "upstreamSha256": "8d3185eecf923091350c4d3d9a72152c77f03f6f697630dd3c6b29b8a710ab8e"
876 },
877 {
878 "path": "react/hooks/useTrackByName.ts",
879 "package": "react",
880 "sha256": "87f038d8f2123d7e41005d959a62c0ad0611fedd211d6f17aeaef6ebf2d40c03",
881 "upstreamSha256": "87f038d8f2123d7e41005d959a62c0ad0611fedd211d6f17aeaef6ebf2d40c03"
882 },
883 {
884 "path": "react/hooks/useTrackMutedIndicator.ts",
885 "package": "react",
886 "sha256": "cca68016e34a10c4a57c27aaf03b69bf56634535a7dfc5a352d84ae213766ec0",
887 "upstreamSha256": "cca68016e34a10c4a57c27aaf03b69bf56634535a7dfc5a352d84ae213766ec0"
888 },
889 {
890 "path": "react/hooks/useTrackRefBySourceOrName.ts",
891 "package": "react",
892 "sha256": "6e09d15c4cb5e2102c11fbc725433d345cae6e402b2ae78dc0bdd5ec765e4364",
893 "upstreamSha256": "6e09d15c4cb5e2102c11fbc725433d345cae6e402b2ae78dc0bdd5ec765e4364"
894 },
895 {
896 "path": "react/hooks/useTracks.ts",
897 "package": "react",
898 "sha256": "a8f963faa3edf98bec886569fcfe43dbd91786409cb89688de0e4cd925f151bd",
899 "upstreamSha256": "a8f963faa3edf98bec886569fcfe43dbd91786409cb89688de0e4cd925f151bd"
900 },
901 {
902 "path": "react/hooks/useTrackSyncTime.ts",
903 "package": "react",
904 "sha256": "e41a5052dd1dc015c03420c20a26c4e4e6c2ee052f4faf29ebe0f5657d7c0f4c",
905 "upstreamSha256": "e41a5052dd1dc015c03420c20a26c4e4e6c2ee052f4faf29ebe0f5657d7c0f4c"
906 },
907 {
908 "path": "react/hooks/useTrackToggle.ts",
909 "package": "react",
910 "sha256": "bce12b496d3be738ee71f42590947270abf850748d71841afd8992217ebe2a08",
911 "upstreamSha256": "bce12b496d3be738ee71f42590947270abf850748d71841afd8992217ebe2a08"
912 },
913 {
914 "path": "react/hooks/useTrackTranscription.ts",
915 "package": "react",
916 "sha256": "26e5f8870c9c242b075ea75281af568c2d53a09db1596de2a5a030bfeb610589",
917 "upstreamSha256": "26e5f8870c9c242b075ea75281af568c2d53a09db1596de2a5a030bfeb610589"
918 },
919 {
920 "path": "react/hooks/useTrackVolume.ts",
921 "package": "react",
922 "sha256": "53d26b85a6538108949c2419e4ebab7f7d4e0bec16b3fa1b0198680563f29d24",
923 "upstreamSha256": "4b895ff01fa44928bddb583e16552d72065603afe5e52c57590d59cf0c096507"
924 },
925 {
926 "path": "react/hooks/useTranscriptions.ts",
927 "package": "react",
928 "sha256": "faba43b94bec4b42af16708e2d9ce5fc5f7ba9a4564f18befbb1a2c703cb90fa",
929 "upstreamSha256": "faba43b94bec4b42af16708e2d9ce5fc5f7ba9a4564f18befbb1a2c703cb90fa"
930 },
931 {
932 "path": "react/hooks/useVisualStableUpdate.ts",
933 "package": "react",
934 "sha256": "dc73bc300f4a9a8bf1fefa5070e0cf1539bac6755208cb80f40018bc06b31625",
935 "upstreamSha256": "dc73bc300f4a9a8bf1fefa5070e0cf1539bac6755208cb80f40018bc06b31625"
936 },
937 {
938 "path": "react/hooks/useVoiceAssistant.ts",
939 "package": "react",
940 "sha256": "c604aafa7158f576fac27dbc9cf4d02207ca00ad33ca3f4473d1cca38b9d39e1",
941 "upstreamSha256": "8820db1896f31857e77cda7416ef1893a250be448dbbd2c632e1a98ad7dbe966"
942 },
943 {
944 "path": "react/hooks/useWarnAboutMissingStyles.ts",
945 "package": "react",
946 "sha256": "c366cf879252455c171ef9e19a2abfb5189178047a1c95f020c2b080c085bdfc",
947 "upstreamSha256": "c366cf879252455c171ef9e19a2abfb5189178047a1c95f020c2b080c085bdfc"
948 },
949 {
950 "path": "react/index.ts",
951 "package": "react",
952 "sha256": "3ca8ccf37a00af7427add635a534a2f46557c16a911f43c2a1e453f12ff3bebf",
953 "upstreamSha256": "3ca8ccf37a00af7427add635a534a2f46557c16a911f43c2a1e453f12ff3bebf"
954 },
955 {
956 "path": "react/mergeProps.ts",
957 "package": "react",
958 "sha256": "dc4bb469ea53147c495eb91853e779e3106f69be0aeabeb0f416ff4c98b58296",
959 "upstreamSha256": "dc4bb469ea53147c495eb91853e779e3106f69be0aeabeb0f416ff4c98b58296"
960 },
961 {
962 "path": "react/prefabs/AudioConference.tsx",
963 "package": "react",
964 "sha256": "6d55eb3ef6a05bec97a957ee25e05edacabb52509f9139e5fcf30f59dac04fb0",
965 "upstreamSha256": "6d55eb3ef6a05bec97a957ee25e05edacabb52509f9139e5fcf30f59dac04fb0"
966 },
967 {
968 "path": "react/prefabs/Chat.tsx",
969 "package": "react",
970 "sha256": "48618ad7d6985f33e308b8de68f10056b3c05bc4bd702ee94cc1d084fdb8e614",
971 "upstreamSha256": "a4f3a153f81568901d20451d2d18c145c128c81bebaa218fc1876ef43b69c01f"
972 },
973 {
974 "path": "react/prefabs/ControlBar.tsx",
975 "package": "react",
976 "sha256": "22ba4b456c04dbdc2d55168ceadfb346d300187d32b26d7783288ca1ee98d355",
977 "upstreamSha256": "3c1163f531cdf8c87f2566dca2ffc8d03bc7f1bac67b59bd81ba7854df0ecbff"
978 },
979 {
980 "path": "react/prefabs/index.ts",
981 "package": "react",
982 "sha256": "dc15ee54bfbd1cd20a7957c0e1e7f7b7cbf7dd178456219e902c679e8e46567f",
983 "upstreamSha256": "dc15ee54bfbd1cd20a7957c0e1e7f7b7cbf7dd178456219e902c679e8e46567f"
984 },
985 {
986 "path": "react/prefabs/MediaDeviceMenu.tsx",
987 "package": "react",
988 "sha256": "c6b5efc4f16121ad7a75c4a9c87c9a267dab1f4d3100cb3de0afd84e33842ecc",
989 "upstreamSha256": "3fad66b350d6f0515da3afb3dcda153b7f858d16fa00472f477593ecac6ea72f"
990 },
991 {
992 "path": "react/prefabs/PreJoin.tsx",
993 "package": "react",
994 "sha256": "d49543030486a92e5ae99de4631d258f388839be4dd58665b596cbee6d99446d",
995 "upstreamSha256": "d49543030486a92e5ae99de4631d258f388839be4dd58665b596cbee6d99446d"
996 },
997 {
998 "path": "react/prefabs/VideoConference.tsx",
999 "package": "react",
1000 "sha256": "90379f249b38d72c78989958761450b30409fbc817fea02e5b35f61dbdaa52ed",
1001 "upstreamSha256": "90379f249b38d72c78989958761450b30409fbc817fea02e5b35f61dbdaa52ed"
1002 },
1003 {
1004 "path": "react/prefabs/VoiceAssistantControlBar.tsx",
1005 "package": "react",
1006 "sha256": "241cede1132b4b722d1f425f2fdf7e533209f31386bc350a3361d3accd194bb9",
1007 "upstreamSha256": "241cede1132b4b722d1f425f2fdf7e533209f31386bc350a3361d3accd194bb9"
1008 },
1009 {
1010 "path": "react/types/events.d.ts",
1011 "package": "react",
1012 "sha256": "23806f26a38d58baa243b3b9a3178873dbf069e19c57b11eabc1df6ff4db7496",
1013 "upstreamSha256": null
1014 },
1015 {
1016 "path": "react/utils.ts",
1017 "package": "react",
1018 "sha256": "f072be3cc8ccb45cf7ce6623a0df43c63d3e9fa07e221e4a40338c09774cfefc",
1019 "upstreamSha256": "1ad1dd12dcfcbdf0a05b5f128d05bd6589f8bdad02ffbcd92bfd9ce4505460cf"
1020 },
1021 {
1022 "path": "core/components/chat.ts",
1023 "package": "core",
1024 "sha256": "d6dcb62b554f309386aac8d575f8afb9bf9941a6d5742fa177a0b3d4199291bb",
1025 "upstreamSha256": "b8c00cfd4a8d6dd6d9edbf6e348bfdd972f60cb79c7d3bad31a00af31d5477a9"
1026 },
1027 {
1028 "path": "core/components/chatToggle.ts",
1029 "package": "core",
1030 "sha256": "26feedfa4564a974c4ef4866652377b84cec20c2b138fc6214f9919b43f6390b",
1031 "upstreamSha256": "26feedfa4564a974c4ef4866652377b84cec20c2b138fc6214f9919b43f6390b"
1032 },
1033 {
1034 "path": "core/components/clearPinButton.ts",
1035 "package": "core",
1036 "sha256": "b671d10726b43163b0ed2c0511d3d62dab34e61ee41679fb5e3b53e9048f4de3",
1037 "upstreamSha256": "b671d10726b43163b0ed2c0511d3d62dab34e61ee41679fb5e3b53e9048f4de3"
1038 },
1039 {
1040 "path": "core/components/connectionQualityIndicator.ts",
1041 "package": "core",
1042 "sha256": "efc1e4c9ab1fc606d04b85398d6eaf956eb2e406d26f0b8b706878970dad3fa3",
1043 "upstreamSha256": "efc1e4c9ab1fc606d04b85398d6eaf956eb2e406d26f0b8b706878970dad3fa3"
1044 },
1045 {
1046 "path": "core/components/disconnectButton.ts",
1047 "package": "core",
1048 "sha256": "efd0c31cbdc9f8ec820289e151d9839ee225f79afc3c7e99ab95b3bdb982a3eb",
1049 "upstreamSha256": "efd0c31cbdc9f8ec820289e151d9839ee225f79afc3c7e99ab95b3bdb982a3eb"
1050 },
1051 {
1052 "path": "core/components/focusToggle.ts",
1053 "package": "core",
1054 "sha256": "572adb54b1f21e85cedfcc5752f50079338e4edf57ba52d9d520836499b528f5",
1055 "upstreamSha256": "572adb54b1f21e85cedfcc5752f50079338e4edf57ba52d9d520836499b528f5"
1056 },
1057 {
1058 "path": "core/components/mediaDeviceSelect.ts",
1059 "package": "core",
1060 "sha256": "a634affdfd5fe2aaaae5f6bf301224ee2bb974e3abdc32d5fcd84c9109c3af7a",
1061 "upstreamSha256": "a634affdfd5fe2aaaae5f6bf301224ee2bb974e3abdc32d5fcd84c9109c3af7a"
1062 },
1063 {
1064 "path": "core/components/mediaToggle.ts",
1065 "package": "core",
1066 "sha256": "21067fca1e8f9f46df52639d0815f395f173a0bb1ba477be0e206f9feabe701b",
1067 "upstreamSha256": "21067fca1e8f9f46df52639d0815f395f173a0bb1ba477be0e206f9feabe701b"
1068 },
1069 {
1070 "path": "core/components/mediaTrack.ts",
1071 "package": "core",
1072 "sha256": "f6eed471013171b4e1e349e4236fcedab47566813f19b048cf7c90879c2415de",
1073 "upstreamSha256": "f6eed471013171b4e1e349e4236fcedab47566813f19b048cf7c90879c2415de"
1074 },
1075 {
1076 "path": "core/components/participantName.ts",
1077 "package": "core",
1078 "sha256": "e444a0e1d18a64361715963ffa3811f5ef6fb0c43981b90b9180e69d9c6877d8",
1079 "upstreamSha256": "e444a0e1d18a64361715963ffa3811f5ef6fb0c43981b90b9180e69d9c6877d8"
1080 },
1081 {
1082 "path": "core/components/participantTile.ts",
1083 "package": "core",
1084 "sha256": "8699eb1a46496a226239ff25431b475426a5e67027da33f2de456214ae2b4c6e",
1085 "upstreamSha256": "8699eb1a46496a226239ff25431b475426a5e67027da33f2de456214ae2b4c6e"
1086 },
1087 {
1088 "path": "core/components/room.ts",
1089 "package": "core",
1090 "sha256": "aff71b54472a7756b7a7fd5232e1f155acf01343dd133999f63fbdec02ccdfd3",
1091 "upstreamSha256": "aff71b54472a7756b7a7fd5232e1f155acf01343dd133999f63fbdec02ccdfd3"
1092 },
1093 {
1094 "path": "core/components/startAudio.ts",
1095 "package": "core",
1096 "sha256": "b6ac07bab1d2e6cf8d920e384d604bada79bf0bb5c67d3528661d31ceef66139",
1097 "upstreamSha256": "b6ac07bab1d2e6cf8d920e384d604bada79bf0bb5c67d3528661d31ceef66139"
1098 },
1099 {
1100 "path": "core/components/startVideo.ts",
1101 "package": "core",
1102 "sha256": "919ffdaf8523eac29eeaf85a94c11bfd8c7804e8cf9d26fcce310624bd7a6e3d",
1103 "upstreamSha256": "919ffdaf8523eac29eeaf85a94c11bfd8c7804e8cf9d26fcce310624bd7a6e3d"
1104 },
1105 {
1106 "path": "core/components/textStream.ts",
1107 "package": "core",
1108 "sha256": "ab3763aa3f6ac7d7d04f5510bb0229cc0998d86bc8f2df5fcb8545687301b70e",
1109 "upstreamSha256": "ab3763aa3f6ac7d7d04f5510bb0229cc0998d86bc8f2df5fcb8545687301b70e"
1110 },
1111 {
1112 "path": "core/components/trackMutedIndicator.ts",
1113 "package": "core",
1114 "sha256": "7f3b0d24d5f4994d0f4178316b2152d0d81c27bc655bd53bd354827f8f97904b",
1115 "upstreamSha256": "7f3b0d24d5f4994d0f4178316b2152d0d81c27bc655bd53bd354827f8f97904b"
1116 },
1117 {
1118 "path": "core/constants.ts",
1119 "package": "core",
1120 "sha256": "862982afb2e3d7ed1fd1d9632c5a169c877b159d5ab562107302e660bc42392d",
1121 "upstreamSha256": "862982afb2e3d7ed1fd1d9632c5a169c877b159d5ab562107302e660bc42392d"
1122 },
1123 {
1124 "path": "core/helper/array-helper.ts",
1125 "package": "core",
1126 "sha256": "92ef8c75d3c575cbe5b44bce87b290ea1cb857eb3ce3f3aed8f9834ed7c08212",
1127 "upstreamSha256": "92ef8c75d3c575cbe5b44bce87b290ea1cb857eb3ce3f3aed8f9834ed7c08212"
1128 },
1129 {
1130 "path": "core/helper/detectMobileBrowser.ts",
1131 "package": "core",
1132 "sha256": "e7142b11db3950fefdd11e62d693348e2ba5d957677e95f53442eb4530c2311d",
1133 "upstreamSha256": "e7142b11db3950fefdd11e62d693348e2ba5d957677e95f53442eb4530c2311d"
1134 },
1135 {
1136 "path": "core/helper/emailRegex.ts",
1137 "package": "core",
1138 "sha256": "b310862402dc95878f4f81802291b90f043f2b178d306b35c15b07c2f7afa7c6",
1139 "upstreamSha256": "b310862402dc95878f4f81802291b90f043f2b178d306b35c15b07c2f7afa7c6"
1140 },
1141 {
1142 "path": "core/helper/eventGroups.ts",
1143 "package": "core",
1144 "sha256": "eb0c22a85e370e97b0505ee855ed0781e5eb5bcadd215ced293db796cf187d66",
1145 "upstreamSha256": "eb0c22a85e370e97b0505ee855ed0781e5eb5bcadd215ced293db796cf187d66"
1146 },
1147 {
1148 "path": "core/helper/featureDetection.ts",
1149 "package": "core",
1150 "sha256": "1a1f34c5b306174d006081285d3b0b142b2fb3af895a5e2cdfc6508e21208bc9",
1151 "upstreamSha256": "1a1f34c5b306174d006081285d3b0b142b2fb3af895a5e2cdfc6508e21208bc9"
1152 },
1153 {
1154 "path": "core/helper/floating-menu.ts",
1155 "package": "core",
1156 "sha256": "153d0cd9b31088783b7093aa99b817401157f602bac37b8096d512fc95028cfd",
1157 "upstreamSha256": "153d0cd9b31088783b7093aa99b817401157f602bac37b8096d512fc95028cfd"
1158 },
1159 {
1160 "path": "core/helper/future.ts",
1161 "package": "core",
1162 "sha256": "4fad622da9230f47d8f22d06d51e00db832bade99eedc5abdd653969e96aa6e3",
1163 "upstreamSha256": "4fad622da9230f47d8f22d06d51e00db832bade99eedc5abdd653969e96aa6e3"
1164 },
1165 {
1166 "path": "core/helper/grid-layouts.ts",
1167 "package": "core",
1168 "sha256": "63ef9fb8d61402e1546d5a3baad4c18f8241f6404773ba28fbc39ba15e7b320f",
1169 "upstreamSha256": "63ef9fb8d61402e1546d5a3baad4c18f8241f6404773ba28fbc39ba15e7b320f"
1170 },
1171 {
1172 "path": "core/helper/index.ts",
1173 "package": "core",
1174 "sha256": "72d225d42290b6eb3d640a5cc242a3786bd3b4952dc51ad6d794a93554f25cad",
1175 "upstreamSha256": "72d225d42290b6eb3d640a5cc242a3786bd3b4952dc51ad6d794a93554f25cad"
1176 },
1177 {
1178 "path": "core/helper/participant-attributes.ts",
1179 "package": "core",
1180 "sha256": "b2c3efde34ec721672fb376a29ed0d2e5657364ed2c80eb4ee68fbc1a0e4a2d3",
1181 "upstreamSha256": "75c7787dff8ab78fc76bc5ab65c4c2db7a9cdb9bf4ad074f734f1a06f1eb5acc"
1182 },
1183 {
1184 "path": "core/helper/set-helper.ts",
1185 "package": "core",
1186 "sha256": "3cbc7ab5867e310c8206aceb3c24bc55c6ffb7eeb20be630273f735c509d33e6",
1187 "upstreamSha256": "3cbc7ab5867e310c8206aceb3c24bc55c6ffb7eeb20be630273f735c509d33e6"
1188 },
1189 {
1190 "path": "core/helper/tokenizer.ts",
1191 "package": "core",
1192 "sha256": "cafa4acd5a8d7113ac8d21c298d04782f41a0aeb442874069530f782b9f1004c",
1193 "upstreamSha256": "cafa4acd5a8d7113ac8d21c298d04782f41a0aeb442874069530f782b9f1004c"
1194 },
1195 {
1196 "path": "core/helper/transcriptions.ts",
1197 "package": "core",
1198 "sha256": "681981e2b0e81e8cdd0952468328711bc17d6a46a4129f7df373d9a340e02ce4",
1199 "upstreamSha256": "681981e2b0e81e8cdd0952468328711bc17d6a46a4129f7df373d9a340e02ce4"
1200 },
1201 {
1202 "path": "core/helper/url-regex.ts",
1203 "package": "core",
1204 "sha256": "a11b497cb24dbbe5b852abdfe4556fa8eb7a114554b399224e5cb2f7498bb980",
1205 "upstreamSha256": "a11b497cb24dbbe5b852abdfe4556fa8eb7a114554b399224e5cb2f7498bb980"
1206 },
1207 {
1208 "path": "core/index.ts",
1209 "package": "core",
1210 "sha256": "68cbf4985194a5138d5d30c374620dc81e2071043ca4ad104704cda0bf521695",
1211 "upstreamSha256": "68cbf4985194a5138d5d30c374620dc81e2071043ca4ad104704cda0bf521695"
1212 },
1213 {
1214 "path": "core/logger.ts",
1215 "package": "core",
1216 "sha256": "f6d8e7b62d6546a4959d9ff58ba569d43ac007d39adf2900134b05e008eba568",
1217 "upstreamSha256": "f6d8e7b62d6546a4959d9ff58ba569d43ac007d39adf2900134b05e008eba568"
1218 },
1219 {
1220 "path": "core/messages/index.ts",
1221 "package": "core",
1222 "sha256": "d5c19655468e29f60c871b21e73af8ebc653f736e7123ade916f22c4a5f80ce5",
1223 "upstreamSha256": "d5c19655468e29f60c871b21e73af8ebc653f736e7123ade916f22c4a5f80ce5"
1224 },
1225 {
1226 "path": "core/messages/types.ts",
1227 "package": "core",
1228 "sha256": "8441bd64fba8ccb9d24307d0866c2513f312541ad0a5e65c495d4bd1af43bad0",
1229 "upstreamSha256": "8441bd64fba8ccb9d24307d0866c2513f312541ad0a5e65c495d4bd1af43bad0"
1230 },
1231 {
1232 "path": "core/observables/dataChannel.ts",
1233 "package": "core",
1234 "sha256": "6be68c5222405ca3c7e85dc27ba4924d47e97fd2efe38a7ab7aec682df9405c9",
1235 "upstreamSha256": "a9a3d4095ed34d10363adba77dc6887527301948c95638b5b9115a86db253b55"
1236 },
1237 {
1238 "path": "core/observables/dom-event.ts",
1239 "package": "core",
1240 "sha256": "9d449feef519d78ef9ef2541853ba9d275283577cc7c360a5a304fb150a89ffa",
1241 "upstreamSha256": "9d449feef519d78ef9ef2541853ba9d275283577cc7c360a5a304fb150a89ffa"
1242 },
1243 {
1244 "path": "core/observables/participant.ts",
1245 "package": "core",
1246 "sha256": "b0d87029b89de3eefff2ddf3258a1d2e415259981103cd42813c354459aa0243",
1247 "upstreamSha256": "b0d87029b89de3eefff2ddf3258a1d2e415259981103cd42813c354459aa0243"
1248 },
1249 {
1250 "path": "core/observables/room.ts",
1251 "package": "core",
1252 "sha256": "41eba945704f654fbb1c5ea38f17c0e8a632ae2c6c061467237417d8e7560633",
1253 "upstreamSha256": "41eba945704f654fbb1c5ea38f17c0e8a632ae2c6c061467237417d8e7560633"
1254 },
1255 {
1256 "path": "core/observables/track.ts",
1257 "package": "core",
1258 "sha256": "a76cbf37579594df4a6dc5429e86a704421c7d9d9ae12e58b8a4d1bede2e7694",
1259 "upstreamSha256": "edade116d2ed2e64b41199352f601e4fdf897f502c8daf5973684bae8f473001"
1260 },
1261 {
1262 "path": "core/persistent-storage/index.ts",
1263 "package": "core",
1264 "sha256": "9c8cc8e7e95d684ba063867d658a785a205e6d428006fc31ccdef7855a0be883",
1265 "upstreamSha256": "9c8cc8e7e95d684ba063867d658a785a205e6d428006fc31ccdef7855a0be883"
1266 },
1267 {
1268 "path": "core/persistent-storage/local-storage-helpers.ts",
1269 "package": "core",
1270 "sha256": "b8d1d84f215099076e189c43d5c34b1e169cf69193d57f3fd9cad1781ebf46a6",
1271 "upstreamSha256": "b8d1d84f215099076e189c43d5c34b1e169cf69193d57f3fd9cad1781ebf46a6"
1272 },
1273 {
1274 "path": "core/persistent-storage/user-choices.ts",
1275 "package": "core",
1276 "sha256": "63411b1d8977d1f9cece56a2271e0893ed8aeaf9362012d65efe58aa1ccae50a",
1277 "upstreamSha256": "63411b1d8977d1f9cece56a2271e0893ed8aeaf9362012d65efe58aa1ccae50a"
1278 },
1279 {
1280 "path": "core/sorting/base-sort-functions.ts",
1281 "package": "core",
1282 "sha256": "89910779bfd000f2111d906e37edf4af30a95549e45a0e4bdf47b5af86d290e9",
1283 "upstreamSha256": "89910779bfd000f2111d906e37edf4af30a95549e45a0e4bdf47b5af86d290e9"
1284 },
1285 {
1286 "path": "core/sorting/index.ts",
1287 "package": "core",
1288 "sha256": "c2ce950d7b5c80c42261bb7487415fa91db565eeceb9b397fa0b92cbf9db2635",
1289 "upstreamSha256": "c2ce950d7b5c80c42261bb7487415fa91db565eeceb9b397fa0b92cbf9db2635"
1290 },
1291 {
1292 "path": "core/sorting/sort-participants.ts",
1293 "package": "core",
1294 "sha256": "8b570135732d9a3ba97124724c1a9862825a2e18a666a053834039b42b81d08a",
1295 "upstreamSha256": "8b570135732d9a3ba97124724c1a9862825a2e18a666a053834039b42b81d08a"
1296 },
1297 {
1298 "path": "core/sorting/sort-track-bundles.ts",
1299 "package": "core",
1300 "sha256": "b5a7a4550dd4f6a272cd97bd1816f028341801181abf08ab605191d2f19aea57",
1301 "upstreamSha256": "b5a7a4550dd4f6a272cd97bd1816f028341801181abf08ab605191d2f19aea57"
1302 },
1303 {
1304 "path": "core/sorting/tile-array-update.ts",
1305 "package": "core",
1306 "sha256": "86e76c792edb0a03b136d7ac5aaef4769633bac882140c809dfb537d55d1d7b5",
1307 "upstreamSha256": "86e76c792edb0a03b136d7ac5aaef4769633bac882140c809dfb537d55d1d7b5"
1308 },
1309 {
1310 "path": "core/styles-interface/class-prefixer.ts",
1311 "package": "core",
1312 "sha256": "42e46a01e59f0134c1b5e30ae754bb24dd5156c5338ab5e5d8ab8ebd660a6ed3",
1313 "upstreamSha256": "42e46a01e59f0134c1b5e30ae754bb24dd5156c5338ab5e5d8ab8ebd660a6ed3"
1314 },
1315 {
1316 "path": "core/styles-interface/index.ts",
1317 "package": "core",
1318 "sha256": "ee2c996a5e7d0f1802cc23adf78cc5c99464cb02e11f834856d542e8b26b84ac",
1319 "upstreamSha256": "ee2c996a5e7d0f1802cc23adf78cc5c99464cb02e11f834856d542e8b26b84ac"
1320 },
1321 {
1322 "path": "core/track-reference/index.ts",
1323 "package": "core",
1324 "sha256": "cc19642bf5c06428dff00f154ced52577a6979717944550bedb5ac213e12100b",
1325 "upstreamSha256": "cc19642bf5c06428dff00f154ced52577a6979717944550bedb5ac213e12100b"
1326 },
1327 {
1328 "path": "core/track-reference/test-utils.ts",
1329 "package": "core",
1330 "sha256": "f84a50863a0108b515957162f6312a91e9227144aca0476f8face7112ac5e485",
1331 "upstreamSha256": "f84a50863a0108b515957162f6312a91e9227144aca0476f8face7112ac5e485"
1332 },
1333 {
1334 "path": "core/track-reference/track-reference.types.ts",
1335 "package": "core",
1336 "sha256": "bdc4a5b4309fbb3346d1b57837833c6c683f257a1916fb42aa45a99a5ebd8ef4",
1337 "upstreamSha256": "bdc4a5b4309fbb3346d1b57837833c6c683f257a1916fb42aa45a99a5ebd8ef4"
1338 },
1339 {
1340 "path": "core/track-reference/track-reference.utils.ts",
1341 "package": "core",
1342 "sha256": "f85e144be3adc86241bdf7febd27fd1ee13480b7325270b309fe457a13cfa1c9",
1343 "upstreamSha256": "f85e144be3adc86241bdf7febd27fd1ee13480b7325270b309fe457a13cfa1c9"
1344 },
1345 {
1346 "path": "core/types.ts",
1347 "package": "core",
1348 "sha256": "8f78902ff9b189bdc61e74bd69a7b23c4d475a466030fe436bb1c34e15aba0f0",
1349 "upstreamSha256": "8f78902ff9b189bdc61e74bd69a7b23c4d475a466030fe436bb1c34e15aba0f0"
1350 },
1351 {
1352 "path": "core/utils.ts",
1353 "package": "core",
1354 "sha256": "a8cfb4628068579687fde6784ef2ff9125c7b4707bc2f42b6be3d027fcd7c377",
1355 "upstreamSha256": "a8cfb4628068579687fde6784ef2ff9125c7b4707bc2f42b6be3d027fcd7c377"
1356 },
1357 {
1358 "path": "styles/dist/general/components/controls/index.css",
1359 "package": "styles",
1360 "sha256": "9ec4eff861c1fc1cabd34273dfa829d546e1a698391940c6541d388ef9cd0256",
1361 "upstreamSha256": "9ec4eff861c1fc1cabd34273dfa829d546e1a698391940c6541d388ef9cd0256"
1362 },
1363 {
1364 "path": "styles/dist/general/components/controls/index.css.map",
1365 "package": "styles",
1366 "sha256": "80b678ce631891212d6997aad4fc9b2a672c9c06a87b6650530c9afeef6b517e",
1367 "upstreamSha256": "80b678ce631891212d6997aad4fc9b2a672c9c06a87b6650530c9afeef6b517e"
1368 },
1369 {
1370 "path": "styles/dist/general/components/index.css",
1371 "package": "styles",
1372 "sha256": "7fa20007efa53072a9d8c1e2a083f5dc860b2f4c8dcfbdce643d4eee60125884",
1373 "upstreamSha256": "7fa20007efa53072a9d8c1e2a083f5dc860b2f4c8dcfbdce643d4eee60125884"
1374 },
1375 {
1376 "path": "styles/dist/general/components/index.css.map",
1377 "package": "styles",
1378 "sha256": "aa8ba4246e945d8ff7fde9fda5fb2f93141f360feef8a3bf75fc2147339f6d33",
1379 "upstreamSha256": "aa8ba4246e945d8ff7fde9fda5fb2f93141f360feef8a3bf75fc2147339f6d33"
1380 },
1381 {
1382 "path": "styles/dist/general/components/layout/index.css",
1383 "package": "styles",
1384 "sha256": "af7bd51c338eb20d2d4b59b71eaf71de89579cd4f4e887d3c15cd73af788e7ec",
1385 "upstreamSha256": "af7bd51c338eb20d2d4b59b71eaf71de89579cd4f4e887d3c15cd73af788e7ec"
1386 },
1387 {
1388 "path": "styles/dist/general/components/layout/index.css.map",
1389 "package": "styles",
1390 "sha256": "b094be7deaafe6d7fb4ce98802e7ece7240d54b2a0558942cd474dd86a0d48a8",
1391 "upstreamSha256": "b094be7deaafe6d7fb4ce98802e7ece7240d54b2a0558942cd474dd86a0d48a8"
1392 },
1393 {
1394 "path": "styles/dist/general/components/participant/index.css",
1395 "package": "styles",
1396 "sha256": "defd5cec4db51231bb7c81b1e8807e9c7eb1e367b157cf425456e8a5910ed257",
1397 "upstreamSha256": "defd5cec4db51231bb7c81b1e8807e9c7eb1e367b157cf425456e8a5910ed257"
1398 },
1399 {
1400 "path": "styles/dist/general/components/participant/index.css.map",
1401 "package": "styles",
1402 "sha256": "ea0439bac73919995f62ad415ce05fc99517f47eb3d4f18a6df4298fc22cd528",
1403 "upstreamSha256": "ea0439bac73919995f62ad415ce05fc99517f47eb3d4f18a6df4298fc22cd528"
1404 },
1405 {
1406 "path": "styles/dist/general/index.css",
1407 "package": "styles",
1408 "sha256": "9171d6d70be284f78d880a2f6143feea505394d17c721ea512a3893d659f71b9",
1409 "upstreamSha256": "9171d6d70be284f78d880a2f6143feea505394d17c721ea512a3893d659f71b9"
1410 },
1411 {
1412 "path": "styles/dist/general/index.css.map",
1413 "package": "styles",
1414 "sha256": "f6e2dd82e94ce2ec36ffdb6967dd92c21417b49f1eeb930a0213eaed9455db93",
1415 "upstreamSha256": "f6e2dd82e94ce2ec36ffdb6967dd92c21417b49f1eeb930a0213eaed9455db93"
1416 },
1417 {
1418 "path": "styles/dist/general/prefabs/audio-conference.css",
1419 "package": "styles",
1420 "sha256": "9b2997f45e7d346b4fbbf9ea08c07af2fc57c036e88b31660df54633b404c2d6",
1421 "upstreamSha256": "9b2997f45e7d346b4fbbf9ea08c07af2fc57c036e88b31660df54633b404c2d6"
1422 },
1423 {
1424 "path": "styles/dist/general/prefabs/audio-conference.css.map",
1425 "package": "styles",
1426 "sha256": "3b1d6b9c308714ff32b19b493c4033968ee7b7fe6c7572118ddc0b20e2aa966d",
1427 "upstreamSha256": "3b1d6b9c308714ff32b19b493c4033968ee7b7fe6c7572118ddc0b20e2aa966d"
1428 },
1429 {
1430 "path": "styles/dist/general/prefabs/chat.css",
1431 "package": "styles",
1432 "sha256": "a83f833bf4e528c33a5c08dc7f4dc182dc4cc8865c91418d009709c24512aa4a",
1433 "upstreamSha256": "a83f833bf4e528c33a5c08dc7f4dc182dc4cc8865c91418d009709c24512aa4a"
1434 },
1435 {
1436 "path": "styles/dist/general/prefabs/chat.css.map",
1437 "package": "styles",
1438 "sha256": "1584c2257adea7a8de3c220820a523fbd4bbbb6cc6fb23fc4cd06fbfdb910208",
1439 "upstreamSha256": "1584c2257adea7a8de3c220820a523fbd4bbbb6cc6fb23fc4cd06fbfdb910208"
1440 },
1441 {
1442 "path": "styles/dist/general/prefabs/control-bar.css",
1443 "package": "styles",
1444 "sha256": "ab21a0273c97bcaa88e90d36d388ba497951c2ddb785f29c5242336291b7f103",
1445 "upstreamSha256": "ab21a0273c97bcaa88e90d36d388ba497951c2ddb785f29c5242336291b7f103"
1446 },
1447 {
1448 "path": "styles/dist/general/prefabs/control-bar.css.map",
1449 "package": "styles",
1450 "sha256": "3101011e974081aae7e91b295f537c2cd4b1df7992fc30c5a5b5156a193942af",
1451 "upstreamSha256": "3101011e974081aae7e91b295f537c2cd4b1df7992fc30c5a5b5156a193942af"
1452 },
1453 {
1454 "path": "styles/dist/general/prefabs/index.css",
1455 "package": "styles",
1456 "sha256": "78ee7c6a6d9bb6ab8e0bc9fe8d8b522beef648016b5b219dd82325791f14d26b",
1457 "upstreamSha256": "78ee7c6a6d9bb6ab8e0bc9fe8d8b522beef648016b5b219dd82325791f14d26b"
1458 },
1459 {
1460 "path": "styles/dist/general/prefabs/index.css.map",
1461 "package": "styles",
1462 "sha256": "7b3da2f792929ec9835ca55d0c3560767db805ce199bf900dd21997da9558936",
1463 "upstreamSha256": "7b3da2f792929ec9835ca55d0c3560767db805ce199bf900dd21997da9558936"
1464 },
1465 {
1466 "path": "styles/dist/general/prefabs/prejoin.css",
1467 "package": "styles",
1468 "sha256": "f2e8a3298db2184cd54a6e8c2515691a8cb3dc6c21ce41aa1caf0fae94def731",
1469 "upstreamSha256": "f2e8a3298db2184cd54a6e8c2515691a8cb3dc6c21ce41aa1caf0fae94def731"
1470 },
1471 {
1472 "path": "styles/dist/general/prefabs/prejoin.css.map",
1473 "package": "styles",
1474 "sha256": "86634a1f44dde63a697b08ba484b3ce1daaa8cadb1d3a56740ed5982999592d1",
1475 "upstreamSha256": "86634a1f44dde63a697b08ba484b3ce1daaa8cadb1d3a56740ed5982999592d1"
1476 },
1477 {
1478 "path": "styles/dist/general/prefabs/video-conference.css",
1479 "package": "styles",
1480 "sha256": "6edc6fd56043c6a61ed5ada1585a4731901f93c59d476b2aa9c0bf67654aebe8",
1481 "upstreamSha256": "6edc6fd56043c6a61ed5ada1585a4731901f93c59d476b2aa9c0bf67654aebe8"
1482 },
1483 {
1484 "path": "styles/dist/general/prefabs/video-conference.css.map",
1485 "package": "styles",
1486 "sha256": "6341406a07635cbd1b829c86c9420b94307b8e009e8b69d4fbf3735986f00d8f",
1487 "upstreamSha256": "6341406a07635cbd1b829c86c9420b94307b8e009e8b69d4fbf3735986f00d8f"
1488 },
1489 {
1490 "path": "styles/dist/general/themes/default.css",
1491 "package": "styles",
1492 "sha256": "f24058bfb8131bd7dc7be5b1f1c1329c94c048e067af82b3a922acbe0eb4e033",
1493 "upstreamSha256": "f24058bfb8131bd7dc7be5b1f1c1329c94c048e067af82b3a922acbe0eb4e033"
1494 },
1495 {
1496 "path": "styles/dist/general/themes/default.css.map",
1497 "package": "styles",
1498 "sha256": "94431b22fdf377c06b1703977f8d868ce793d4a05ba5ea825247210f770b139e",
1499 "upstreamSha256": "94431b22fdf377c06b1703977f8d868ce793d4a05ba5ea825247210f770b139e"
1500 },
1501 {
1502 "path": "styles/dist/general/themes/huddle.css",
1503 "package": "styles",
1504 "sha256": "7acba9a0b95f800f4a4e0d8ac7e329fecee1c2879b0de759234a8a3129a382df",
1505 "upstreamSha256": "7acba9a0b95f800f4a4e0d8ac7e329fecee1c2879b0de759234a8a3129a382df"
1506 },
1507 {
1508 "path": "styles/dist/general/themes/huddle.css.map",
1509 "package": "styles",
1510 "sha256": "e0aaf31220017f2e74eceb7308391203302da6038b18bf1807e937012c564ac2",
1511 "upstreamSha256": "e0aaf31220017f2e74eceb7308391203302da6038b18bf1807e937012c564ac2"
1512 },
1513 {
1514 "path": "styles/dist/types_unprefixed/components/controls/index.scss.d.ts",
1515 "package": "styles",
1516 "sha256": "133050d171aff635fb6b6d19487839accb8d6189fc338b3e1ea0c467967b8d5a",
1517 "upstreamSha256": "133050d171aff635fb6b6d19487839accb8d6189fc338b3e1ea0c467967b8d5a"
1518 },
1519 {
1520 "path": "styles/dist/types_unprefixed/components/controls/_button.scss.d.ts",
1521 "package": "styles",
1522 "sha256": "ebd3a434fef83d57453f098d40f6926cff193aff8fe390fa57fa0fe7ed50bd8c",
1523 "upstreamSha256": "ebd3a434fef83d57453f098d40f6926cff193aff8fe390fa57fa0fe7ed50bd8c"
1524 },
1525 {
1526 "path": "styles/dist/types_unprefixed/components/controls/_chat-toggle.scss.d.ts",
1527 "package": "styles",
1528 "sha256": "7c7f27b89595210f3b9badce814ae728b891e20d6ca7e08fcfaad0bc4b2f58b3",
1529 "upstreamSha256": "7c7f27b89595210f3b9badce814ae728b891e20d6ca7e08fcfaad0bc4b2f58b3"
1530 },
1531 {
1532 "path": "styles/dist/types_unprefixed/components/controls/_disconnect-button.scss.d.ts",
1533 "package": "styles",
1534 "sha256": "86092ffabebfd5b8c96b9aa27527a695dd02cf472e4d1c9b07ba01a4f10e8b02",
1535 "upstreamSha256": "86092ffabebfd5b8c96b9aa27527a695dd02cf472e4d1c9b07ba01a4f10e8b02"
1536 },
1537 {
1538 "path": "styles/dist/types_unprefixed/components/controls/_media-device-select.scss.d.ts",
1539 "package": "styles",
1540 "sha256": "27d824f0b159f22ca56193931e8399353b92c89cc80c9710d960de1fa265d9b7",
1541 "upstreamSha256": "27d824f0b159f22ca56193931e8399353b92c89cc80c9710d960de1fa265d9b7"
1542 },
1543 {
1544 "path": "styles/dist/types_unprefixed/components/controls/_pagination-control.scss.d.ts",
1545 "package": "styles",
1546 "sha256": "a10b28ae6d0c3c5c6f29e5fc3c0ea00d426aeb6e918421ae9e0496be61ee2982",
1547 "upstreamSha256": "a10b28ae6d0c3c5c6f29e5fc3c0ea00d426aeb6e918421ae9e0496be61ee2982"
1548 },
1549 {
1550 "path": "styles/dist/types_unprefixed/components/controls/_pagination-indicator.scss.d.ts",
1551 "package": "styles",
1552 "sha256": "d5d01ccfe93e7b7ace7c06497689acf3b348836bf250e63b08ead9953dd901ca",
1553 "upstreamSha256": "d5d01ccfe93e7b7ace7c06497689acf3b348836bf250e63b08ead9953dd901ca"
1554 },
1555 {
1556 "path": "styles/dist/types_unprefixed/components/controls/_start-audio.scss.d.ts",
1557 "package": "styles",
1558 "sha256": "8ee292e818fb8212131aa19cdef2b7fb6f7bb68df0802621848b0f7f3dff1bb3",
1559 "upstreamSha256": "8ee292e818fb8212131aa19cdef2b7fb6f7bb68df0802621848b0f7f3dff1bb3"
1560 },
1561 {
1562 "path": "styles/dist/types_unprefixed/components/index.scss.d.ts",
1563 "package": "styles",
1564 "sha256": "c6e1743566bef0618be6d595d87c492491bbd2121f58f66495b76493d53222ef",
1565 "upstreamSha256": "c6e1743566bef0618be6d595d87c492491bbd2121f58f66495b76493d53222ef"
1566 },
1567 {
1568 "path": "styles/dist/types_unprefixed/components/layout/index.scss.d.ts",
1569 "package": "styles",
1570 "sha256": "0f35fe7ee88f2c67aea6766191fefa4c436fa8d4209330126557dbd888ccc36d",
1571 "upstreamSha256": "0f35fe7ee88f2c67aea6766191fefa4c436fa8d4209330126557dbd888ccc36d"
1572 },
1573 {
1574 "path": "styles/dist/types_unprefixed/components/layout/_carousel-view.scss.d.ts",
1575 "package": "styles",
1576 "sha256": "da7cf18bb713a53989efb3dc8bee5fbd1865de4f9bc01c8d714980045b6214e5",
1577 "upstreamSha256": "da7cf18bb713a53989efb3dc8bee5fbd1865de4f9bc01c8d714980045b6214e5"
1578 },
1579 {
1580 "path": "styles/dist/types_unprefixed/components/layout/_focus-layout.scss.d.ts",
1581 "package": "styles",
1582 "sha256": "06726c6e8586e1bcead103fed9457e4ff4549396a72c19a559d1d7b9a7844a75",
1583 "upstreamSha256": "06726c6e8586e1bcead103fed9457e4ff4549396a72c19a559d1d7b9a7844a75"
1584 },
1585 {
1586 "path": "styles/dist/types_unprefixed/components/layout/_grid-layout.scss.d.ts",
1587 "package": "styles",
1588 "sha256": "e10592085ab3e4b3fca898ce6b03b82c7257326a6354f2e1254efe72b126de7d",
1589 "upstreamSha256": "e10592085ab3e4b3fca898ce6b03b82c7257326a6354f2e1254efe72b126de7d"
1590 },
1591 {
1592 "path": "styles/dist/types_unprefixed/components/participant/index.scss.d.ts",
1593 "package": "styles",
1594 "sha256": "35aff480631cd08f8c5974f48fde8c68454a57baa0746b6262dec1d3e30cb91b",
1595 "upstreamSha256": "35aff480631cd08f8c5974f48fde8c68454a57baa0746b6262dec1d3e30cb91b"
1596 },
1597 {
1598 "path": "styles/dist/types_unprefixed/components/participant/_audio-visualizer.scss.d.ts",
1599 "package": "styles",
1600 "sha256": "a6467f0bd9d644198837738bd634a5efe43f581314716d76e241a3d9e198e3dd",
1601 "upstreamSha256": "a6467f0bd9d644198837738bd634a5efe43f581314716d76e241a3d9e198e3dd"
1602 },
1603 {
1604 "path": "styles/dist/types_unprefixed/components/participant/_connection-quality.scss.d.ts",
1605 "package": "styles",
1606 "sha256": "42cc6fe3e31f03a1808eb5c4d87e3884387c58b6027096fa3560ab88c5a5b59e",
1607 "upstreamSha256": "42cc6fe3e31f03a1808eb5c4d87e3884387c58b6027096fa3560ab88c5a5b59e"
1608 },
1609 {
1610 "path": "styles/dist/types_unprefixed/components/participant/_participant-media.scss.d.ts",
1611 "package": "styles",
1612 "sha256": "001612ba4e370eb5d23eacdb89dab8355596184f1fbdab4a78741d981acbd510",
1613 "upstreamSha256": "001612ba4e370eb5d23eacdb89dab8355596184f1fbdab4a78741d981acbd510"
1614 },
1615 {
1616 "path": "styles/dist/types_unprefixed/components/participant/_participant-name.scss.d.ts",
1617 "package": "styles",
1618 "sha256": "c32cc96dd64acf7ce95adba68d64306a3a0a2dc809fd50ca5abe30201ca3aa62",
1619 "upstreamSha256": "c32cc96dd64acf7ce95adba68d64306a3a0a2dc809fd50ca5abe30201ca3aa62"
1620 },
1621 {
1622 "path": "styles/dist/types_unprefixed/components/participant/_participant-tile.scss.d.ts",
1623 "package": "styles",
1624 "sha256": "00e025c91e8ec927fe8a20b4cf713b9ee0c37fb621cfe38fc02c8b4ab7699232",
1625 "upstreamSha256": "00e025c91e8ec927fe8a20b4cf713b9ee0c37fb621cfe38fc02c8b4ab7699232"
1626 },
1627 {
1628 "path": "styles/dist/types_unprefixed/components/participant/_track-muted-indicator.scss.d.ts",
1629 "package": "styles",
1630 "sha256": "2293f302e4d3eae3ce2a1b4b6ccfbc9153d8bdedd4310ecf2f6c8e3da064549e",
1631 "upstreamSha256": "2293f302e4d3eae3ce2a1b4b6ccfbc9153d8bdedd4310ecf2f6c8e3da064549e"
1632 },
1633 {
1634 "path": "styles/dist/types_unprefixed/components/_room.scss.d.ts",
1635 "package": "styles",
1636 "sha256": "f3f369777a8bc38eb1978977826673bf1a5aee9959a094507c8fddb727ca54b5",
1637 "upstreamSha256": "f3f369777a8bc38eb1978977826673bf1a5aee9959a094507c8fddb727ca54b5"
1638 },
1639 {
1640 "path": "styles/dist/types_unprefixed/components/_toast.scss.d.ts",
1641 "package": "styles",
1642 "sha256": "2be48ad0b5450dcb84dff5f7d4a8934ed5c7464236294c13bfc6c7d92b8f7643",
1643 "upstreamSha256": "2be48ad0b5450dcb84dff5f7d4a8934ed5c7464236294c13bfc6c7d92b8f7643"
1644 },
1645 {
1646 "path": "styles/dist/types_unprefixed/index.scss.d.ts",
1647 "package": "styles",
1648 "sha256": "2e455040dd69a4d167fcfb4062f51a3c1379e31569db688518ff4139e003c477",
1649 "upstreamSha256": "2e455040dd69a4d167fcfb4062f51a3c1379e31569db688518ff4139e003c477"
1650 },
1651 {
1652 "path": "styles/dist/types_unprefixed/prefabs/audio-conference.scss.d.ts",
1653 "package": "styles",
1654 "sha256": "17b934cf78827147899504134e1684a0553bce41651af036f2f96d5ca905b472",
1655 "upstreamSha256": "17b934cf78827147899504134e1684a0553bce41651af036f2f96d5ca905b472"
1656 },
1657 {
1658 "path": "styles/dist/types_unprefixed/prefabs/chat.scss.d.ts",
1659 "package": "styles",
1660 "sha256": "279077b7a00bc45272171c7091e7246de724b66cf46700a5d66ca1782d30e27a",
1661 "upstreamSha256": "279077b7a00bc45272171c7091e7246de724b66cf46700a5d66ca1782d30e27a"
1662 },
1663 {
1664 "path": "styles/dist/types_unprefixed/prefabs/control-bar.scss.d.ts",
1665 "package": "styles",
1666 "sha256": "20a51d608a719340200ec49a42d2aed6e3fd29f6023b8b1c5d58f792ad9fab33",
1667 "upstreamSha256": "20a51d608a719340200ec49a42d2aed6e3fd29f6023b8b1c5d58f792ad9fab33"
1668 },
1669 {
1670 "path": "styles/dist/types_unprefixed/prefabs/index.scss.d.ts",
1671 "package": "styles",
1672 "sha256": "c1ad4125f7babc4e62f8d1ebd6291b1ba0ad489dab52a750303619739d2b076b",
1673 "upstreamSha256": "c1ad4125f7babc4e62f8d1ebd6291b1ba0ad489dab52a750303619739d2b076b"
1674 },
1675 {
1676 "path": "styles/dist/types_unprefixed/prefabs/prejoin.scss.d.ts",
1677 "package": "styles",
1678 "sha256": "ec03a358fde5ae22050d0a78362b65ce5a769636dd430040f0f35eb86fbbf652",
1679 "upstreamSha256": "ec03a358fde5ae22050d0a78362b65ce5a769636dd430040f0f35eb86fbbf652"
1680 },
1681 {
1682 "path": "styles/dist/types_unprefixed/prefabs/video-conference.scss.d.ts",
1683 "package": "styles",
1684 "sha256": "b01eb3840b1cb433fcadeb442445e45866ba6d0e53f5591024b4ddb65e362cbb",
1685 "upstreamSha256": "b01eb3840b1cb433fcadeb442445e45866ba6d0e53f5591024b4ddb65e362cbb"
1686 },
1687 {
1688 "path": "styles/dist/types_unprefixed/themes/huddle.scss.d.ts",
1689 "package": "styles",
1690 "sha256": "d4c1e171211815e58258bf502461d345649f80557ecaf164315fbe4f9aee1420",
1691 "upstreamSha256": "d4c1e171211815e58258bf502461d345649f80557ecaf164315fbe4f9aee1420"
1692 },
1693 {
1694 "path": "styles/dist/types_unprefixed/_preflight.scss.d.ts",
1695 "package": "styles",
1696 "sha256": "c8a3cca3b0ad556348276fd3210ab5f5be57cabb06db98d3e35060f6f64267ac",
1697 "upstreamSha256": "c8a3cca3b0ad556348276fd3210ab5f5be57cabb06db98d3e35060f6f64267ac"
1698 }
1699 ]
1700}
Added web/src/shared/voice/host.tsx web/src/shared/voice/host.tsx +37 −0 View web/src/shared/voice/host.tsx
@@ -0,0 +1,37 @@
1/**
2 * The React edge of the voice session.
3 *
4 * `voiceSession` owns the single tab-wide Room (see session.ts), so the
5 * vendored components are fed that same instance through `RoomContext` instead
6 * of the official `LiveKitRoom`, which would tie the connection to a route.
7 * `VoiceHost` mounts the parts that must keep working while the user navigates
8 * away from the channel: remote audio playback and the connection toast.
9 */
10import type { ReactNode } from 'react'
11import { ConnectionStateToast, RoomAudioRenderer, RoomContext } from '@/shared/livekit'
12import { useVoiceSession } from './react'
13import { voiceSession } from './session'
14
15/** Injects the live Room for any subtree that renders official components. */
16export function VoiceProvider({ children }: { children: ReactNode }) {
17 const session = useVoiceSession()
18 const room = session.roomId ? voiceSession.getRoom() : null
19 if (!room) return null
20 return <RoomContext.Provider value={room}>{children}</RoomContext.Provider>
21}
22
23/**
24 * Route-independent audio + connection feedback. Mount once, at the root: the
25 * toast carries lk- classes, so this wrapper owns the theme attribute that
26 * scopes the vendored stylesheet.
27 */
28export function VoiceHost() {
29 return (
30 <VoiceProvider>
31 <div className="voice-host" data-lk-theme="default">
32 <RoomAudioRenderer />
33 <ConnectionStateToast />
34 </div>
35 </VoiceProvider>
36 )
37}