mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 20:27:01 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7d89600ea |
@@ -0,0 +1,53 @@
|
|||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { FocusLayout } from '@/features/rooms/livekit/components/FocusLayout'
|
||||||
|
import { SecondaryScreenShareStrip } from './SecondaryScreenShareStrip'
|
||||||
|
|
||||||
|
export interface FocusAreaProps {
|
||||||
|
focusTrack: TrackReferenceOrPlaceholder
|
||||||
|
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main focus track with an optional strip of secondary screen shares below.
|
||||||
|
*/
|
||||||
|
export function FocusArea({
|
||||||
|
focusTrack,
|
||||||
|
secondaryScreenShareTracks,
|
||||||
|
}: FocusAreaProps) {
|
||||||
|
return (
|
||||||
|
<FocusColumn>
|
||||||
|
<MainFocusSlot>
|
||||||
|
<FocusLayout trackRef={focusTrack} />
|
||||||
|
</MainFocusSlot>
|
||||||
|
<SecondaryScreenShareStrip tracks={secondaryScreenShareTracks} />
|
||||||
|
</FocusColumn>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FocusColumn = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const MainFocusSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { getTrackKey } from '@/features/pip/utils/pipTrackSelection'
|
||||||
|
|
||||||
|
export interface SecondaryScreenShareStripProps {
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact horizontal strip for non-focused screen shares.
|
||||||
|
* Each tile keeps pin controls so users can switch focus to any share.
|
||||||
|
*/
|
||||||
|
export const SecondaryScreenShareStrip = memo(
|
||||||
|
({ tracks }: SecondaryScreenShareStripProps) => {
|
||||||
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StripContainer>
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<ShareTile key={getTrackKey(track)}>
|
||||||
|
<ParticipantTile trackRef={track} />
|
||||||
|
</ShareTile>
|
||||||
|
))}
|
||||||
|
</StripContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SecondaryScreenShareStrip.displayName = 'SecondaryScreenShareStrip'
|
||||||
|
|
||||||
|
const StripContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '120px',
|
||||||
|
minHeight: '80px',
|
||||||
|
maxHeight: '140px',
|
||||||
|
padding: '0.5rem 0 0',
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ShareTile = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
height: '100%',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
minWidth: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
isTrackReference,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
|
export function getSubscribedScreenShareTracks(
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): TrackReferenceOrPlaceholder[] {
|
||||||
|
return tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter(
|
||||||
|
(track) =>
|
||||||
|
track.publication.source === Track.Source.ScreenShare &&
|
||||||
|
track.publication.isSubscribed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNewScreenShareTracks(
|
||||||
|
screenShareTracks: TrackReferenceOrPlaceholder[],
|
||||||
|
knownTrackSids: Set<string>
|
||||||
|
): TrackReferenceOrPlaceholder[] {
|
||||||
|
return screenShareTracks.filter(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid &&
|
||||||
|
!knownTrackSids.has(track.publication.trackSid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncKnownScreenShareSids(
|
||||||
|
screenShareTracks: TrackReferenceOrPlaceholder[],
|
||||||
|
knownTrackSids: Set<string>
|
||||||
|
): void {
|
||||||
|
const currentSids = new Set(
|
||||||
|
screenShareTracks
|
||||||
|
.map((track) => track.publication.trackSid)
|
||||||
|
.filter((sid): sid is string => !!sid)
|
||||||
|
)
|
||||||
|
|
||||||
|
screenShareTracks.forEach((track) => {
|
||||||
|
if (track.publication.trackSid) {
|
||||||
|
knownTrackSids.add(track.publication.trackSid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
knownTrackSids.forEach((sid) => {
|
||||||
|
if (!currentSids.has(sid)) {
|
||||||
|
knownTrackSids.delete(sid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNewestScreenShareTrack(
|
||||||
|
screenShareTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): TrackReferenceOrPlaceholder | undefined {
|
||||||
|
if (screenShareTracks.length === 0) return undefined
|
||||||
|
return screenShareTracks[screenShareTracks.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTracksForFocusLayout(
|
||||||
|
tracks: TrackReferenceOrPlaceholder[],
|
||||||
|
focusTrack: TrackReferenceOrPlaceholder | undefined,
|
||||||
|
screenShareTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): {
|
||||||
|
carouselTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
} {
|
||||||
|
const hasActiveScreenShares = screenShareTracks.length > 0
|
||||||
|
|
||||||
|
const secondaryScreenShareTracks = screenShareTracks.filter(
|
||||||
|
(track) => !focusTrack || !isEqualTrackRef(track, focusTrack)
|
||||||
|
)
|
||||||
|
|
||||||
|
const carouselTracks = tracks.filter((track) => {
|
||||||
|
if (focusTrack && isEqualTrackRef(track, focusTrack)) return false
|
||||||
|
if (hasActiveScreenShares && track.source === Track.Source.ScreenShare) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return { carouselTracks, secondaryScreenShareTracks }
|
||||||
|
}
|
||||||
@@ -1,21 +1,52 @@
|
|||||||
import { useFocusToggle } from '@livekit/components-react'
|
import {
|
||||||
|
isTrackReferencePinned,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { useFocusToggle, useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
import { type Participant, Track } from 'livekit-client'
|
import { type Participant, Track } from 'livekit-client'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { MouseEvent } from 'react'
|
import type { MouseEvent } from 'react'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
export const useFocusToggleParticipant = (participant: Participant) => {
|
const getParticipantPinTrackRef = (participant: Participant) => {
|
||||||
const trackRef = {
|
const screenSharePublication = participant.getTrackPublication(
|
||||||
participant: participant,
|
Source.ScreenShare
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
)
|
||||||
source: Source.Camera,
|
const source =
|
||||||
}
|
participant.isScreenShareEnabled && screenSharePublication
|
||||||
|
? Source.ScreenShare
|
||||||
|
: Source.Camera
|
||||||
|
|
||||||
const { mergedProps, inFocus } = useFocusToggle({
|
return {
|
||||||
|
participant,
|
||||||
|
publication: participant.getTrackPublication(source),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFocusToggleParticipant = (participant: Participant) => {
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
const trackRef = getParticipantPinTrackRef(participant)
|
||||||
|
|
||||||
|
const { mergedProps, inFocus: isFocusedTrack } = useFocusToggle({
|
||||||
trackRef,
|
trackRef,
|
||||||
props: {},
|
props: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const inFocus =
|
||||||
|
isFocusedTrack ||
|
||||||
|
(!!layoutContext?.pin.state &&
|
||||||
|
[Source.Camera, Source.ScreenShare].some((source) => {
|
||||||
|
const ref = {
|
||||||
|
participant,
|
||||||
|
publication: participant.getTrackPublication(source),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!!ref.publication &&
|
||||||
|
isTrackReferencePinned(ref, layoutContext.pin.state)
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
preventDefault: () => {},
|
preventDefault: () => {},
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar/ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
import { FocusLayout } from '../components/FocusLayout'
|
|
||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
@@ -39,8 +38,16 @@ import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
|
import { FocusArea } from '@/features/layout/components/FocusArea'
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
|
import {
|
||||||
|
getNewScreenShareTracks,
|
||||||
|
getNewestScreenShareTrack,
|
||||||
|
getSubscribedScreenShareTracks,
|
||||||
|
splitTracksForFocusLayout,
|
||||||
|
syncKnownScreenShareSids,
|
||||||
|
} from '@/features/layout/utils/screenShareLayout'
|
||||||
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
||||||
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||||
const lastAutoFocusedScreenShareTrack =
|
const lastAutoFocusedScreenShareTrack =
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
|
const knownScreenShareTrackSids = useRef<Set<string>>(new Set())
|
||||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
@@ -114,14 +122,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
|
|
||||||
useNoiseReduction()
|
useNoiseReduction()
|
||||||
|
|
||||||
const screenShareTracks = tracks
|
const screenShareTracks = getSubscribedScreenShareTracks(tracks)
|
||||||
.filter(isTrackReference)
|
|
||||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
||||||
const carouselTracks = tracks.filter(
|
const { carouselTracks, secondaryScreenShareTracks } =
|
||||||
(track) => !isEqualTrackRef(track, focusTrack)
|
splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks)
|
||||||
)
|
|
||||||
|
|
||||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||||
|
|
||||||
@@ -181,31 +186,48 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
const newScreenShares = getNewScreenShareTracks(
|
||||||
if (
|
screenShareTracks,
|
||||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
knownScreenShareTrackSids.current
|
||||||
lastAutoFocusedScreenShareTrack.current === null
|
)
|
||||||
) {
|
syncKnownScreenShareSids(
|
||||||
log.debug('Auto set screen share focus:', {
|
screenShareTracks,
|
||||||
newScreenShareTrack: screenShareTracks[0],
|
knownScreenShareTrackSids.current
|
||||||
})
|
)
|
||||||
layoutContext.pin.dispatch?.({
|
|
||||||
msg: 'set_pin',
|
if (newScreenShares.length > 0) {
|
||||||
trackReference: screenShareTracks[0],
|
const newestScreenShare = getNewestScreenShareTrack(newScreenShares)
|
||||||
})
|
if (newestScreenShare) {
|
||||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
log.debug('Auto set screen share focus:', {
|
||||||
|
newScreenShareTrack: newestScreenShare,
|
||||||
|
})
|
||||||
|
layoutContext.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference: newestScreenShare,
|
||||||
|
})
|
||||||
|
lastAutoFocusedScreenShareTrack.current = newestScreenShare
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
lastAutoFocusedScreenShareTrack.current &&
|
focusTrack?.source === Track.Source.ScreenShare &&
|
||||||
!screenShareTracks.some(
|
!screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack))
|
||||||
(track) =>
|
|
||||||
track.publication.trackSid ===
|
|
||||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
log.debug('Auto clearing screen share focus.')
|
const newestRemaining = getNewestScreenShareTrack(screenShareTracks)
|
||||||
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
if (newestRemaining) {
|
||||||
lastAutoFocusedScreenShareTrack.current = null
|
log.debug('Auto switching to remaining screen share:', {
|
||||||
|
screenShareTrack: newestRemaining,
|
||||||
|
})
|
||||||
|
layoutContext.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference: newestRemaining,
|
||||||
|
})
|
||||||
|
lastAutoFocusedScreenShareTrack.current = newestRemaining
|
||||||
|
} else {
|
||||||
|
log.debug('Auto clearing screen share focus.')
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusTrack && !isTrackReference(focusTrack)) {
|
if (focusTrack && !isTrackReference(focusTrack)) {
|
||||||
const updatedFocusTrack = tracks.find(
|
const updatedFocusTrack = tracks.find(
|
||||||
(tr) =>
|
(tr) =>
|
||||||
@@ -281,7 +303,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
>
|
>
|
||||||
<ParticipantTile />
|
<ParticipantTile />
|
||||||
</CarouselLayout>
|
</CarouselLayout>
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
{focusTrack && (
|
||||||
|
<FocusArea
|
||||||
|
focusTrack={focusTrack}
|
||||||
|
secondaryScreenShareTracks={
|
||||||
|
secondaryScreenShareTracks
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FocusLayoutContainer>
|
</FocusLayoutContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user