mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-08 14:32:32 +00:00
✨(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:
@@ -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,
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -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>
|
||||
|
||||
+100
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user