diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 8a00ceb0..524bc123 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { formatChatMessageLinks, LiveKitRoom, - VideoConference, type LocalUserChoices, } from '@livekit/components-react' import { Room, RoomOptions } from 'livekit-client' @@ -19,6 +18,8 @@ import { ApiRoom } from '../api/ApiRoom' import { useCreateRoom } from '../api/createRoom' import { InviteDialog } from './InviteDialog' +import { VideoConference } from '../livekit/prefabs/VideoConference' + export const Conference = ({ roomId, userConfig, diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx new file mode 100644 index 00000000..a113b506 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -0,0 +1,206 @@ +import type { + MessageDecoder, + MessageEncoder, + TrackReferenceOrPlaceholder, + WidgetState, +} from '@livekit/components-core' +import { + isEqualTrackRef, + isTrackReference, + isWeb, + log, +} from '@livekit/components-core' +import { RoomEvent, Track } from 'livekit-client' +import * as React from 'react' + +import { + CarouselLayout, + ConnectionStateToast, + FocusLayout, + FocusLayoutContainer, + GridLayout, + LayoutContextProvider, + ParticipantTile, + RoomAudioRenderer, + MessageFormatter, + usePinnedTracks, + useTracks, + useCreateLayoutContext, + ControlBar, + Chat, +} from '@livekit/components-react' + +/** + * @public + */ +export interface VideoConferenceProps + extends React.HTMLAttributes { + chatMessageFormatter?: MessageFormatter + chatMessageEncoder?: MessageEncoder + chatMessageDecoder?: MessageDecoder + /** @alpha */ + SettingsComponent?: React.ComponentType +} + +/** + * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application. + * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers + * of participants, basic non-persistent chat, screen sharing, and more. + * + * @remarks + * The component is implemented with other LiveKit components like `FocusContextProvider`, + * `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`. + * You can use this components as a starting point for your own custom video conferencing application. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function VideoConference({ + chatMessageFormatter, + chatMessageDecoder, + chatMessageEncoder, + SettingsComponent, + ...props +}: VideoConferenceProps) { + const [widgetState, setWidgetState] = React.useState({ + showChat: false, + unreadMessages: 0, + showSettings: false, + }) + const lastAutoFocusedScreenShareTrack = + React.useRef(null) + + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } + ) + + const widgetUpdate = (state: WidgetState) => { + log.debug('updating widget state', state) + setWidgetState(state) + } + + const layoutContext = useCreateLayoutContext() + + const screenShareTracks = tracks + .filter(isTrackReference) + .filter((track) => track.publication.source === Track.Source.ScreenShare) + + const focusTrack = usePinnedTracks(layoutContext)?.[0] + const carouselTracks = tracks.filter( + (track) => !isEqualTrackRef(track, focusTrack) + ) + + /* eslint-disable react-hooks/exhaustive-deps */ + // Code duplicated from LiveKit; this warning will be addressed in the refactoring. + React.useEffect(() => { + // If screen share tracks are published, and no pin is set explicitly, auto set the screen share. + if ( + screenShareTracks.some((track) => track.publication.isSubscribed) && + lastAutoFocusedScreenShareTrack.current === null + ) { + log.debug('Auto set screen share focus:', { + newScreenShareTrack: screenShareTracks[0], + }) + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: screenShareTracks[0], + }) + lastAutoFocusedScreenShareTrack.current = screenShareTracks[0] + } else if ( + lastAutoFocusedScreenShareTrack.current && + !screenShareTracks.some( + (track) => + track.publication.trackSid === + lastAutoFocusedScreenShareTrack.current?.publication?.trackSid + ) + ) { + log.debug('Auto clearing screen share focus.') + layoutContext.pin.dispatch?.({ msg: 'clear_pin' }) + lastAutoFocusedScreenShareTrack.current = null + } + if (focusTrack && !isTrackReference(focusTrack)) { + const updatedFocusTrack = tracks.find( + (tr) => + tr.participant.identity === focusTrack.participant.identity && + tr.source === focusTrack.source + ) + if ( + updatedFocusTrack !== focusTrack && + isTrackReference(updatedFocusTrack) + ) { + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: updatedFocusTrack, + }) + } + } + }, [ + screenShareTracks + .map( + (ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}` + ) + .join(), + focusTrack?.publication?.trackSid, + tracks, + ]) + /* eslint-enable react-hooks/exhaustive-deps */ + + return ( +
+ {isWeb() && ( + +
+ {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )} + +
+ + {SettingsComponent && ( +
+ +
+ )} +
+ )} + + +
+ ) +}