mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-24 17:27:02 +00:00
bf6f7430e7
Mark JS imports as type-only when applicable so they are stripped at build time and ignored during chunk splitting, ensuring we take full advantage of Rollup's code-splitting optimizations.
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
import {
|
|
isEqualTrackRef,
|
|
isTrackReference,
|
|
isWeb,
|
|
log,
|
|
} from '@livekit/components-core'
|
|
import { type Participant, RoomEvent, Track } from 'livekit-client'
|
|
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
|
import {
|
|
ConnectionStateToast,
|
|
FocusLayoutContainer,
|
|
LayoutContextProvider,
|
|
RoomAudioRenderer,
|
|
usePinnedTracks,
|
|
useTracks,
|
|
useCreateLayoutContext,
|
|
useRoomContext,
|
|
} from '@livekit/components-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { ControlBar } from './ControlBar/ControlBar'
|
|
import { FocusLayout } from '../components/FocusLayout'
|
|
import { ParticipantTile } from '../components/ParticipantTile'
|
|
import { SidePanel } from '../components/SidePanel'
|
|
import { RecordingProvider } from '@/features/recording'
|
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
import { useSettingsDialog } from '@/features/settings'
|
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
|
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
|
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
|
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
/** @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
|
|
* <LiveKitRoom>
|
|
* <VideoConference />
|
|
* <LiveKitRoom>
|
|
* ```
|
|
* @public
|
|
*/
|
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
|
const lastAutoFocusedScreenShareTrack =
|
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
|
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
|
const { t: tRooms } = useTranslation('rooms')
|
|
const room = useRoomContext()
|
|
const announce = useScreenReaderAnnounce()
|
|
const { toggleSettingsDialog } = useSettingsDialog()
|
|
|
|
const getAnnouncementName = useCallback(
|
|
(participant?: Participant | null) => {
|
|
if (!participant) return tRooms('participants.unknown')
|
|
return participant.isLocal
|
|
? tRooms('participants.you')
|
|
: getParticipantName(participant)
|
|
},
|
|
[tRooms]
|
|
)
|
|
|
|
useConnectionObserver()
|
|
useRoomPageTitle()
|
|
useVideoResolutionSubscription()
|
|
useSyncLiveKitMetadata()
|
|
|
|
useRegisterKeyboardShortcut({
|
|
id: 'open-shortcuts',
|
|
handler: useCallback(() => {
|
|
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
|
|
}, [toggleSettingsDialog]),
|
|
})
|
|
|
|
const tracks = useTracks(
|
|
[
|
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
|
],
|
|
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
|
|
)
|
|
|
|
const layoutContext = useCreateLayoutContext()
|
|
|
|
useNoiseReduction()
|
|
|
|
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)
|
|
)
|
|
|
|
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
|
|
|
// handle pin announcements
|
|
|
|
useEffect(() => {
|
|
const participant = focusTrack?.participant
|
|
|
|
// 1. unpin
|
|
if (!participant) {
|
|
if (!lastPinnedParticipantIdentityRef.current) return
|
|
|
|
const lastIdentity = lastPinnedParticipantIdentityRef.current
|
|
const lastParticipant =
|
|
room.localParticipant.identity === lastIdentity
|
|
? room.localParticipant
|
|
: room.remoteParticipants.get(lastIdentity)
|
|
const announcementName = getAnnouncementName(lastParticipant)
|
|
|
|
announce(
|
|
lastParticipant?.isLocal
|
|
? t('self.unpin')
|
|
: t('unpin', {
|
|
name: announcementName,
|
|
})
|
|
)
|
|
|
|
lastPinnedParticipantIdentityRef.current = null
|
|
return
|
|
}
|
|
|
|
// 2. same pin → do nothing
|
|
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
|
|
return
|
|
}
|
|
|
|
// 3. new pin
|
|
const participantName = participant.isLocal
|
|
? tRooms('participants.you')
|
|
: getParticipantName(participant)
|
|
|
|
lastPinnedParticipantIdentityRef.current = participant.identity
|
|
|
|
announce(
|
|
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
|
|
)
|
|
}, [
|
|
announce,
|
|
focusTrack,
|
|
getAnnouncementName,
|
|
room.localParticipant,
|
|
room.remoteParticipants,
|
|
t,
|
|
tRooms,
|
|
])
|
|
|
|
/* eslint-disable react-hooks/exhaustive-deps */
|
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
|
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 */
|
|
|
|
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
|
|
|
return (
|
|
<div
|
|
className="lk-video-conference"
|
|
{...props}
|
|
style={{
|
|
overflowX: 'hidden',
|
|
}}
|
|
>
|
|
{isWeb() && (
|
|
<LayoutContextProvider
|
|
value={layoutContext}
|
|
// onPinChange={handleFocusStateChange}
|
|
>
|
|
<ScreenShareErrorModal
|
|
isOpen={isShareErrorVisible}
|
|
onClose={() => setIsShareErrorVisible(false)}
|
|
/>
|
|
<IsIdleDisconnectModal />
|
|
<RoomContentArea>
|
|
{isPictureInPictureOpen ? (
|
|
<PipRoomPlaceholder />
|
|
) : (
|
|
<>
|
|
{!focusTrack ? (
|
|
<div
|
|
className="lk-grid-layout-wrapper"
|
|
style={{ height: 'auto' }}
|
|
>
|
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
|
<ParticipantTile />
|
|
</GridLayout>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="lk-focus-layout-wrapper"
|
|
style={{ height: 'auto' }}
|
|
>
|
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
<CarouselLayout
|
|
tracks={carouselTracks}
|
|
style={{
|
|
minWidth: '200px',
|
|
}}
|
|
>
|
|
<ParticipantTile />
|
|
</CarouselLayout>
|
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
|
</FocusLayoutContainer>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</RoomContentArea>
|
|
<ControlBar
|
|
onDeviceError={(e) => {
|
|
console.error(e)
|
|
if (
|
|
e.source == Track.Source.ScreenShare &&
|
|
e.error.toString() ==
|
|
'NotAllowedError: Permission denied by system'
|
|
) {
|
|
setIsShareErrorVisible(true)
|
|
}
|
|
}}
|
|
/>
|
|
<SidePanel />
|
|
</LayoutContextProvider>
|
|
)}
|
|
<RoomAudioRenderer />
|
|
<ConnectionStateToast />
|
|
<RecordingProvider />
|
|
<SettingsDialogProvider />
|
|
<ReactionPortals />
|
|
</div>
|
|
)
|
|
}
|