✨(frontend) let the local tile leave or keep the corner layout

1:1 local tile stays corner or returns to grid via menu; changes are announced
This commit is contained in:
Ovgodd
2026-10-08 16:08:25 +02:00
parent 7ff2a0c52c
commit e897a1635a
11 changed files with 289 additions and 34 deletions
@@ -4,10 +4,13 @@ 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'
import { GridLayout } from '@/features/layout/components/GridLayout'
type OneToOneFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
mainTracks?: TrackReferenceOrPlaceholder[]
thumbnailTrack?: TrackReferenceOrPlaceholder
thumbnailMinimized?: boolean
disableTileControls?: boolean
/** Controls thumbnail dimensions – 'pip' for small PiP window, 'room' for the main viewport. */
context?: 'pip' | 'room'
@@ -15,7 +18,9 @@ type OneToOneFocusLayoutProps = {
/**
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
* with an optional thumbnail overlay at the bottom-right.
* with an optional thumbnail overlay at the bottom-right. When the user keeps
* their tile in the corner with more participants, remotes share the main area
* as a grid.
*
* Shared between PiP and the main room – pass `disableTileControls` in PiP
* where hover controls should be hidden.
@@ -23,23 +28,37 @@ type OneToOneFocusLayoutProps = {
export const OneToOneFocusLayout = memo(
({
mainTrack,
mainTracks,
thumbnailTrack,
thumbnailMinimized = false,
disableTileControls,
context = 'room',
}: OneToOneFocusLayoutProps) => {
const resolvedMainTracks = mainTracks ?? (mainTrack ? [mainTrack] : [])
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
{resolvedMainTracks.length === 1 && (
<MainSlot letterbox>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
key={getTrackKey(resolvedMainTracks[0])}
trackRef={resolvedMainTracks[0]}
disableTileControls={disableTileControls}
/>
</MainSlot>
)}
{resolvedMainTracks.length > 1 && (
<MainSlot>
<GridLayout
tracks={resolvedMainTracks}
style={{ height: '100%', padding: 0 }}
>
<ParticipantTile disableTileControls={disableTileControls} />
</GridLayout>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail context={context}>
<Thumbnail context={context} minimized={thumbnailMinimized}>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
@@ -71,12 +90,19 @@ const MainSlot = styled('div', {
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
minHeight: 0,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
variants: {
letterbox: {
true: {
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
},
},
})
@@ -111,6 +137,13 @@ const Thumbnail = styled(
minWidth: '180px',
},
},
minimized: {
true: {
width: '14%',
maxWidth: '180px',
minWidth: '128px',
},
},
},
defaultVariants: {
context: 'room',
@@ -14,6 +14,10 @@ import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useMemo, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import {
shouldUseFocusLayout,
splitFocusTracks,
} from '@/features/layout/utils/selfTileLayout'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
@@ -31,7 +35,8 @@ export const StageLayout = () => {
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const { pinnedTrackRef, selfTileLayout, selfTileMinimized } =
useSnapshot(layoutStore)
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, pinnedTrackRef)
@@ -42,23 +47,15 @@ export const StageLayout = () => {
[tracks]
)
const isOneToOne =
const useFocusLayout =
!pinnedTrackRef &&
screenShareTracks.length === 0 &&
cameraTracks.length <= 2
shouldUseFocusLayout(selfTileLayout, cameraTracks)
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])
const { mainTracks, thumbnailTrack } = useMemo(
() => splitFocusTracks(cameraTracks),
[cameraTracks]
)
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
@@ -111,10 +108,11 @@ export const StageLayout = () => {
return (
<>
{isOneToOne ? (
{useFocusLayout ? (
<OneToOneFocusLayout
mainTrack={oneToOneMainTrack}
thumbnailTrack={oneToOneThumbnailTrack}
mainTracks={mainTracks}
thumbnailTrack={thumbnailTrack}
thumbnailMinimized={selfTileLayout === 'corner' && selfTileMinimized}
/>
) : !pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
@@ -0,0 +1,32 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type { SelfTileLayout } from '@/stores/layout'
const remoteTracks = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
cameraTracks.filter((track) => !track.participant?.isLocal)
/**
* Focus layout (main area + optional local thumbnail) instead of the original grid.
* Pin and screen share are handled by the caller: they take the stage over this.
*/
export const shouldUseFocusLayout = (
layout: SelfTileLayout,
cameraTracks: TrackReferenceOrPlaceholder[]
): boolean => {
if (layout === 'classic') return false
if (layout === 'corner') return true
return cameraTracks.length <= 2
}
export const hasRemoteCamera = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
remoteTracks(cameraTracks).length > 0
export const splitFocusTracks = (
cameraTracks: TrackReferenceOrPlaceholder[]
) => {
const local = cameraTracks.find((track) => track.participant?.isLocal)
const remotes = remoteTracks(cameraTracks)
return {
mainTracks: remotes.length > 0 ? remotes : local ? [local] : [],
thumbnailTrack: remotes.length > 0 ? local : undefined,
}
}
@@ -7,6 +7,9 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { SelfTileMenu } from './SelfTileMenu'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
const MOUSE_IDLE_TIME = 3000
@@ -14,6 +17,7 @@ type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
tileRef: React.RefObject<HTMLDivElement | null>
forceVisible?: boolean
}
// Pointer-events none so this overlay doesn't block the zoom surface below.
@@ -24,6 +28,7 @@ const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
forceVisible = false,
}: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
@@ -63,8 +68,7 @@ const FadeOverlay = ({
}
}, [tileRef])
const isVisible = hasKeyboardFocus || active
const isVisible = hasKeyboardFocus || active || forceVisible
return (
<div
className={css({
@@ -99,9 +103,18 @@ export const ParticipantTileFocus = ({
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = participant.isLocal
const canMute = useCanMute(participant)
const [menuOpen, setMenuOpen] = useState(false)
const { pinnedTrackRef } = useSnapshot(layoutStore)
// A pin (screen shares auto-pin) replaces the corner layout with the carousel,
// where the self-tile actions have no effect.
const showSelfTileMenu = isLocal && !isScreenShare && !pinnedTrackRef
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
<FadeOverlay
hasKeyboardFocus={hasKeyboardFocus}
tileRef={tileRef}
forceVisible={menuOpen}
>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -127,6 +140,7 @@ export const ParticipantTileFocus = ({
)}
</>
)}
{showSelfTileMenu && <SelfTileMenu onOpenChange={setMenuOpen} />}
</HStack>
</div>
</FadeOverlay>
@@ -0,0 +1,100 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import {
RiContractLeftLine,
RiExpandRightLine,
RiMoreFill,
RiPictureInPictureExitLine,
RiPictureInPictureLine,
} from '@remixicon/react'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { Button, Menu } from '@/primitives'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { menuRecipe } from '@/primitives/menuRecipe'
import {
hideSelfTile,
layoutStore,
showSelfTile,
toggleSelfTileMinimized,
} from '@/stores/layout'
import {
hasRemoteCamera,
shouldUseFocusLayout,
} from '@/features/layout/utils/selfTileLayout'
type SelfTileMenuProps = {
onOpenChange?: (isOpen: boolean) => void
}
export const SelfTileMenu = ({ onOpenChange }: SelfTileMenuProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const announce = useScreenReaderAnnounce()
const { selfTileLayout, selfTileMinimized } = useSnapshot(layoutStore)
const cameraTracks = useTracks(
[{ source: Track.Source.Camera, withPlaceholder: true }],
{ updateOnlyOn: [], onlySubscribed: false }
)
if (!hasRemoteCamera(cameraTracks)) return null
const isInCorner = shouldUseFocusLayout(selfTileLayout, cameraTracks)
const isMinimized = selfTileLayout === 'corner' && selfTileMinimized
const itemClassName = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<Menu variant="dark" placement="top" onOpenChange={onOpenChange}>
<Button
size="sm"
variant="primaryTextDark"
square
aria-label={t('moreOptions')}
tooltip={t('moreOptions')}
>
<RiMoreFill />
</Button>
<RACMenu style={{ minWidth: '220px' }}>
{isInCorner && (
<MenuItem
className={itemClassName}
onAction={() => {
toggleSelfTileMinimized()
announce(
isMinimized
? t('announcements.expanded')
: t('announcements.reduced')
)
}}
>
{isMinimized ? (
<RiExpandRightLine size={20} />
) : (
<RiContractLeftLine size={20} />
)}
{isMinimized ? t('expand') : t('reduce')}
</MenuItem>
)}
<MenuItem
className={itemClassName}
onAction={() => {
if (isInCorner) {
hideSelfTile()
announce(t('announcements.hidden'))
return
}
showSelfTile()
announce(t('announcements.shown'))
}}
>
{isInCorner ? (
<RiPictureInPictureExitLine size={20} />
) : (
<RiPictureInPictureLine size={20} />
)}
{isInCorner ? t('hideTile') : t('showTile')}
</MenuItem>
</RACMenu>
</Menu>
)
}
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Hintergründe und Effekte anwenden",
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
"fullScreen": "Vollbild",
"moreOptions": "Weitere Optionen",
"reduce": "Verkleinern",
"expand": "Vergrößern",
"hideTile": "Diese Kachel entfernen",
"showTile": "Kachel anzeigen",
"announcements": {
"reduced": "Deine Kachel ist verkleinert.",
"expanded": "Deine Kachel ist vergrößert.",
"shown": "Deine Kachel wird unten rechts angezeigt.",
"hidden": "Das ursprüngliche Layout ist wiederhergestellt."
}
},
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Apply backgrounds and effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
"fullScreen": "Full screen",
"moreOptions": "More options",
"reduce": "Minimize",
"expand": "Enlarge",
"hideTile": "Remove this tile",
"showTile": "Show tile",
"announcements": {
"reduced": "Your tile is minimized.",
"expanded": "Your tile is enlarged.",
"shown": "Your tile is shown at the bottom right.",
"hidden": "The original layout is restored."
}
},
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Arrière-plans et effets",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
"fullScreen": "Plein écran",
"moreOptions": "Plus d'options",
"reduce": "Réduire",
"expand": "Agrandir",
"hideTile": "Supprimer cette tuile",
"showTile": "Afficher la tuile",
"announcements": {
"reduced": "Votre tuile est réduite.",
"expanded": "Votre tuile est agrandie.",
"shown": "Votre tuile est affichée en bas à droite.",
"hidden": "La disposition d’origine est rétablie."
}
},
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Pas visuele effecten toe",
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm"
"fullScreen": "Volledig scherm",
"moreOptions": "Meer opties",
"reduce": "Verkleinen",
"expand": "Vergroten",
"hideTile": "Deze tegel verwijderen",
"showTile": "Tegel weergeven",
"announcements": {
"reduced": "Je tegel is verkleind.",
"expanded": "Je tegel is vergroot.",
"shown": "Je tegel wordt rechtsonder weergegeven.",
"hidden": "De oorspronkelijke indeling is hersteld."
}
},
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
+3 -1
View File
@@ -10,14 +10,16 @@ export const Menu = ({
children,
variant = 'light',
placement,
onOpenChange,
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
placement?: 'bottom' | 'top' | 'left' | 'right'
onOpenChange?: (isOpen: boolean) => void
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
<MenuTrigger onOpenChange={onOpenChange}>
{trigger}
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
+32
View File
@@ -7,6 +7,14 @@ import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
type PinnedTrackRef = ReturnType<typeof ref<TrackReferenceOrPlaceholder>>
/**
* Where the local camera sits in the stage.
* - auto: corner thumbnail in 1-to-1, original grid otherwise
* - corner: keep the local tile bottom-right even with more participants
* - classic: original grid, including in 1-to-1
*/
export type SelfTileLayout = 'auto' | 'corner' | 'classic'
type State = {
showHeader: boolean
showFooter: boolean
@@ -15,6 +23,8 @@ type State = {
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
pinnedTrackRef?: PinnedTrackRef
selfTileLayout: SelfTileLayout
selfTileMinimized: boolean
}
export const layoutStore = proxy<State>({
@@ -25,6 +35,8 @@ export const layoutStore = proxy<State>({
activeSubPanelId: null,
showReactionsToolbar: false,
pinnedTrackRef: undefined,
selfTileLayout: 'auto',
selfTileMinimized: false,
})
export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
@@ -35,6 +47,26 @@ export const clearPinnedTrack = (): void => {
layoutStore.pinnedTrackRef = undefined
}
/** Also opts into the corner layout, so the size choice survives past 2 participants. */
export const toggleSelfTileMinimized = (): void => {
const isMinimized =
layoutStore.selfTileLayout === 'corner' && layoutStore.selfTileMinimized
layoutStore.selfTileLayout = 'corner'
layoutStore.selfTileMinimized = !isMinimized
}
/** Show the local tile as a bottom-right thumbnail and keep it past 2 participants. */
export const showSelfTile = (): void => {
layoutStore.selfTileLayout = 'corner'
layoutStore.selfTileMinimized = false
}
/** Leave the corner thumbnail and return to the original grid. */
export const hideSelfTile = (): void => {
layoutStore.selfTileLayout = 'classic'
layoutStore.selfTileMinimized = false
}
export const closeSidePanel = (): void => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null