Compare commits

...

4 Commits

Author SHA1 Message Date
Cyril 26cb99b81c (frontend) apply OneToOneFocusLayout to main room 1-to-1
Show remote fullscreen with local thumbnail when no pin.
2026-08-26 10:26:12 +02:00
Cyril 5bc04409de ♻️(frontend) use OneToOneFocusLayout in PiP stage
Replace PipFocusLayout with the shared focus layout.
2026-08-26 10:25:48 +02:00
Cyril 78a06ab55f (frontend) add shared OneToOneFocusLayout component
Reuse the PiP main + thumbnail pattern for room and PiP.
2026-08-26 10:25:48 +02:00
Cyril 1443ef12eb ♻️(frontend) extract getTrackKey to shared layout utils
Move track key helper out of pip so layouts can reuse it.
2026-08-26 10:25:48 +02:00
9 changed files with 167 additions and 93 deletions
+1
View File
@@ -75,6 +75,7 @@ db.sqlite3
# IDEs
.idea/
.vscode/
.cursor/
*.iml
.devcontainer
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
## [1.29.0] - 2026-08-25
### Added
@@ -0,0 +1,119 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
type OneToOneFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
disableTileControls?: boolean
/** Controls thumbnail dimensions 'pip' for small PiP window, 'room' for the main viewport. */
context?: 'pip' | 'room'
}
/**
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
* with an optional thumbnail overlay at the bottom-right.
*
* Shared between PiP and the main room pass `disableTileControls` in PiP
* where hover controls should be hidden.
*/
export const OneToOneFocusLayout = memo(
({
mainTrack,
thumbnailTrack,
disableTileControls,
context = 'room',
}: OneToOneFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls={disableTileControls}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail context={context}>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls={disableTileControls}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled(
'div',
cva({
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
variants: {
context: {
pip: {
width: '42%',
maxWidth: '220px',
minWidth: '140px',
},
room: {
width: '20%',
maxWidth: '320px',
minWidth: '180px',
},
},
},
defaultVariants: {
context: 'room',
},
})
)
@@ -12,7 +12,8 @@ import {
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
@@ -36,6 +37,29 @@ export const StageLayout = () => {
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
const cameraTracks = useMemo(
() => tracks.filter((t) => t.source === Track.Source.Camera),
[tracks]
)
const isOneToOne =
!pinnedTrackRef &&
screenShareTracks.length === 0 &&
cameraTracks.length <= 2
const oneToOneMainTrack = useMemo(() => {
if (!isOneToOne) return undefined
const remote = cameraTracks.find((t) => !t.participant?.isLocal)
const local = cameraTracks.find((t) => t.participant?.isLocal)
return remote ?? local
}, [isOneToOne, cameraTracks])
const oneToOneThumbnailTrack = useMemo(() => {
if (!isOneToOne) return undefined
const local = cameraTracks.find((t) => t.participant?.isLocal)
return oneToOneMainTrack === local ? undefined : local
}, [isOneToOne, cameraTracks, oneToOneMainTrack])
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
@@ -87,7 +111,12 @@ export const StageLayout = () => {
return (
<>
{!pinnedTrackRef ? (
{isOneToOne ? (
<OneToOneFocusLayout
mainTrack={oneToOneMainTrack}
thumbnailTrack={oneToOneThumbnailTrack}
/>
) : !pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
@@ -1,86 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
@@ -2,7 +2,7 @@ import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout'
@@ -59,9 +59,11 @@ export const PipStage = () => {
if (cameraTracks.length <= 1) {
return (
<StageFrame>
<PipFocusLayout
<OneToOneFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
disableTileControls
context="pip"
/>
</StageFrame>
)
@@ -99,7 +101,12 @@ export const PipStage = () => {
return (
<StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
<OneToOneFocusLayout
mainTrack={mainTrack}
thumbnailTrack={thumbnailTrack}
disableTileControls
context="pip"
/>
</StageFrame>
)
}