diff --git a/src/frontend/src/features/layout/components/FocusArea.tsx b/src/frontend/src/features/layout/components/FocusArea.tsx
new file mode 100644
index 00000000..d8949b1d
--- /dev/null
+++ b/src/frontend/src/features/layout/components/FocusArea.tsx
@@ -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 (
+
+
+
+
+
+
+ )
+}
+
+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',
+ },
+ },
+})
diff --git a/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx b/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx
new file mode 100644
index 00000000..8b2439b7
--- /dev/null
+++ b/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx
@@ -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 (
+
+ {tracks.map((track) => (
+
+
+
+ ))}
+
+ )
+ }
+)
+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',
+ },
+ },
+})
diff --git a/src/frontend/src/features/layout/utils/screenShareLayout.ts b/src/frontend/src/features/layout/utils/screenShareLayout.ts
new file mode 100644
index 00000000..073494a5
--- /dev/null
+++ b/src/frontend/src/features/layout/utils/screenShareLayout.ts
@@ -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
+): TrackReferenceOrPlaceholder[] {
+ return screenShareTracks.filter(
+ (track) =>
+ track.publication.trackSid &&
+ !knownTrackSids.has(track.publication.trackSid)
+ )
+}
+
+export function syncKnownScreenShareSids(
+ screenShareTracks: TrackReferenceOrPlaceholder[],
+ knownTrackSids: Set
+): 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 }
+}
diff --git a/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts
index d462a64d..b8909f16 100644
--- a/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts
+++ b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts
@@ -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 { useCallback } from 'react'
import type { MouseEvent } from 'react'
import Source = Track.Source
-export const useFocusToggleParticipant = (participant: Participant) => {
- const trackRef = {
- participant: participant,
- publication: participant.getTrackPublication(Source.Camera),
- source: Source.Camera,
- }
+const getParticipantPinTrackRef = (participant: Participant) => {
+ const screenSharePublication = participant.getTrackPublication(
+ Source.ScreenShare
+ )
+ 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,
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 syntheticEvent = {
preventDefault: () => {},
diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
index 13d476fb..27fafe06 100644
--- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
+++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
@@ -20,7 +20,6 @@ import {
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'
@@ -39,8 +38,16 @@ 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 { FocusArea } from '@/features/layout/components/FocusArea'
import { GridLayout } from '@/features/layout/components/GridLayout'
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 { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
@@ -73,6 +80,7 @@ export interface VideoConferenceProps extends React.HTMLAttributes(null)
+ const knownScreenShareTrackSids = useRef>(new Set())
const lastPinnedParticipantIdentityRef = useRef(null)
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
@@ -114,14 +122,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useNoiseReduction()
- const screenShareTracks = tracks
- .filter(isTrackReference)
- .filter((track) => track.publication.source === Track.Source.ScreenShare)
+ const screenShareTracks = getSubscribedScreenShareTracks(tracks)
const focusTrack = usePinnedTracks(layoutContext)?.[0]
- const carouselTracks = tracks.filter(
- (track) => !isEqualTrackRef(track, focusTrack)
- )
+ const { carouselTracks, secondaryScreenShareTracks } =
+ splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks)
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
@@ -181,31 +186,48 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/* 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]
+ const newScreenShares = getNewScreenShareTracks(
+ screenShareTracks,
+ knownScreenShareTrackSids.current
+ )
+ syncKnownScreenShareSids(
+ screenShareTracks,
+ knownScreenShareTrackSids.current
+ )
+
+ if (newScreenShares.length > 0) {
+ const newestScreenShare = getNewestScreenShareTrack(newScreenShares)
+ if (newestScreenShare) {
+ log.debug('Auto set screen share focus:', {
+ newScreenShareTrack: newestScreenShare,
+ })
+ layoutContext.pin.dispatch?.({
+ msg: 'set_pin',
+ trackReference: newestScreenShare,
+ })
+ lastAutoFocusedScreenShareTrack.current = newestScreenShare
+ }
} else if (
- lastAutoFocusedScreenShareTrack.current &&
- !screenShareTracks.some(
- (track) =>
- track.publication.trackSid ===
- lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
- )
+ focusTrack?.source === Track.Source.ScreenShare &&
+ !screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack))
) {
- log.debug('Auto clearing screen share focus.')
- layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
- lastAutoFocusedScreenShareTrack.current = null
+ const newestRemaining = getNewestScreenShareTrack(screenShareTracks)
+ if (newestRemaining) {
+ 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)) {
const updatedFocusTrack = tracks.find(
(tr) =>
@@ -281,7 +303,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
>
- {focusTrack && }
+ {focusTrack && (
+
+ )}
)}